From 46f1587dc098a55c3acfe27063194d29ca906a43 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 7 Jul 2026 14:11:41 +0000 Subject: [PATCH 1/3] feat: query shared workspaces with shared_with_user and hide view when unsupported --- src/core/contextManager.ts | 1 + src/extension.ts | 18 ++++++ src/workspace/workspacesProvider.ts | 47 ++++++++-------- .../unit/workspace/workspacesProvider.test.ts | 55 +++++++------------ 4 files changed, 62 insertions(+), 59 deletions(-) diff --git a/src/core/contextManager.ts b/src/core/contextManager.ts index 6c27c66b8..7e57abd6f 100644 --- a/src/core/contextManager.ts +++ b/src/core/contextManager.ts @@ -4,6 +4,7 @@ const CONTEXT_DEFAULTS = { "coder.authenticated": false, "coder.isOwner": false, "coder.loaded": false, + "coder.sharedWorkspacesSupported": true, "coder.workspace.connected": false, "coder.workspace.updatable": false, "coder.workspacesPanelEnabled": false, diff --git a/src/extension.ts b/src/extension.ts index 1fabc886d..ed35ee97e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -193,9 +193,24 @@ async function doActivate( client, output, deploymentManager.session, + { + // Deployments older than 2.27.0 reject the shared workspaces query; + // hide the view for them (see `when` in package.json). + onQueryRejected: () => + contextManager.set("coder.sharedWorkspacesSupported", false), + }, ); ctx.subscriptions.push(sharedWorkspacesProvider); + // Re-probe shared workspaces support whenever the session changes (login, + // logout, deployment switch): the view re-appears and the next fetch hides + // it again if the deployment still does not support the query. + ctx.subscriptions.push( + deploymentManager.session.onDidChange(() => + contextManager.set("coder.sharedWorkspacesSupported", true), + ), + ); + // createTreeView, unlike registerTreeDataProvider, gives us the tree view API // (so we can see when it is visible) but otherwise they have the same effect. const registerWorkspaceTreeView = ( @@ -328,6 +343,9 @@ async function doActivate( commands.navigateToWorkspaceSettings.bind(commands), ); commandManager.register("coder.refreshWorkspaces", () => { + // Re-probe shared workspaces support in case the deployment was upgraded; + // showing the view triggers its fetch, which hides it again on rejection. + contextManager.set("coder.sharedWorkspacesSupported", true); void myWorkspacesProvider.fetchAndRefresh(); void sharedWorkspacesProvider.fetchAndRefresh(); void allWorkspacesProvider.fetchAndRefresh(); diff --git a/src/workspace/workspacesProvider.ts b/src/workspace/workspacesProvider.ts index 15d4623b1..b6b4e116c 100644 --- a/src/workspace/workspacesProvider.ts +++ b/src/workspace/workspacesProvider.ts @@ -1,3 +1,4 @@ +import { isAxiosError } from "axios"; import { type Workspace, type WorkspaceAgent, @@ -23,40 +24,47 @@ import { type Logger } from "../logging/logger"; import type { SessionData, SessionState } from "../deployment/sessionStore"; export enum WorkspaceQuery { - Mine = "owner:me", - All = "", - Shared = "shared:true", + Mine = "mine", + All = "all", + Shared = "shared", } -/** Per-view rendering behavior, keyed by workspace query. */ +type SignedInSession = Extract; + +/** Per-view rendering behavior and search query, keyed by workspace view. */ interface WorkspaceQueryConfig { readonly showOwner: boolean; readonly showMetadata: boolean; - readonly excludeOwn: boolean; + readonly getQuery: (session: SignedInSession) => string; } const WORKSPACE_QUERY_CONFIG = { [WorkspaceQuery.Mine]: { showOwner: false, showMetadata: true, - excludeOwn: false, + getQuery: () => "owner:me", }, [WorkspaceQuery.All]: { showOwner: true, showMetadata: false, - excludeOwn: false, + getQuery: () => "", }, [WorkspaceQuery.Shared]: { showOwner: true, showMetadata: false, - // `shared:true` also returns workspaces we own and shared out; exclude - // them to leave only those shared with us. - excludeOwn: true, + // Only workspaces shared with the user; excludes workspaces the user + // owns and shared with others. Requires Coder 2.27.0+. + getQuery: (session) => `shared_with_user:${session.user.id}`, }, } as const satisfies Record; export interface WorkspaceProviderOptions { readonly refreshIntervalMs?: number; + /** + * Called when the server rejects the workspaces query with HTTP 400, + * which indicates a deployment that does not support the query filter. + */ + readonly onQueryRejected?: () => void; } /** @@ -127,6 +135,9 @@ export class WorkspaceProvider this.logger.warn("Failed to fetch workspaces:", error); hadError = true; this.setWorkspaces([]); + if (isAxiosError(error) && error.response?.status === 400) { + this.options.onQueryRejected?.(); + } } } while (this.refetchPending && !this.disposed && this.visible); } finally { @@ -163,14 +174,14 @@ export class WorkspaceProvider } const resp = await this.client.getWorkspaces({ - q: this.getWorkspacesQuery, + q: this.config.getQuery(session), }); if (this.sessionChangedSince(session)) { return null; } - const workspaces = this.filterWorkspaces(resp.workspaces, session); + const workspaces = resp.workspaces; const oldWatcherIds = [...this.agentWatchers.keys()]; const reusedWatcherIds: string[] = []; @@ -221,18 +232,6 @@ export class WorkspaceProvider return this.sessionState.current !== session; } - private filterWorkspaces( - workspaces: readonly Workspace[], - session: Extract, - ): readonly Workspace[] { - if (!this.config.excludeOwn) { - return workspaces; - } - return workspaces.filter( - (workspace) => workspace.owner_id !== session.user.id, - ); - } - /** * Either start or stop the refresh timer based on visibility. * diff --git a/test/unit/workspace/workspacesProvider.test.ts b/test/unit/workspace/workspacesProvider.test.ts index 36118593b..d0de4e95f 100644 --- a/test/unit/workspace/workspacesProvider.test.ts +++ b/test/unit/workspace/workspacesProvider.test.ts @@ -36,7 +36,7 @@ function setup() { const session = new TestSessionStore(); const makeProvider = ( query: WorkspaceQuery, - options?: { refreshIntervalMs?: number }, + options?: { refreshIntervalMs?: number; onQueryRejected?: () => void }, ): WorkspaceProvider => new WorkspaceProvider( query, @@ -138,7 +138,7 @@ describe("WorkspaceProvider", () => { it.each([ [WorkspaceQuery.Mine, "owner:me"], - [WorkspaceQuery.Shared, "shared:true"], + [WorkspaceQuery.Shared, `shared_with_user:${TEST_CURRENT_USER_ID}`], [WorkspaceQuery.All, ""], ])("fetches %s with the expected query", async (query, expectedQuery) => { const { client, makeProvider } = setup(); @@ -189,48 +189,33 @@ describe("WorkspaceProvider", () => { }, ); - it("filters current-user-owned workspaces from shared results", async () => { + it("reports a rejected query when the server responds with HTTP 400", async () => { const { client, makeProvider } = setup(); - client.respondOnce([ - workspace({ - id: "owned-shared-out", - name: "owned", - owner_id: TEST_CURRENT_USER_ID, - owner_name: "current", - }), - workspace({ - id: "shared-with-me", - name: "shared", - owner_id: "alice-id", - owner_name: "alice", + const onQueryRejected = vi.fn(); + client.getWorkspaces.mockRejectedValueOnce( + Object.assign(new Error("invalid query"), { + isAxiosError: true, + response: { status: 400 }, }), - ]); - const provider = makeProvider(WorkspaceQuery.Shared); + ); + const provider = makeProvider(WorkspaceQuery.Shared, { onQueryRejected }); await show(provider); - expect(await labels(provider)).toEqual(["alice / shared"]); + expect(onQueryRejected).toHaveBeenCalledTimes(1); + expect(await provider.getChildren()).toEqual([]); }); - it.each([WorkspaceQuery.Mine, WorkspaceQuery.All])( - "does not apply shared ownership filtering to %s", - async (query) => { - const { client, makeProvider } = setup(); - client.respondOnce([ - workspace({ - id: "owned", - name: "owned", - owner_id: TEST_CURRENT_USER_ID, - owner_name: "current", - }), - ]); - const provider = makeProvider(query); + it("does not report a rejected query for other fetch failures", async () => { + const { client, makeProvider } = setup(); + const onQueryRejected = vi.fn(); + client.getWorkspaces.mockRejectedValueOnce(new Error("network down")); + const provider = makeProvider(WorkspaceQuery.Shared, { onQueryRejected }); - await show(provider); + await show(provider); - expect(await labels(provider)).toHaveLength(1); - }, - ); + expect(onQueryRejected).not.toHaveBeenCalled(); + }); it("clears rendered workspaces when the session signs out", async () => { const { client, session, makeProvider } = setup(); From 7e9ab0b78f270381a0389e81f8e1507a86929819 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 7 Jul 2026 14:11:41 +0000 Subject: [PATCH 2/3] feat: give My Workspaces twice the initial size of other views --- package.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index d8c4eb4ef..714d549c9 100644 --- a/package.json +++ b/package.json @@ -316,20 +316,23 @@ "id": "myWorkspaces", "name": "My Workspaces", "visibility": "visible", - "icon": "media/logo-white.svg" + "icon": "media/logo-white.svg", + "initialSize": 2 }, { "id": "sharedWorkspaces", "name": "Shared Workspaces", "visibility": "visible", "icon": "media/logo-white.svg", - "when": "coder.authenticated" + "initialSize": 1, + "when": "coder.authenticated && coder.sharedWorkspacesSupported" }, { "id": "allWorkspaces", "name": "All Workspaces", "visibility": "visible", "icon": "media/logo-white.svg", + "initialSize": 1, "when": "coder.authenticated && coder.isOwner" } ], From 6667b8164ac7bdcf0caca00cbf8584fbb1b42fdd Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 8 Jul 2026 15:40:06 +0300 Subject: [PATCH 3/3] fix: show a message instead of hiding the shared workspaces view Toggling view visibility based on coder.sharedWorkspacesSupported raced the manual refresh command's context flip, causing the view to flicker on and off across refreshes. The view is now always shown when authenticated, with viewsWelcome explaining when shared workspaces aren't supported or the list is empty. --- CHANGELOG.md | 10 ++++++++-- package.json | 12 +++++++++++- src/extension.ts | 13 ++++++------- 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc5a7df93..4aeba367c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,14 @@ bar item instead). A new **Coder: View Announcements** command opens the full messages in a markdown preview. +### Changed + +- Filter the Shared Workspaces view with the server-side `shared_with_user` + query instead of filtering `shared:true` results on the client, so fewer + workspaces are fetched and the view loads faster. Deployments too old to + support the new filter now show a message explaining why instead of an + empty list. + ### Fixed - Apply a 60-second default timeout to REST requests, so requests hung on a @@ -60,8 +68,6 @@ `coder logout` rather than by the extension directly. The minimum supported Coder version is now v0.25.0. -### Changed - - Workspace opens and `coder://` URI handling now log more diagnostics (target workspace, agent, and handoff) to make failed opens easier to trace. URI parameter values, including tokens, are never logged. diff --git a/package.json b/package.json index 714d549c9..a1ca7b3f9 100644 --- a/package.json +++ b/package.json @@ -325,7 +325,7 @@ "visibility": "visible", "icon": "media/logo-white.svg", "initialSize": 1, - "when": "coder.authenticated && coder.sharedWorkspacesSupported" + "when": "coder.authenticated" }, { "id": "allWorkspaces", @@ -371,6 +371,16 @@ "view": "coder.tasksLogin", "contents": "Sign in to view and manage Coder tasks.\n[Login](command:coder.login)", "when": "!coder.authenticated && coder.loaded" + }, + { + "view": "sharedWorkspaces", + "contents": "Shared workspaces require Coder 2.27.0 or newer.", + "when": "coder.authenticated && !coder.sharedWorkspacesSupported" + }, + { + "view": "sharedWorkspaces", + "contents": "No workspaces have been shared with you.", + "when": "coder.authenticated && coder.sharedWorkspacesSupported" } ], "commands": [ diff --git a/src/extension.ts b/src/extension.ts index ed35ee97e..6dc82209e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -194,17 +194,16 @@ async function doActivate( output, deploymentManager.session, { - // Deployments older than 2.27.0 reject the shared workspaces query; - // hide the view for them (see `when` in package.json). + // Older deployments reject this query; show a message instead of an + // empty tree (see `viewsWelcome` in package.json). onQueryRejected: () => contextManager.set("coder.sharedWorkspacesSupported", false), }, ); ctx.subscriptions.push(sharedWorkspacesProvider); - // Re-probe shared workspaces support whenever the session changes (login, - // logout, deployment switch): the view re-appears and the next fetch hides - // it again if the deployment still does not support the query. + // Re-probe support on session change (login, logout, deployment switch); + // the next fetch clears the message again if still unsupported. ctx.subscriptions.push( deploymentManager.session.onDidChange(() => contextManager.set("coder.sharedWorkspacesSupported", true), @@ -343,8 +342,8 @@ async function doActivate( commands.navigateToWorkspaceSettings.bind(commands), ); commandManager.register("coder.refreshWorkspaces", () => { - // Re-probe shared workspaces support in case the deployment was upgraded; - // showing the view triggers its fetch, which hides it again on rejection. + // Re-probe in case the deployment was upgraded; the fetch below clears + // the message again on rejection. contextManager.set("coder.sharedWorkspacesSupported", true); void myWorkspacesProvider.fetchAndRefresh(); void sharedWorkspacesProvider.fetchAndRefresh();