From 88da1ca42f3a8e5f5969cf22097afa435ac2011f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kayla=20=E3=81=AF=E3=81=AA?= Date: Fri, 15 May 2026 19:04:06 +0000 Subject: [PATCH] feat: add shared workspaces view Adds a third tree view, Shared Workspaces, that lists workspaces the current user has access to but does not own. Visible to any authenticated user (unlike All Workspaces, which is admin-only). Generated with Coder Agents on behalf of @aslilac. --- package.json | 27 +++++++++++++++++++++++ src/core/commandManager.ts | 1 + src/deployment/deploymentManager.ts | 11 ++++++++++ src/extension.ts | 33 ++++++++++++++++++++++++++++- src/workspace/workspacesProvider.ts | 31 ++++++++++++++++++++++++--- 5 files changed, 99 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 1e65216691..72fa0afdba 100644 --- a/package.json +++ b/package.json @@ -295,6 +295,13 @@ "visibility": "visible", "icon": "media/logo-white.svg" }, + { + "id": "sharedWorkspaces", + "name": "Shared Workspaces", + "visibility": "visible", + "icon": "media/logo-white.svg", + "when": "coder.authenticated" + }, { "id": "allWorkspaces", "name": "All Workspaces", @@ -432,6 +439,12 @@ "category": "Coder", "icon": "$(search)" }, + { + "command": "coder.searchSharedWorkspaces", + "title": "Search", + "category": "Coder", + "icon": "$(search)" + }, { "command": "coder.searchAllWorkspaces", "title": "Search", @@ -560,6 +573,10 @@ "command": "coder.searchMyWorkspaces", "when": "false" }, + { + "command": "coder.searchSharedWorkspaces", + "when": "false" + }, { "command": "coder.searchAllWorkspaces", "when": "false" @@ -607,6 +624,16 @@ "when": "coder.authenticated && view == myWorkspaces", "group": "navigation@3" }, + { + "command": "coder.refreshWorkspaces", + "when": "coder.authenticated && view == sharedWorkspaces", + "group": "navigation@2" + }, + { + "command": "coder.searchSharedWorkspaces", + "when": "coder.authenticated && view == sharedWorkspaces", + "group": "navigation@3" + }, { "command": "coder.searchAllWorkspaces", "when": "coder.authenticated && view == allWorkspaces", diff --git a/src/core/commandManager.ts b/src/core/commandManager.ts index 241183c183..518629fb0a 100644 --- a/src/core/commandManager.ts +++ b/src/core/commandManager.ts @@ -21,6 +21,7 @@ export const CODER_COMMAND_IDS = [ "coder.refreshWorkspaces", "coder.viewLogs", "coder.searchMyWorkspaces", + "coder.searchSharedWorkspaces", "coder.searchAllWorkspaces", "coder.manageCredentials", "coder.applyRecommendedSettings", diff --git a/src/deployment/deploymentManager.ts b/src/deployment/deploymentManager.ts index 738f87b776..0316891cd1 100644 --- a/src/deployment/deploymentManager.ts +++ b/src/deployment/deploymentManager.ts @@ -37,6 +37,7 @@ export class DeploymentManager implements vscode.Disposable { private readonly telemetryService: TelemetryService; #deployment: Deployment | null = null; + #currentUserId: string | undefined; #authListenerDisposable: vscode.Disposable | undefined; #crossWindowSyncDisposable: vscode.Disposable | undefined; @@ -83,6 +84,14 @@ export class DeploymentManager implements vscode.Disposable { return this.contextManager.get("coder.authenticated"); } + /** + * Get the id of the currently authenticated user, if any. Used by the + * Shared workspaces view to filter out the current user's workspaces. + */ + public getCurrentUserId(): string | undefined { + return this.#currentUserId; + } + /** * Attempt to change to a deployment after validating authentication. * Only changes deployment if authentication succeeds. @@ -127,6 +136,7 @@ export class DeploymentManager implements vscode.Disposable { user: deployment.user.username, }); this.#deployment = { ...deployment }; + this.#currentUserId = deployment.user.id; this.telemetryService.setDeploymentUrl(deployment.url); // Updates client credentials @@ -173,6 +183,7 @@ export class DeploymentManager implements vscode.Disposable { this.client.setCredentials(undefined, undefined); this.updateAuthContexts(undefined); this.contextManager.set("coder.agentsEnabled", false); + this.#currentUserId = undefined; this.clearWorkspaces(); } diff --git a/src/extension.ts b/src/extension.ts index 3067014c2c..390edbd14b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -31,6 +31,7 @@ import { } from "./workspace/workspacesProvider"; const MY_WORKSPACES_TREE_ID = "myWorkspaces"; +const SHARED_WORKSPACES_TREE_ID = "sharedWorkspaces"; const ALL_WORKSPACES_TREE_ID = "allWorkspaces"; export async function activate(ctx: vscode.ExtensionContext): Promise { @@ -174,6 +175,19 @@ async function doActivate( ); ctx.subscriptions.push(allWorkspacesProvider); + const sharedWorkspacesProvider = new WorkspaceProvider( + WorkspaceQuery.Shared, + client, + output, + isAuthenticated, + undefined, + // Filter out workspaces owned by the current user. The deployment + // manager is created below; we capture it via the closure and read it + // lazily, since the callback only fires when workspaces are fetched. + () => deploymentManager.getCurrentUserId(), + ); + ctx.subscriptions.push(sharedWorkspacesProvider); + // 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 myWsTree = vscode.window.createTreeView(MY_WORKSPACES_TREE_ID, { @@ -189,6 +203,19 @@ async function doActivate( ctx.subscriptions, ); + const sharedWsTree = vscode.window.createTreeView(SHARED_WORKSPACES_TREE_ID, { + treeDataProvider: sharedWorkspacesProvider, + }); + ctx.subscriptions.push(sharedWsTree); + sharedWorkspacesProvider.setVisibility(sharedWsTree.visible); + sharedWsTree.onDidChangeVisibility( + (event) => { + sharedWorkspacesProvider.setVisibility(event.visible); + }, + undefined, + ctx.subscriptions, + ); + const allWsTree = vscode.window.createTreeView(ALL_WORKSPACES_TREE_ID, { treeDataProvider: allWorkspacesProvider, }); @@ -207,7 +234,7 @@ async function doActivate( serviceContainer, client, oauthSessionManager, - [myWorkspacesProvider, allWorkspacesProvider], + [myWorkspacesProvider, sharedWorkspacesProvider, allWorkspacesProvider], ); ctx.subscriptions.push(deploymentManager); @@ -313,12 +340,16 @@ async function doActivate( ); commandManager.register("coder.refreshWorkspaces", () => { void myWorkspacesProvider.fetchAndRefresh(); + void sharedWorkspacesProvider.fetchAndRefresh(); void allWorkspacesProvider.fetchAndRefresh(); }); commandManager.register("coder.viewLogs", commands.viewLogs.bind(commands)); commandManager.register("coder.searchMyWorkspaces", async () => showTreeViewSearch(MY_WORKSPACES_TREE_ID), ); + commandManager.register("coder.searchSharedWorkspaces", async () => + showTreeViewSearch(SHARED_WORKSPACES_TREE_ID), + ); commandManager.register("coder.searchAllWorkspaces", async () => showTreeViewSearch(ALL_WORKSPACES_TREE_ID), ); diff --git a/src/workspace/workspacesProvider.ts b/src/workspace/workspacesProvider.ts index 7ead0dfafd..3322107d5b 100644 --- a/src/workspace/workspacesProvider.ts +++ b/src/workspace/workspacesProvider.ts @@ -23,6 +23,11 @@ import { type Logger } from "../logging/logger"; export enum WorkspaceQuery { Mine = "owner:me", All = "", + // Shared returns workspaces the user has access to via sharing but does not + // own. The server-side `shared:true` filter also includes workspaces the + // user owns and has shared out, so the provider filters those out + // client-side using the current user's id. + Shared = "shared:true", } /** @@ -53,6 +58,10 @@ export class WorkspaceProvider private readonly logger: Logger, private readonly isAuthenticated: () => boolean, private readonly timerSeconds?: number, + // Returns the id of the currently authenticated user. Used by the Shared + // query to filter out workspaces owned by the current user. + private readonly getCurrentUserId: () => string | undefined = () => + undefined, ) { // No initialization. } @@ -112,6 +121,18 @@ export class WorkspaceProvider q: this.getWorkspacesQuery, }); + // `shared:true` also matches workspaces the current user shared out; + // keep only the ones owned by someone else. + let workspaces = resp.workspaces; + if (this.getWorkspacesQuery === WorkspaceQuery.Shared) { + const currentUserId = this.getCurrentUserId(); + if (currentUserId) { + workspaces = workspaces.filter( + (workspace) => workspace.owner_id !== currentUserId, + ); + } + } + // We could have logged out while waiting for the query, or logged into a // different deployment. const url2 = this.client.getAxiosInstance().defaults.baseURL; @@ -133,7 +154,7 @@ export class WorkspaceProvider // have this separate map held outside the tree. const showMetadata = this.getWorkspacesQuery === WorkspaceQuery.Mine; if (showMetadata) { - const agents = extractAllAgents(resp.workspaces); + const agents = extractAllAgents(workspaces); for (const agent of agents) { // If we have an existing watcher, re-use it. const oldWatcher = this.agentWatchers.get(agent.id); @@ -159,11 +180,15 @@ export class WorkspaceProvider } } + // Show the owner alongside the workspace name when the list may contain + // workspaces owned by other users. + const showOwner = this.getWorkspacesQuery !== WorkspaceQuery.Mine; + // Create tree items for each workspace - const workspaceTreeItems = resp.workspaces.map((workspace: Workspace) => { + const workspaceTreeItems = workspaces.map((workspace: Workspace) => { const workspaceTreeItem = new WorkspaceTreeItem( workspace, - this.getWorkspacesQuery === WorkspaceQuery.All, + showOwner, showMetadata, );