Skip to content

Commit 6fe2f74

Browse files
authored
Add React webview architecture with pnpm workspaces (#761)
Introduce a webview system for building rich UI panels using React 19, Vite, and @vscode-elements/react-elements. Webviews are organized as pnpm workspace packages under `packages/`. ## Architecture - `packages/webview-shared`: Shared types and React hooks for VS Code API (@repo/webview-shared) - `packages/tasks`: Example Tasks panel webview (@repo/tasks) - `src/webviews/`: Extension-side WebviewViewProvider implementations - `vite.config.base.ts`: Shared Vite config factory for webviews ## Key Features - Type-safe message passing between extension and webviews - CSP-compliant HTML generation with nonce-based script loading - Vite with SWC for fast development builds ## Build Commands - `pnpm build` - Build webviews + extension - `pnpm watch:all` - Watch both extension and webviews concurrently Closes #771
1 parent c024db9 commit 6fe2f74

27 files changed

+2174
-52
lines changed

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,8 @@
77
*.vsix
88
pnpm-debug.log
99
.eslintcache
10+
11+
# Webview packages build artifacts
12+
packages/*/node_modules/
13+
packages/*/dist/
14+
packages/*/*.tsbuildinfo

.vscodeignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ esbuild.mjs
2727
pnpm-lock.yaml
2828
pnpm-workspace.yaml
2929

30+
# Webview packages (exclude everything except built output in dist/webviews)
31+
packages/**
32+
!dist/webviews/**
33+
3034
# Nix/flake files
3135
flake.nix
3236
flake.lock

CONTRIBUTING.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,47 @@ workspaces if the user has the required permissions.
6666
There are also notifications for an outdated workspace and for workspaces that
6767
are close to shutting down.
6868

69+
## Webviews
70+
71+
The extension uses React-based webviews for rich UI panels, built with Vite and
72+
organized as a pnpm workspace in `packages/`.
73+
74+
### Project Structure
75+
76+
```text
77+
packages/
78+
├── webview-shared/ # Shared types, React hooks, and Vite config
79+
│ └── extension.d.ts # Types exposed to extension (excludes React)
80+
└── tasks/ # Example webview (copy this for new webviews)
81+
82+
src/webviews/
83+
├── util.ts # getWebviewHtml() helper
84+
└── tasks/ # Extension-side provider for tasks panel
85+
```
86+
87+
Key patterns:
88+
89+
- **Type sharing**: Extension imports types from `@repo/webview-shared` via path mapping
90+
to `extension.d.ts`. Webviews import directly from `@repo/webview-shared/react`.
91+
- **Message passing**: Use `postMessage()`/`useMessage()` hooks for communication.
92+
- **Lifecycle**: Dispose event listeners properly (see `TasksPanel.ts` for example).
93+
94+
### Development
95+
96+
```bash
97+
pnpm watch:all # Rebuild extension and webviews on changes
98+
```
99+
100+
Press F5 to launch the Extension Development Host. Use "Developer: Reload Webviews"
101+
to see webview changes.
102+
103+
### Adding a New Webview
104+
105+
1. Copy `packages/tasks` to `packages/<name>` and update the package name
106+
2. Create a provider in `src/webviews/<name>/` (see `TasksPanel.ts` for reference)
107+
3. Register the view in `package.json` under `contributes.views`
108+
4. Register the provider in `src/extension.ts`
109+
69110
## Testing
70111

71112
There are a few ways you can test the "Open in VS Code" flow:

eslint.config.mjs

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import prettierConfig from "eslint-config-prettier";
77
import { createTypeScriptImportResolver } from "eslint-import-resolver-typescript";
88
import { flatConfigs as importXFlatConfigs } from "eslint-plugin-import-x";
99
import packageJson from "eslint-plugin-package-json";
10+
import reactPlugin from "eslint-plugin-react";
11+
import reactHooksPlugin from "eslint-plugin-react-hooks";
1012
import globals from "globals";
1113

1214
export default defineConfig(
@@ -15,21 +17,24 @@ export default defineConfig(
1517
ignores: [
1618
"out/**",
1719
"dist/**",
20+
"packages/*/dist/**",
1821
"**/*.d.ts",
1922
"vitest.config.ts",
23+
"**/vite.config*.ts",
24+
"**/createWebviewConfig.ts",
2025
".vscode-test/**",
2126
],
2227
},
2328

24-
// Base ESLint recommended rules (for JS/TS files only)
29+
// Base ESLint recommended rules (for JS/TS/TSX files only)
2530
{
26-
files: ["**/*.ts", "**/*.js", "**/*.mjs"],
31+
files: ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.mjs"],
2732
...eslint.configs.recommended,
2833
},
2934

