Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 38 additions & 3 deletions packages/mocks/src/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import type {
Workspace,
WorkspaceAgent,
WorkspaceAgentMetadata,
WorkspaceBuild,
WorkspaceResource,
} from "coder/site/src/api/typesGenerated";
Expand Down Expand Up @@ -51,13 +52,21 @@ const defaultBuild: WorkspaceBuild = {
template_version_preset_id: null,
};

/** Create a Workspace with sensible defaults for a running task workspace. */
/**
* Create a Workspace with sensible defaults for a running task workspace.
* `agents` puts them on a single resource, the common shape in tests.
*/
export function workspace(
overrides: Omit<Partial<Workspace>, "latest_build"> & {
latest_build?: Partial<WorkspaceBuild>;
agents?: WorkspaceAgent[];
} = {},
): Workspace {
const { latest_build: buildOverrides, ...rest } = overrides;
const { latest_build: buildOverrides, agents, ...rest } = overrides;
const build = { ...defaultBuild, ...buildOverrides };
if (agents) {
build.resources = [resource({ agents })];
}
return {
id: "workspace-1",
created_at: "2024-01-01T00:00:00Z",
Expand All @@ -75,7 +84,7 @@ export function workspace(
template_active_version_id: "version-1",
template_require_active_version: false,
template_use_classic_parameter_flow: false,
latest_build: { ...defaultBuild, ...buildOverrides },
latest_build: build,
latest_app_status: null,
outdated: false,
name: "test-workspace",
Expand Down Expand Up @@ -126,6 +135,32 @@ export function agent(overrides: Partial<WorkspaceAgent> = {}): WorkspaceAgent {
};
}

/** Create a WorkspaceAgentMetadata report with sensible defaults. */
export function agentMetadata(
overrides: {
result?: Partial<WorkspaceAgentMetadata["result"]>;
description?: Partial<WorkspaceAgentMetadata["description"]>;
} = {},
): WorkspaceAgentMetadata {
return {
result: {
collected_at: "2024-01-01T00:00:00Z",
age: 0,
value: "42",
error: "",
...overrides.result,
},
description: {
display_name: "CPU",
key: "cpu",
script: "cpu.sh",
interval: 5,
timeout: 1,
...overrides.description,
},
};
}

/** Create a WorkspaceResource with sensible defaults. */
export function resource(
overrides: Partial<WorkspaceResource> = {},
Expand Down
3 changes: 2 additions & 1 deletion packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,6 @@ export type {
NetcheckSeverity,
} from "./netcheck/types";

// Workspaces API
// Workspaces types and API
export * from "./workspaces/types";
export { WorkspacesApi } from "./workspaces/api";
32 changes: 31 additions & 1 deletion packages/shared/src/workspaces/api.ts
Original file line number Diff line number Diff line change
@@ -1 +1,31 @@
export const WorkspacesApi = {} as const;
/**
* Workspaces API - Type-safe message definitions for the Workspaces webview.
*
* The extension owns the data and pushes it; the webview renders what it is
* given and sends back the actions the user takes.
*/

import { defineCommand, defineNotification } from "../ipc/protocol";

import type {
OpenWorkspaceParams,
SetFilterParams,
ViewInDashboardParams,
WatchAgentsParams,
WorkspacesUpdate,
} from "./types";

export const WorkspacesApi = {
// Notifications
/** Every field of the state that changed, applied together */
stateUpdated: defineNotification<WorkspacesUpdate>("stateUpdated"),
// Commands
/** Webview signals its subscription is live and asks for the whole state */
ready: defineCommand<void>("ready"),
openWorkspace: defineCommand<OpenWorkspaceParams>("openWorkspace"),
viewInDashboard: defineCommand<ViewInDashboardParams>("viewInDashboard"),
refresh: defineCommand<void>("refresh"),
setFilter: defineCommand<SetFilterParams>("setFilter"),
/** Watch metadata for these agents only, so idle rows cost nothing */
watchAgents: defineCommand<WatchAgentsParams>("watchAgents"),
} as const;
69 changes: 69 additions & 0 deletions packages/shared/src/workspaces/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type {
Workspace,
WorkspaceAgent,
WorkspaceAgentMetadata,
} from "coder/site/src/api/typesGenerated";

// Re-export SDK types for convenience
export type { Workspace, WorkspaceAgent, WorkspaceAgentMetadata };

export type WorkspaceFilter = "mine" | "shared" | "all";

/** A workspace page in the dashboard, opened in the browser. */
export type DashboardPage = "workspace" | "settings";

/** What the panel may offer for the current session. */
export interface WorkspacesCapabilities {
readonly authenticated: boolean;
/** Filters the user may select, in display order. */
readonly filters: readonly WorkspaceFilter[];
}

export interface FilteredWorkspaces {
readonly filter: WorkspaceFilter;
readonly workspaces: readonly Workspace[];
/** True while the first list for this filter is still on its way. */
readonly loading: boolean;
}

export interface AgentMetadataState {
readonly metadata: readonly WorkspaceAgentMetadata[];
/** The watcher failure, which replaces the metadata in the UI. */
readonly error: string | null;
/** True until the agent reports for the first time. */
readonly loading: boolean;
}

/** Keyed by agent id. */
export type AgentMetadataMap = Readonly<Record<string, AgentMetadataState>>;

/** Everything the panel renders. Fields are replaced, never mutated. */
export interface WorkspacesState {
readonly capabilities: WorkspacesCapabilities;
readonly workspaces: FilteredWorkspaces;
readonly metadata: AgentMetadataMap;
readonly error: string | null;
}

/** A state slice: present fields changed, absent ones did not. */
export type WorkspacesUpdate = Partial<WorkspacesState>;

export interface OpenWorkspaceParams {
workspaceId: string;
/** Which agent to connect to. Picked interactively when omitted. */
agentId?: string;
}

export interface ViewInDashboardParams {
workspaceId: string;
page: DashboardPage;
}

export interface SetFilterParams {
filter: WorkspaceFilter;
}

export interface WatchAgentsParams {
/** The agents whose metadata the webview is showing. */
agentIds: readonly string[];
}
7 changes: 6 additions & 1 deletion packages/workspaces/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import { useWorkspaces } from "./hooks/useWorkspaces";

/** Placeholder: renders the pushed state until the panel UI lands. */
export default function App() {
return <div>TODO</div>;
const { state } = useWorkspaces();

return <pre>{JSON.stringify(state, null, 2)}</pre>;
}
26 changes: 26 additions & 0 deletions packages/workspaces/src/hooks/useWorkspaces.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import {
buildApiHook,
WorkspacesApi,
type WorkspacesUpdate,
} from "@repo/shared";
import { useIpc } from "@repo/webview-shared/react";
import { useEffect, useState } from "react";

/**
* The state the extension pushes, and the commands to send back. State fields
* are undefined until their first push, which `ready` asks for.
*/
export function useWorkspaces() {
const api = buildApiHook(WorkspacesApi, useIpc());
const [state, setState] = useState<WorkspacesUpdate>({});

useEffect(() => {
const unsubscribe = api.onStateUpdated((update) =>
setState((previous) => ({ ...previous, ...update })),
);
api.ready();
return unsubscribe;
}, []);

return { state, api };
}
3 changes: 3 additions & 0 deletions src/api/agentMetadataHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export interface AgentMetadataWatcher {
dispose: () => void;
metadata?: AgentMetadataEvent[];
error?: unknown;
/** True once the socket closed on its own, so it reports nothing more. */
closed?: boolean;
}

/**
Expand Down Expand Up @@ -70,6 +72,7 @@ export async function createAgentMetadataWatcher(
socket.addEventListener("error", handleError);

socket.addEventListener("close", (event) => {
watcher.closed = true;
if (event.code !== 1000) {
handleError(
new Error(
Expand Down
6 changes: 6 additions & 0 deletions src/api/api-helper.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { isApiError, isApiErrorResponse } from "coder/site/src/api/errors";
import {
type User,
type Workspace,
type WorkspaceAgent,
type WorkspaceResource,
Expand Down Expand Up @@ -27,6 +28,11 @@ export function errToStr(error: unknown, def = "No error message provided") {
return def;
}

/** True when the user holds the deployment-wide owner role. */
export function isOwner(user: User | undefined): boolean {
return user?.roles.some((role) => role.name === "owner") ?? false;
}

/**
* Create workspace owner/name identifier
*/
Expand Down
4 changes: 2 additions & 2 deletions src/deployment/deploymentManager.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isOwner } from "../api/api-helper";
import { CoderApi } from "../api/coderApi";
import {
CONFIG_CHANGE_DEBOUNCE_MS,
Expand Down Expand Up @@ -420,8 +421,7 @@ export class DeploymentManager implements vscode.Disposable {
*/
private updateAuthContexts(user: User | undefined): void {
this.contextManager.set("coder.authenticated", Boolean(user));
const isOwner = user?.roles.some((r) => r.name === "owner") ?? false;
this.contextManager.set("coder.isOwner", isOwner);
this.contextManager.set("coder.isOwner", isOwner(user));
}

/**
Expand Down
23 changes: 20 additions & 3 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ import { getRemoteSshExtension } from "./remote/sshExtension";
import { registerUriHandler } from "./uri/uriHandler";
import { initVscodeProposed } from "./vscodeProposed";
import { TasksPanelProvider } from "./webviews/tasks/tasksPanelProvider";
import { WorkspacesPanelProvider } from "./webviews/workspaces/workspacesPanelProvider";
import { WorkspacesPanelProvider } from "./webviews/workspaces/panelProvider";
import { WorkspaceStore } from "./webviews/workspaces/store";
import {
WorkspaceProvider,
WorkspaceQuery,
Expand Down Expand Up @@ -302,12 +303,28 @@ async function doActivate(
contextManager.set("coder.workspacesPanelEnabled", workspacesPanelEnabled);

if (workspacesPanelEnabled) {
const workspacesPanelProvider = new WorkspacesPanelProvider(
ctx.extensionUri,
const workspacesStore = new WorkspaceStore(
client,
output,
deploymentManager.session,
);
const workspacesPanelProvider = new WorkspacesPanelProvider({
extensionUri: ctx.extensionUri,
client,
logger: output,
store: workspacesStore,
openWorkspace: (workspace, agent) =>
commands.open({
workspaceOwner: workspace.owner_name,
workspaceName: workspace.name,
agentName: agent?.name,
openRecent: true,
source: agent ? "sidebar_agent" : "sidebar_workspace",
}),
});

ctx.subscriptions.push(
workspacesStore,
workspacesPanelProvider,
vscode.window.registerWebviewViewProvider(
WorkspacesPanelProvider.viewType,
Expand Down
Loading