Skip to content

Commit 5ae7bef

Browse files
committed
feat(workspaces): IPC API and extension-side data layer for the panel
Adds the typed IPC contract for the experimental Workspaces panel and the extension-side provider that owns its data, porting the tree views' behaviors to push through IPC. `packages/shared/src/workspaces` defines the contract: `stateUpdated` out; `ready`, `openWorkspace`, `viewInDashboard`, `refresh`, `setFilter` and `watchAgents` back. State is pushed as one update carrying only the fields that changed, and the payloads carry decisions rather than facts to derive, so the webview holds no data and applies no policy: it asks for the state with `ready` and renders what arrives. `WorkspaceStore` lists the active filter while visible, backs off on failures, watches metadata for the agents the panel is showing, and reports what changed. A cancellation token per fetch drops superseded results, the list is pushed before sockets open, and a structural diff keeps quiet polls off the wire. Filters that a deployment rejects stop being offered. Split by concern, in their own layers: - `src/workspace/agentMetadataTracker.ts`: the watched set and its sockets, which linger briefly after release so toggling a row reuses them - `src/workspace/filters.ts`: each filter's query, presentation, role requirement and poll policy, shared with the tree views instead of duplicated `isOwner(user)` moved to `src/api/api-helper.ts` for both `deploymentManager` and the panel, since the `coder.isOwner` context is written after the session change fires. Existing tree views are untouched. The webview's placeholder App prints the pushed state; the UI lands with the tree components.
1 parent 3449c8b commit 5ae7bef

27 files changed

Lines changed: 2316 additions & 149 deletions

packages/mocks/src/workspaces.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import type {
66
Workspace,
77
WorkspaceAgent,
8+
WorkspaceAgentMetadata,
89
WorkspaceBuild,
910
WorkspaceResource,
1011
} from "coder/site/src/api/typesGenerated";
@@ -51,13 +52,21 @@ const defaultBuild: WorkspaceBuild = {
5152
template_version_preset_id: null,
5253
};
5354

