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 d8c4eb4ef..a1ca7b3f9 100644 --- a/package.json +++ b/package.json @@ -316,13 +316,15 @@ "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", + "initialSize": 1, "when": "coder.authenticated" }, { @@ -330,6 +332,7 @@ "name": "All Workspaces", "visibility": "visible", "icon": "media/logo-white.svg", + "initialSize": 1, "when": "coder.authenticated && coder.isOwner" } ], @@ -368,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/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..6dc82209e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -193,9 +193,23 @@ async function doActivate( client, output, deploymentManager.session, + { + // 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 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), + ), + ); + // 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 +342,9 @@ async function doActivate( commands.navigateToWorkspaceSettings.bind(commands), ); commandManager.register("coder.refreshWorkspaces", () => { + // 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(); 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();