diff --git a/package.json b/package.json index 1066eb9e..7e47ac1a 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "package:prerelease": "pnpm build:production && vsce package --pre-release --no-dependencies", "storybook": "storybook dev -p 6006 --config-dir .storybook", "storybook:build": "storybook build --config-dir .storybook", - "storybook:ci": "storybook build --test --config-dir .storybook", + "storybook:ci": "storybook build --test --config-dir .storybook", "test": "cross-env CI=true ELECTRON_RUN_AS_NODE=1 electron node_modules/vitest/vitest.mjs", "test:extension": "cross-env ELECTRON_RUN_AS_NODE=1 electron node_modules/vitest/vitest.mjs --project extension", "test:integration": "pnpm compile-tests:integration && node esbuild.mjs && vscode-test", @@ -277,6 +277,11 @@ "id": "coderTasks", "title": "Coder Tasks", "icon": "media/tasks-logo.svg" + }, + { + "id": "coderExperimentalWorkspaces", + "title": "Coder Remote (New)", + "icon": "media/shorthand-logo.svg" } ], "secondarySidebar": [ @@ -326,6 +331,15 @@ "icon": "media/shorthand-logo.svg", "when": "coder.agentsEnabled" } + ], + "coderExperimentalWorkspaces": [ + { + "type": "webview", + "id": "coder.workspacesPanel", + "name": "Workspaces", + "icon": "media/shorthand-logo.svg", + "when": "coder.authenticated && coder.workspacesPanelEnabled" + } ] }, "viewsWelcome": [ diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index b403822e..f9b553c0 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -19,3 +19,6 @@ export { // Chat API export { ChatApi } from "./chat/api"; + +// Workspaces API +export { WorkspacesApi } from "./workspaces/api"; diff --git a/packages/shared/src/workspaces/api.ts b/packages/shared/src/workspaces/api.ts new file mode 100644 index 00000000..508b2e5b --- /dev/null +++ b/packages/shared/src/workspaces/api.ts @@ -0,0 +1 @@ +export const WorkspacesApi = {} as const; diff --git a/packages/workspaces/README.md b/packages/workspaces/README.md new file mode 100644 index 00000000..f83b897a --- /dev/null +++ b/packages/workspaces/README.md @@ -0,0 +1,22 @@ +# Coder Workspaces Webview Panel + +This package contains the Workspaces webview panel for the Coder VS Code extension. + +## Enabling the Feature + +The workspaces panel is controlled by the `coder.experimental.workspacesPanel` configuration setting. + +To enable via settings.json: + +1. Open your VS Code settings.json (Cmd/Ctrl + Shift + P → "Preferences: Open User Settings (JSON)") +2. Add the following: + +```json +{ + "coder.experimental.workspacesPanel": true +} +``` + +3. Reload VS Code + +A new activity bar icon labeled **"Coder Remote (New)"** will appear when the setting is enabled. diff --git a/packages/workspaces/package.json b/packages/workspaces/package.json new file mode 100644 index 00000000..d7a3b345 --- /dev/null +++ b/packages/workspaces/package.json @@ -0,0 +1,33 @@ +{ + "name": "@repo/workspaces", + "version": "1.0.0", + "description": "Coder Workspaces webview panel", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite build --watch", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@repo/shared": "workspace:*", + "@repo/webview-shared": "workspace:*", + "@tanstack/react-query": "catalog:", + "@vscode-elements/react-elements": "catalog:", + "@vscode/codicons": "catalog:", + "date-fns": "catalog:", + "react": "catalog:", + "react-dom": "catalog:" + }, + "devDependencies": { + "@repo/mocks": "workspace:*", + "@repo/storybook-utils": "workspace:*", + "@rolldown/plugin-babel": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "babel-plugin-react-compiler": "catalog:", + "typescript": "catalog:", + "vite": "catalog:" + } +} diff --git a/packages/workspaces/src/App.tsx b/packages/workspaces/src/App.tsx new file mode 100644 index 00000000..abed2111 --- /dev/null +++ b/packages/workspaces/src/App.tsx @@ -0,0 +1,3 @@ +export default function App() { + return
TODO
; +} diff --git a/packages/workspaces/src/css.d.ts b/packages/workspaces/src/css.d.ts new file mode 100644 index 00000000..cbe652db --- /dev/null +++ b/packages/workspaces/src/css.d.ts @@ -0,0 +1 @@ +declare module "*.css"; diff --git a/packages/workspaces/src/index.css b/packages/workspaces/src/index.css new file mode 100644 index 00000000..8f414f58 --- /dev/null +++ b/packages/workspaces/src/index.css @@ -0,0 +1 @@ +/* TODO */ diff --git a/packages/workspaces/src/index.tsx b/packages/workspaces/src/index.tsx new file mode 100644 index 00000000..e6bb1159 --- /dev/null +++ b/packages/workspaces/src/index.tsx @@ -0,0 +1,26 @@ +import { ErrorBoundary } from "@repo/webview-shared/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; + +import App from "./App"; +import "./index.css"; + +const queryClient = new QueryClient(); + +const root = document.getElementById("root"); +if (!root) { + throw new Error( + "Failed to find root element. The webview HTML must contain an element with id='root'.", + ); +} + +createRoot(root).render( + + + + + + + , +); diff --git a/packages/workspaces/storybook.preview.ts b/packages/workspaces/storybook.preview.ts new file mode 100644 index 00000000..c01b6f9c --- /dev/null +++ b/packages/workspaces/storybook.preview.ts @@ -0,0 +1 @@ +import "./src/index.css"; diff --git a/packages/workspaces/tsconfig.json b/packages/workspaces/tsconfig.json new file mode 100644 index 00000000..27059a98 --- /dev/null +++ b/packages/workspaces/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.packages.json", + "compilerOptions": { + "paths": { + "@repo/shared": ["../shared/src"], + "@repo/webview-shared": ["../webview-shared/src"] + } + }, + "include": ["src", "storybook.preview.ts"] +} diff --git a/packages/workspaces/vite.config.ts b/packages/workspaces/vite.config.ts new file mode 100644 index 00000000..ebbae452 --- /dev/null +++ b/packages/workspaces/vite.config.ts @@ -0,0 +1,3 @@ +import { createReactWebviewConfig } from "../webview-shared/createWebviewConfig"; + +export default createReactWebviewConfig("workspaces", __dirname); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a409aa6e..8a93e1e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -454,6 +454,61 @@ importers: specifier: 'catalog:' version: 6.0.3 + packages/workspaces: + dependencies: + '@repo/shared': + specifier: workspace:* + version: link:../shared + '@repo/webview-shared': + specifier: workspace:* + version: link:../webview-shared + '@tanstack/react-query': + specifier: 'catalog:' + version: 5.100.9(react@19.2.6) + '@vscode-elements/react-elements': + specifier: 'catalog:' + version: 2.4.0(@types/react@19.2.14)(@vscode/codicons@0.0.45)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@vscode/codicons': + specifier: 'catalog:' + version: 0.0.45 + date-fns: + specifier: 'catalog:' + version: 4.1.0 + react: + specifier: 'catalog:' + version: 19.2.6 + react-dom: + specifier: 'catalog:' + version: 19.2.6(react@19.2.6) + devDependencies: + '@repo/mocks': + specifier: workspace:* + version: link:../mocks + '@repo/storybook-utils': + specifier: workspace:* + version: link:../storybook-utils + '@rolldown/plugin-babel': + specifier: 'catalog:' + version: 0.2.3(@babel/core@7.29.0)(@babel/runtime@7.29.2)(rolldown@1.0.0-rc.18)(vite@8.0.11(@types/node@24.10.12)(esbuild@0.28.0)) + '@types/react': + specifier: 'catalog:' + version: 19.2.14 + '@types/react-dom': + specifier: 'catalog:' + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: 'catalog:' + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.0)(@babel/runtime@7.29.2)(rolldown@1.0.0-rc.18)(vite@8.0.11(@types/node@24.10.12)(esbuild@0.28.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.0.11(@types/node@24.10.12)(esbuild@0.28.0)) + babel-plugin-react-compiler: + specifier: 'catalog:' + version: 1.0.0 + typescript: + specifier: 'catalog:' + version: 6.0.3 + vite: + specifier: 'catalog:' + version: 8.0.11(@types/node@24.10.12)(esbuild@0.28.0) + packages: '@abraham/reflection@0.13.0': diff --git a/src/core/contextManager.ts b/src/core/contextManager.ts index 8facb2ea..179a1623 100644 --- a/src/core/contextManager.ts +++ b/src/core/contextManager.ts @@ -7,6 +7,7 @@ const CONTEXT_DEFAULTS = { "coder.agentsEnabled": false, "coder.workspace.connected": false, "coder.workspace.updatable": false, + "coder.workspacesPanelEnabled": false, } as const; type CoderContext = keyof typeof CONTEXT_DEFAULTS; diff --git a/src/extension.ts b/src/extension.ts index cf310bce..59b8a093 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -25,6 +25,7 @@ import { registerUriHandler } from "./uri/uriHandler"; import { initVscodeProposed } from "./vscodeProposed"; import { ChatPanelProvider } from "./webviews/chat/chatPanelProvider"; import { TasksPanelProvider } from "./webviews/tasks/tasksPanelProvider"; +import { WorkspacesPanelProvider } from "./webviews/workspaces/workspacesPanelProvider"; import { WorkspaceProvider, WorkspaceQuery, @@ -265,6 +266,31 @@ async function doActivate( ), ); + const workspacesPanelEnabled = vscode.workspace + .getConfiguration("coder") + .get("experimental.workspacesPanel", false); + + contextManager.set("coder.workspacesPanelEnabled", workspacesPanelEnabled); + + if (workspacesPanelEnabled) { + const workspacesPanelProvider = new WorkspacesPanelProvider( + ctx.extensionUri, + output, + ); + + ctx.subscriptions.push( + workspacesPanelProvider, + vscode.window.registerWebviewViewProvider( + WorkspacesPanelProvider.viewType, + workspacesPanelProvider, + { webviewOptions: { retainContextWhenHidden: true } }, + ), + secretsManager.onDidChangeCurrentDeployment(() => + workspacesPanelProvider.refresh(), + ), + ); + } + ctx.subscriptions.push( registerUriHandler({ serviceContainer, diff --git a/src/webviews/workspaces/workspacesPanelProvider.ts b/src/webviews/workspaces/workspacesPanelProvider.ts new file mode 100644 index 00000000..cac04d7c --- /dev/null +++ b/src/webviews/workspaces/workspacesPanelProvider.ts @@ -0,0 +1,102 @@ +import * as vscode from "vscode"; + +import { + buildCommandHandlers, + buildRequestHandlers, + WorkspacesApi, +} from "@repo/shared"; + +import { + dispatchCommand, + dispatchRequest, + isIpcCommand, + isIpcRequest, +} from "../dispatch"; +import { getWebviewHtml } from "../html"; + +import type { Logger } from "../../logging/logger"; + +export class WorkspacesPanelProvider + implements vscode.WebviewViewProvider, vscode.Disposable +{ + public static readonly viewType = "coder.workspacesPanel"; + + private view?: vscode.WebviewView; + private disposables: vscode.Disposable[] = []; + + private readonly requestHandlers = buildRequestHandlers(WorkspacesApi, {}); + private readonly commandHandlers = buildCommandHandlers(WorkspacesApi, {}); + + constructor( + private readonly extensionUri: vscode.Uri, + private readonly logger: Logger, + ) {} + + public refresh(): void { + this.logger.debug("Workspaces panel refresh requested"); + } + + resolveWebviewView( + webviewView: vscode.WebviewView, + _context: vscode.WebviewViewResolveContext, + _token: vscode.CancellationToken, + ): void { + this.view = webviewView; + + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [ + vscode.Uri.joinPath( + this.extensionUri, + "dist", + "webviews", + "workspaces", + ), + ], + }; + + this.disposeView(); + + this.disposables.push( + webviewView.webview.onDidReceiveMessage((message: unknown) => { + this.handleMessage(message).catch((err: unknown) => { + this.logger.error("Unhandled error in message handler", err); + }); + }), + ); + + webviewView.webview.html = getWebviewHtml( + webviewView.webview, + this.extensionUri, + "workspaces", + "Coder Workspaces", + ); + + webviewView.onDidDispose(() => this.disposeView()); + } + + private async handleMessage(message: unknown): Promise { + if (isIpcRequest(message)) { + await dispatchRequest(message, this.requestHandlers, this.view?.webview, { + logger: this.logger, + }); + } else if (isIpcCommand(message)) { + await dispatchCommand(message, this.commandHandlers, { + logger: this.logger, + }); + } else { + this.logger.warn("Unexpected webview message", message); + } + } + + private disposeView(): void { + for (const d of this.disposables) { + d.dispose(); + } + this.disposables = []; + } + + dispose(): void { + this.disposeView(); + } +}