54-
/** Create a Workspace with sensible defaults for a running task workspace. */
55+
/**
56+
* Create a Workspace with sensible defaults for a running task workspace.
57+
* `agents` puts them on a single resource, the common shape in tests.
58+
*/
5559
export function workspace(
5660
overrides: Omit<Partial<Workspace>, "latest_build"> & {
5761
latest_build?: Partial<WorkspaceBuild>;
62+
agents?: WorkspaceAgent[];
5863
} = {},
5964
): Workspace {
60-
const { latest_build: buildOverrides, ...rest } = overrides;
65+
const { latest_build: buildOverrides, agents, ...rest } = overrides;
66+
const build = { ...defaultBuild, ...buildOverrides };
67+
if (agents) {
68+
build.resources = [resource({ agents })];
69+
}
6170
return {
6271
id: "workspace-1",
6372
created_at: "2024-01-01T00:00:00Z",
@@ -75,7 +84,7 @@ export function workspace(
7584
template_active_version_id: "version-1",
7685
template_require_active_version: false,
7786
template_use_classic_parameter_flow: false,
78-
latest_build: { ...defaultBuild, ...buildOverrides },
87+
latest_build: build,
7988
latest_app_status: null,
8089
outdated: false,
8190
name: "test-workspace",
@@ -126,6 +135,32 @@ export function agent(overrides: Partial<WorkspaceAgent> = {}): WorkspaceAgent {
126135
};
127136
}
128137

138+
/** Create a WorkspaceAgentMetadata report with sensible defaults. */
139+
export function agentMetadata(
140+
overrides: {
141+
result?: Partial<WorkspaceAgentMetadata["result"]>;
142+
description?: Partial<WorkspaceAgentMetadata["description"]>;
143+
} = {},
144+
): WorkspaceAgentMetadata {
145+
return {
146+
result: {
147+
collected_at: "2024-01-01T00:00:00Z",
148+
age: 0,
149+
value: "42",
150+
error: "",
151+
...overrides.result,
152+
},
153+
description: {
154+
display_name: "CPU",
155+
key: "cpu",
156+
script: "cpu.sh",
157+
interval: 5,
158+
timeout: 1,
159+
...overrides.description,
160+
},
161+
};
162+
}
163+
129164
/** Create a WorkspaceResource with sensible defaults. */
130165
export function resource(
131166
overrides: Partial<WorkspaceResource> = {},

packages/shared/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,6 @@ export type {
3030
NetcheckSeverity,
3131
} from "./netcheck/types";
3232

33-
// Workspaces API
33+
// Workspaces types and API
34+
export * from "./workspaces/types";
3435
export { WorkspacesApi } from "./workspaces/api";
Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,31 @@
1-
export const WorkspacesApi = {} as const;
1+
/**
2+
* Workspaces API - Type-safe message definitions for the Workspaces webview.
3+
*
4+
* The extension owns the data and pushes it; the webview renders what it is
5+
* given and sends back the actions the user takes.
6+
*/
7+
8+
import { defineCommand, defineNotification } from "../ipc/protocol";
9+
10+
import type {
11+
OpenWorkspaceParams,
12+
SetFilterParams,
13+
ViewInDashboardParams,
14+
WatchAgentsParams,
15+
WorkspacesUpdate,
16+
} from "./types";
17+
18+
export const WorkspacesApi = {
19+
// Notifications
20+
/** Every field of the state that changed, applied together */
21+
stateUpdated: defineNotification<WorkspacesUpdate>("stateUpdated"),
22+
// Commands
23+
/** Webview signals its subscription is live and asks for the whole state */
24+
ready: defineCommand<void>("ready"),
25+
openWorkspace: defineCommand<OpenWorkspaceParams>("openWorkspace"),
26+
viewInDashboard: defineCommand<ViewInDashboardParams>("viewInDashboard"),
27+
refresh: defineCommand<void>("refresh"),
28+
setFilter: defineCommand<SetFilterParams>("setFilter"),
29+
/** Watch metadata for these agents only, so idle rows cost nothing */
30+
watchAgents: defineCommand<WatchAgentsParams>("watchAgents"),
31+
} as const;
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import type {
2+
Workspace,
3+
WorkspaceAgent,
4+
WorkspaceAgentMetadata,
5+
} from "coder/site/src/api/typesGenerated";
6+
7+
// Re-export SDK types for convenience
8+
export type { Workspace, WorkspaceAgent, WorkspaceAgentMetadata };
9+
10+
export type WorkspaceFilter = "mine" | "shared" | "all";
11+
12+
/** A workspace page in the dashboard, opened in the browser. */
13+
export type DashboardPage = "workspace" | "settings";
14+
15+
/** What the panel may offer for the current session. */
16+
export interface WorkspacesCapabilities {
17+
readonly authenticated: boolean;
18+
/** Filters the user may select, in display order. */
19+
readonly filters: readonly WorkspaceFilter[];
20+
}
21+
22+
export interface FilteredWorkspaces {
23+
readonly filter: WorkspaceFilter;
24+
readonly workspaces: readonly Workspace[];
25+
/** True while the first list for this filter is still on its way. */
26+
readonly loading: boolean;
27+
}
28+
29+
export interface AgentMetadataState {
30+
readonly metadata: readonly WorkspaceAgentMetadata[];
31+
/** The watcher failure, which replaces the metadata in the UI. */
32+
readonly error: string | null;
33+
/** True until the agent reports for the first time. */
34+
readonly loading: boolean;
35+
}
36+
37+
/** Keyed by agent id. */
38+
export type AgentMetadataMap = Readonly<Record<string, AgentMetadataState>>;
39+
40+
/** Everything the panel renders. Fields are replaced, never mutated. */
41+
export interface WorkspacesState {
42+
readonly capabilities: WorkspacesCapabilities;
43+
readonly workspaces: FilteredWorkspaces;
44+
readonly metadata: AgentMetadataMap;
45+
readonly error: string | null;
46+
}
47+
48+
/** A state slice: present fields changed, absent ones did not. */
49+
export type WorkspacesUpdate = Partial<WorkspacesState>;
50+
51+
export interface OpenWorkspaceParams {
52+
workspaceId: string;
53+
/** Which agent to connect to. Picked interactively when omitted. */
54+
agentId?: string;
55+
}
56+
57+
export interface ViewInDashboardParams {
58+
workspaceId: string;
59+
page: DashboardPage;
60+
}
61+
62+
export interface SetFilterParams {
63+
filter: WorkspaceFilter;
64+
}
65+
66+
export interface WatchAgentsParams {
67+
/** The agents whose metadata the webview is showing. */
68+
agentIds: readonly string[];
69+
}

packages/workspaces/src/App.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
import { useWorkspaces } from "./hooks/useWorkspaces";
2+
3+
/** Placeholder: renders the pushed state until the panel UI lands. */
14
export default function App() {
2-
return <div>TODO</div>;
5+
const { state } = useWorkspaces();
6+
7+
return <pre>{JSON.stringify(state, null, 2)}</pre>;
38
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import {
2+
buildApiHook,
3+
WorkspacesApi,
4+
type WorkspacesUpdate,
5+
} from "@repo/shared";
6+
import { useIpc } from "@repo/webview-shared/react";
7+
import { useEffect, useState } from "react";
8+
9+
/**
10+
* The state the extension pushes, and the commands to send back. State fields
11+
* are undefined until their first push, which `ready` asks for.
12+
*/
13+
export function useWorkspaces() {
14+
const api = buildApiHook(WorkspacesApi, useIpc());
15+
const [state, setState] = useState<WorkspacesUpdate>({});
16+
17+
useEffect(() => {
18+
const unsubscribe = api.onStateUpdated((update) =>
19+
setState((previous) => ({ ...previous, ...update })),
20+
);
21+
api.ready();
22+
return unsubscribe;
23+
}, []);
24+
25+
return { state, api };
26+
}

src/api/agentMetadataHelper.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export interface AgentMetadataWatcher {
1313
dispose: () => void;
1414
metadata?: AgentMetadataEvent[];
1515
error?: unknown;
16+
/** True once the socket closed on its own, so it reports nothing more. */
17+
closed?: boolean;
1618
}
1719

1820
/**
@@ -70,6 +72,7 @@ export async function createAgentMetadataWatcher(
7072
socket.addEventListener("error", handleError);
7173

7274
socket.addEventListener("close", (event) => {
75+
watcher.closed = true;
7376
if (event.code !== 1000) {
7477
handleError(
7578
new Error(

src/api/api-helper.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { isApiError, isApiErrorResponse } from "coder/site/src/api/errors";
22
import {
3+
type User,
34
type Workspace,
45
type WorkspaceAgent,
56
type WorkspaceResource,
@@ -27,6 +28,11 @@ export function errToStr(error: unknown, def = "No error message provided") {
2728
return def;
2829
}
2930

31+
/** True when the user holds the deployment-wide owner role. */
32+
export function isOwner(user: User | undefined): boolean {
33+
return user?.roles.some((role) => role.name === "owner") ?? false;
34+
}
35+
3036
/**
3137
* Create workspace owner/name identifier
3238
*/

src/deployment/deploymentManager.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isOwner } from "../api/api-helper";
12
import { CoderApi } from "../api/coderApi";
23
import {
34
CONFIG_CHANGE_DEBOUNCE_MS,
@@ -420,8 +421,7 @@ export class DeploymentManager implements vscode.Disposable {
420421
*/
421422
private updateAuthContexts(user: User | undefined): void {
422423
this.contextManager.set("coder.authenticated", Boolean(user));
423-
const isOwner = user?.roles.some((r) => r.name === "owner") ?? false;
424-
this.contextManager.set("coder.isOwner", isOwner);
424+
this.contextManager.set("coder.isOwner", isOwner(user));
425425
}
426426

427427
/**

src/extension.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ import { getRemoteSshExtension } from "./remote/sshExtension";
2727
import { registerUriHandler } from "./uri/uriHandler";
2828
import { initVscodeProposed } from "./vscodeProposed";
2929
import { TasksPanelProvider } from "./webviews/tasks/tasksPanelProvider";
30-
import { WorkspacesPanelProvider } from "./webviews/workspaces/workspacesPanelProvider";
30+
import { WorkspacesPanelProvider } from "./webviews/workspaces/panelProvider";
31+
import { WorkspaceStore } from "./webviews/workspaces/store";
3132
import {
3233
WorkspaceProvider,
3334
WorkspaceQuery,
@@ -302,12 +303,28 @@ async function doActivate(
302303
contextManager.set("coder.workspacesPanelEnabled", workspacesPanelEnabled);
303304

304305
if (workspacesPanelEnabled) {
305-
const workspacesPanelProvider = new WorkspacesPanelProvider(
306-
ctx.extensionUri,
306+
const workspacesStore = new WorkspaceStore(
307+
client,
307308
output,
309+
deploymentManager.session,
308310
);
311+
const workspacesPanelProvider = new WorkspacesPanelProvider({
312+
extensionUri: ctx.extensionUri,
313+
client,
314+
logger: output,
315+
store: workspacesStore,
316+
openWorkspace: (workspace, agent) =>
317+
commands.open({
318+
workspaceOwner: workspace.owner_name,
319+
workspaceName: workspace.name,
320+
agentName: agent?.name,
321+
openRecent: true,
322+
source: agent ? "sidebar_agent" : "sidebar_workspace",
323+
}),
324+
});
309325

310326
ctx.subscriptions.push(
327+
workspacesStore,
311328
workspacesPanelProvider,
312329
vscode.window.registerWebviewViewProvider(
313330
WorkspacesPanelProvider.viewType,

0 commit comments

Comments
 (0)