Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,8 @@
*.vsix
pnpm-debug.log
.eslintcache

# Webview packages build artifacts
packages/*/node_modules/
packages/*/dist/
packages/*/*.tsbuildinfo
4 changes: 4 additions & 0 deletions .vscodeignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ esbuild.mjs
pnpm-lock.yaml
pnpm-workspace.yaml

# Webview packages (exclude everything except built output in dist/webviews)
packages/**
!dist/webviews/**

# Nix/flake files
flake.nix
flake.lock
Expand Down
41 changes: 41 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,47 @@ workspaces if the user has the required permissions.
There are also notifications for an outdated workspace and for workspaces that
are close to shutting down.

## Webviews

The extension uses React-based webviews for rich UI panels, built with Vite and
organized as a pnpm workspace in `packages/`.

### Project Structure

```text
packages/
├── webview-shared/ # Shared types, React hooks, and Vite config
│ └── extension.d.ts # Types exposed to extension (excludes React)
└── tasks/ # Example webview (copy this for new webviews)

src/webviews/
├── util.ts # getWebviewHtml() helper
└── tasks/ # Extension-side provider for tasks panel
```

Key patterns:

- **Type sharing**: Extension imports types from `@repo/webview-shared` via path mapping
to `extension.d.ts`. Webviews import directly from `@repo/webview-shared/react`.
- **Message passing**: Use `postMessage()`/`useMessage()` hooks for communication.
- **Lifecycle**: Dispose event listeners properly (see `TasksPanel.ts` for example).

### Development

```bash
pnpm watch:all # Rebuild extension and webviews on changes
```

Press F5 to launch the Extension Development Host. Use "Developer: Reload Webviews"
to see webview changes.

### Adding a New Webview

1. Copy `packages/tasks` to `packages/<name>` and update the package name
2. Create a provider in `src/webviews/<name>/` (see `TasksPanel.ts` for reference)
3. Register the view in `package.json` under `contributes.views`
4. Register the provider in `src/extension.ts`

## Testing

There are a few ways you can test the "Open in VS Code" flow:
Expand Down
45 changes: 41 additions & 4 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import prettierConfig from "eslint-config-prettier";
import { createTypeScriptImportResolver } from "eslint-import-resolver-typescript";
import { flatConfigs as importXFlatConfigs } from "eslint-plugin-import-x";
import packageJson from "eslint-plugin-package-json";
import reactPlugin from "eslint-plugin-react";
import reactHooksPlugin from "eslint-plugin-react-hooks";
import globals from "globals";

export default defineConfig(
Expand All @@ -15,21 +17,24 @@ export default defineConfig(
ignores: [
"out/**",
"dist/**",
"packages/*/dist/**",
"**/*.d.ts",
"vitest.config.ts",
"**/vite.config*.ts",
"**/createWebviewConfig.ts",
".vscode-test/**",
],
},

// Base ESLint recommended rules (for JS/TS files only)
// Base ESLint recommended rules (for JS/TS/TSX files only)
{
files: ["**/*.ts", "**/*.js", "**/*.mjs"],
files: ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.mjs"],
...eslint.configs.recommended,
},