3035
// TypeScript configuration with type-checked rules
3136
{
32-
files: ["**/*.ts"],
37+
files: ["**/*.ts", "**/*.tsx"],
3338
extends: [
3439
...tseslint.configs.recommendedTypeChecked,
3540
...tseslint.configs.stylisticTypeChecked,
@@ -64,7 +69,7 @@ export default defineConfig(
6469
],
6570
"@typescript-eslint/no-unused-vars": [
6671
"error",
67-
{ varsIgnorePattern: "^_" },
72+
{ varsIgnorePattern: "^_", argsIgnorePattern: "^_" },
6873
],
6974
"@typescript-eslint/array-type": ["error", { default: "array-simple" }],
7075
"@typescript-eslint/prefer-nullish-coalescing": [
@@ -96,6 +101,7 @@ export default defineConfig(
96101
"newlines-between": "always",
97102
alphabetize: { order: "asc", caseInsensitive: true },
98103
sortTypesGroup: true,
104+
warnOnUnassignedImports: true,
99105
},
100106
],
101107
"no-duplicate-imports": "off",
@@ -160,6 +166,37 @@ export default defineConfig(
160166
},
161167
},
162168

169+
// Webview packages - browser globals
170+
{
171+
files: ["packages/*/src/**/*.ts", "packages/*/src/**/*.tsx"],
172+
languageOptions: {
173+
globals: {
174+
...globals.browser,
175+
},
176+
},
177+
},
178+
179+
// TSX files - React rules
180+
{
181+
files: ["**/*.tsx"],
182+
plugins: {
183+
react: reactPlugin,
184+
"react-hooks": reactHooksPlugin,
185+
},
186+
settings: {
187+
react: {
188+
version: "detect",
189+
},
190+
},
191+
rules: {
192+
// TS rules already applied above; add React-specific rules
193+
...reactPlugin.configs.recommended.rules,
194+
...reactPlugin.configs["jsx-runtime"].rules, // React 17+ JSX transform
195+
...reactHooksPlugin.configs.recommended.rules,
196+
"react/prop-types": "off", // Using TypeScript
197+
},
198+
},
199+
163200
// Package.json linting
164201
packageJson.configs.recommended,
165202

package.json

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,9 @@
1818
"type": "commonjs",
1919
"main": "./dist/extension.js",
2020
"scripts": {
21-
"build": "tsc --noEmit && node esbuild.mjs",
22-
"build:production": "tsc --noEmit && node esbuild.mjs --production",
21+
"build": "pnpm build:webviews && tsc --noEmit && node esbuild.mjs",
22+
"build:production": "NODE_ENV=production pnpm build:webviews && tsc --noEmit && node esbuild.mjs --production",
23+
"build:webviews": "pnpm -r --filter \"./packages/*\" build",
2324
"fmt": "prettier --write --cache --cache-strategy content .",
2425
"fmt:check": "prettier --check --cache --cache-strategy content .",
2526
"lint": "eslint --cache --cache-strategy content .",
@@ -31,7 +32,9 @@
3132
"test:ci": "CI=true pnpm test",
3233
"test:integration": "tsc -p test --outDir out && node esbuild.mjs && vscode-test",
3334
"vscode:prepublish": "pnpm build:production",
34-
"watch": "node esbuild.mjs --watch"
35+
"watch:all": "concurrently -n extension,webviews \"pnpm watch:extension\" \"pnpm watch:webviews\"",
36+
"watch:extension": "node esbuild.mjs --watch",
37+
"watch:webviews": "pnpm -r --filter \"./packages/*\" --parallel dev"
3538
},
3639
"contributes": {
3740
"configuration": {
@@ -194,6 +197,13 @@
194197
"visibility": "visible",
195198
"icon": "media/logo-white.svg",
196199
"when": "coder.authenticated && coder.isOwner"
200+
},
201+
{
202+
"type": "webview",
203+
"id": "coder.tasksPanel",
204+
"name": "Tasks",
205+
"icon": "media/logo-white.svg",
206+
"when": "coder.authenticated && coder.devMode"
197207
}
198208
]
199209
},
@@ -202,6 +212,11 @@
202212
"view": "myWorkspaces",
203213
"contents": "Coder is a platform that provisions remote development environments. \n[Login](command:coder.login)",
204214
"when": "!coder.authenticated && coder.loaded"
215+
},
216+
{
217+
"view": "coder.tasksPanel",
218+
"contents": "[Login](command:coder.login) to view tasks.",
219+
"when": "!coder.authenticated && coder.loaded"
205220
}
206221
],
207222
"commands": [
@@ -458,12 +473,14 @@
458473
"@types/ws": "^8.18.1",
459474
"@typescript-eslint/eslint-plugin": "^8.53.0",
460475
"@typescript-eslint/parser": "^8.53.1",
476+
"@vitejs/plugin-react-swc": "^3.8.0",
461477
"@vitest/coverage-v8": "^4.0.16",
462478
"@vscode/test-cli": "^0.0.12",
463479
"@vscode/test-electron": "^2.5.2",
464480
"@vscode/vsce": "^3.7.1",
465481
"bufferutil": "^4.1.0",
466482
"coder": "github:coder/coder#main",
483+
"concurrently": "^9.2.1",
467484
"dayjs": "^1.11.19",
468485
"electron": "^40.0.0",
469486
"esbuild": "^0.27.2",
@@ -472,13 +489,16 @@
472489
"eslint-import-resolver-typescript": "^4.4.4",
473490
"eslint-plugin-import-x": "^4.16.1",
474491
"eslint-plugin-package-json": "^0.88.1",
492+
"eslint-plugin-react": "^7.37.0",
493+
"eslint-plugin-react-hooks": "^5.0.0",
475494
"globals": "^17.0.0",
476495
"jsonc-eslint-parser": "^2.4.2",
477496
"memfs": "^4.56.4",
478497
"prettier": "^3.7.4",
479498
"typescript": "^5.9.3",
480499
"typescript-eslint": "^8.53.1",
481500
"utf-8-validate": "^6.0.6",
501+
"vite": "^6.0.0",
482502
"vitest": "^4.0.16"
483503
},
484504
"extensionPack": [

packages/tasks/package.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"name": "@repo/tasks",
3+
"version": "1.0.0",
4+
"description": "Coder Tasks webview panel",
5+
"private": true,
6+
"type": "module",
7+
"scripts": {
8+
"build": "tsc -b && vite build",
9+
"dev": "vite build --watch"
10+
},
11+
"dependencies": {
12+
"@repo/webview-shared": "workspace:*",
13+
"@vscode-elements/react-elements": "^2.4.0",
14+
"react": "^19.0.0",
15+
"react-dom": "^19.0.0"
16+
},
17+
"devDependencies": {
18+
"@types/react": "^19.0.0",
19+
"@types/react-dom": "^19.0.0",
20+
"@vitejs/plugin-react-swc": "^3.8.0",
21+
"typescript": "^5.7.3",
22+
"vite": "^6.0.0"
23+
}
24+
}

packages/tasks/src/App.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { postMessage, useMessage } from "@repo/webview-shared/react";
2+
import {
3+
VscodeButton,
4+
VscodeProgressRing,
5+
} from "@vscode-elements/react-elements";
6+
import { useCallback, useEffect, useState } from "react";
7+
8+
import type { WebviewMessage } from "@repo/webview-shared";
9+
10+
export default function App() {
11+
const [ready, setReady] = useState(false);
12+
13+
const handleMessage = useCallback((message: WebviewMessage) => {
14+
switch (message.type) {
15+
case "init":
16+
setReady(true);
17+
break;
18+
}
19+
}, []);
20+
21+
useMessage(handleMessage);
22+
23+
useEffect(() => {
24+
postMessage({ type: "ready" });
25+
}, []);
26+
27+
if (!ready) {
28+
return <VscodeProgressRing />;
29+
}
30+
31+
return (
32+
<div>
33+
<h2>Coder Tasks</h2>
34+
<VscodeButton onClick={() => postMessage({ type: "refresh" })}>
35+
Refresh
36+
</VscodeButton>
37+
</div>
38+
);
39+
}

packages/tasks/src/index.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/* Webview styles */

packages/tasks/src/index.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { StrictMode } from "react";
2+
import { createRoot } from "react-dom/client";
3+
4+
import App from "./App";
5+
import "./index.css";
6+
7+
const root = document.getElementById("root");
8+
if (root) {
9+
createRoot(root).render(
10+
<StrictMode>
11+
<App />
12+
</StrictMode>,
13+
);
14+
}

packages/tasks/tsconfig.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"extends": "../tsconfig.webview.json",
3+
"compilerOptions": {
4+
"paths": {
5+
"@repo/webview-shared": ["../webview-shared/src"]
6+
}
7+
},
8+
"include": ["src"],
9+
"references": [{ "path": "../webview-shared" }]
10+
}

0 commit comments

Comments
 (0)