// TypeScript configuration with type-checked rules
{
files: ["**/*.ts"],
files: ["**/*.ts", "**/*.tsx"],
extends: [
...tseslint.configs.recommendedTypeChecked,
...tseslint.configs.stylisticTypeChecked,
Expand Down Expand Up @@ -64,7 +69,7 @@ export default defineConfig(
],
"@typescript-eslint/no-unused-vars": [
"error",
{ varsIgnorePattern: "^_" },
{ varsIgnorePattern: "^_", argsIgnorePattern: "^_" },
],
"@typescript-eslint/array-type": ["error", { default: "array-simple" }],
"@typescript-eslint/prefer-nullish-coalescing": [
Expand Down Expand Up @@ -96,6 +101,7 @@ export default defineConfig(
"newlines-between": "always",
alphabetize: { order: "asc", caseInsensitive: true },
sortTypesGroup: true,
warnOnUnassignedImports: true,
},
],
"no-duplicate-imports": "off",
Expand Down Expand Up @@ -160,6 +166,37 @@ export default defineConfig(
},
},

// Webview packages - browser globals
{
files: ["packages/*/src/**/*.ts", "packages/*/src/**/*.tsx"],
languageOptions: {
globals: {
...globals.browser,
},
},
},

// TSX files - React rules
{
files: ["**/*.tsx"],
plugins: {
react: reactPlugin,
"react-hooks": reactHooksPlugin,
},
settings: {
react: {
version: "detect",
},
},
rules: {
// TS rules already applied above; add React-specific rules
...reactPlugin.configs.recommended.rules,
...reactPlugin.configs["jsx-runtime"].rules, // React 17+ JSX transform
...reactHooksPlugin.configs.recommended.rules,
"react/prop-types": "off", // Using TypeScript
},
},

// Package.json linting
packageJson.configs.recommended,

Expand Down
26 changes: 23 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@
"type": "commonjs",
"main": "./dist/extension.js",
"scripts": {
"build": "tsc --noEmit && node esbuild.mjs",
"build:production": "tsc --noEmit && node esbuild.mjs --production",
"build": "pnpm build:webviews && tsc --noEmit && node esbuild.mjs",
"build:production": "NODE_ENV=production pnpm build:webviews && tsc --noEmit && node esbuild.mjs --production",
"build:webviews": "pnpm -r --filter \"./packages/*\" build",
"fmt": "prettier --write --cache --cache-strategy content .",
"fmt:check": "prettier --check --cache --cache-strategy content .",
"lint": "eslint --cache --cache-strategy content .",
Expand All @@ -31,7 +32,9 @@
"test:ci": "CI=true pnpm test",
"test:integration": "tsc -p test --outDir out && node esbuild.mjs && vscode-test",
"vscode:prepublish": "pnpm build:production",
"watch": "node esbuild.mjs --watch"
"watch:all": "concurrently -n extension,webviews \"pnpm watch:extension\" \"pnpm watch:webviews\"",
"watch:extension": "node esbuild.mjs --watch",
"watch:webviews": "pnpm -r --filter \"./packages/*\" --parallel dev"
},
"contributes": {
"configuration": {
Expand Down Expand Up @@ -194,6 +197,13 @@
"visibility": "visible",
"icon": "media/logo-white.svg",
"when": "coder.authenticated && coder.isOwner"
},
{
"type": "webview",
"id": "coder.tasksPanel",
"name": "Tasks",
"icon": "media/logo-white.svg",
"when": "coder.authenticated && coder.devMode"
}
]
},
Expand All @@ -202,6 +212,11 @@
"view": "myWorkspaces",
"contents": "Coder is a platform that provisions remote development environments. \n[Login](command:coder.login)",
"when": "!coder.authenticated && coder.loaded"
},
{
"view": "coder.tasksPanel",
"contents": "[Login](command:coder.login) to view tasks.",
"when": "!coder.authenticated && coder.loaded"
}
],
"commands": [
Expand Down Expand Up @@ -458,12 +473,14 @@
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.53.0",
"@typescript-eslint/parser": "^8.53.1",
"@vitejs/plugin-react-swc": "^3.8.0",
"@vitest/coverage-v8": "^4.0.16",
"@vscode/test-cli": "^0.0.12",
"@vscode/test-electron": "^2.5.2",
"@vscode/vsce": "^3.7.1",
"bufferutil": "^4.1.0",
"coder": "github:coder/coder#main",
"concurrently": "^9.2.1",
"dayjs": "^1.11.19",
"electron": "^40.0.0",
"esbuild": "^0.27.2",
Expand All @@ -472,13 +489,16 @@
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import-x": "^4.16.1",
"eslint-plugin-package-json": "^0.88.1",
"eslint-plugin-react": "^7.37.0",
"eslint-plugin-react-hooks": "^5.0.0",
"globals": "^17.0.0",
"jsonc-eslint-parser": "^2.4.2",
"memfs": "^4.56.4",
"prettier": "^3.7.4",
"typescript": "^5.9.3",
"typescript-eslint": "^8.53.1",
"utf-8-validate": "^6.0.6",
"vite": "^6.0.0",
"vitest": "^4.0.16"
},
"extensionPack": [
Expand Down
24 changes: 24 additions & 0 deletions packages/tasks/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "@repo/tasks",
"version": "1.0.0",
"description": "Coder Tasks webview panel",
"private": true,
"type": "module",
"scripts": {
"build": "tsc -b && vite build",
"dev": "vite build --watch"
},
"dependencies": {
"@repo/webview-shared": "workspace:*",
"@vscode-elements/react-elements": "^2.4.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react-swc": "^3.8.0",
"typescript": "^5.7.3",
"vite": "^6.0.0"
}
}
39 changes: 39 additions & 0 deletions packages/tasks/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { postMessage, useMessage } from "@repo/webview-shared/react";
import {
VscodeButton,
VscodeProgressRing,
} from "@vscode-elements/react-elements";
import { useCallback, useEffect, useState } from "react";

import type { WebviewMessage } from "@repo/webview-shared";

export default function App() {
const [ready, setReady] = useState(false);

const handleMessage = useCallback((message: WebviewMessage) => {
switch (message.type) {
case "init":
setReady(true);
break;
}
}, []);

useMessage(handleMessage);

useEffect(() => {
postMessage({ type: "ready" });
}, []);

if (!ready) {
return <VscodeProgressRing />;
}

return (
<div>
<h2>Coder Tasks</h2>
<VscodeButton onClick={() => postMessage({ type: "refresh" })}>
Refresh
</VscodeButton>
</div>
);
}
1 change: 1 addition & 0 deletions packages/tasks/src/index.css
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/* Webview styles */
14 changes: 14 additions & 0 deletions packages/tasks/src/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";

import App from "./App";
import "./index.css";

const root = document.getElementById("root");
if (root) {
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);
}
10 changes: 10 additions & 0 deletions packages/tasks/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"extends": "../tsconfig.webview.json",
"compilerOptions": {
"paths": {
"@repo/webview-shared": ["../webview-shared/src"]
}
},
"include": ["src"],
"references": [{ "path": "../webview-shared" }]
}
3 changes: 3 additions & 0 deletions packages/tasks/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { createWebviewConfig } from "../webview-shared/createWebviewConfig";

export default createWebviewConfig("tasks", __dirname);
13 changes: 13 additions & 0 deletions packages/tsconfig.webview.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"extends": "@tsconfig/node20/tsconfig.json",
"compilerOptions": {
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"noEmit": true,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true
}
}
42 changes: 42 additions & 0 deletions packages/webview-shared/createWebviewConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import react from "@vitejs/plugin-react-swc";
import { resolve } from "node:path";
import { defineConfig, type UserConfig } from "vite";

/**
* Create a Vite config for a webview package
* @param webviewName - Name of the webview (used for output path)
* @param dirname - __dirname of the calling config file
*/
export function createWebviewConfig(
webviewName: string,
dirname: string,
): UserConfig {
const production = process.env.NODE_ENV === "production";

return defineConfig({
plugins: [react()],
build: {
outDir: resolve(dirname, `../../dist/webviews/${webviewName}`),
emptyOutDir: true,
// Target modern browsers (VS Code webview uses Chromium from Electron)
target: "esnext",
// Skip gzip size calculation for faster builds
reportCompressedSize: false,
rollupOptions: {
// HTML is generated by the extension with CSP headers
input: resolve(dirname, "src/index.tsx"),
output: {
entryFileNames: "index.js",
assetFileNames: "index.[ext]",
},
},
// Keeps extension size down; build locally to map stack traces
sourcemap: !production,
},
resolve: {
alias: {
"@repo/webview-shared": resolve(dirname, "../webview-shared/src"),
},
},
});
}
2 changes: 2 additions & 0 deletions packages/webview-shared/extension.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Types exposed to the extension (react/ subpath is excluded).
export type { WebviewMessage } from "./src/index";
Loading