Skip to content
Open
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
42 changes: 42 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,48 @@ 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/
├── 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 `@coder/shared` via path mapping
to `extension.d.ts`. Webviews import directly from `@coder/shared/react`.
- **Message passing**: Use `postMessage()`/`useMessage()` hooks for communication.
- **Lifecycle**: Dispose event listeners properly (see `TasksPanel.ts` for example).

### Development

```bash
pnpm watch # Rebuild extension on changes
pnpm dev:webviews # Rebuild webviews on changes (run in separate terminal)
```

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
43 changes: 39 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,23 @@ export default defineConfig(
ignores: [
"out/**",
"dist/**",
"packages/*/dist/**",
"**/*.d.ts",
"vitest.config.ts",
"**/vite.config*.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 +68,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 @@ -160,6 +164,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: {
// Only add React-specific rules, TS rules already applied via **/*.ts config above
...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
22 changes: 20 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@
"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",
"dev:webviews": "pnpm -r --filter \"./packages/*\" --parallel dev",
"fmt": "prettier --write --cache --cache-strategy content .",
"fmt:check": "prettier --check --cache --cache-strategy content .",
"lint": "eslint --cache --cache-strategy content .",
Expand Down Expand Up @@ -194,6 +196,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 +211,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 @@ -445,6 +459,7 @@
"@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",
Expand All @@ -459,13 +474,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
2 changes: 2 additions & 0 deletions packages/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";
31 changes: 31 additions & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"name": "@coder/shared",
"version": "1.0.0",
"description": "Shared types and utilities for Coder webviews",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./react": {
"types": "./src/react/index.ts",
"default": "./src/react/index.ts"
}
},
"peerDependencies": {
"react": "^19.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
}
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/vscode-webview": "^1.57.5",
"react": "^19.0.0",
"typescript": "^5.7.3"
}
}
5 changes: 5 additions & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Message passing types - simple generic interface
export interface WebviewMessage<T = unknown> {
type: string;
data?: T;
}
25 changes: 25 additions & 0 deletions packages/shared/src/react/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { WebviewApi } from "vscode-webview";

import type { WebviewMessage } from "../index";

// Singleton - acquireVsCodeApi can only be called once
let vscodeApi: WebviewApi<unknown> | undefined;

declare function acquireVsCodeApi(): WebviewApi<unknown>;

export function getVsCodeApi(): WebviewApi<unknown> {
vscodeApi ??= acquireVsCodeApi();
return vscodeApi;
}

export function postMessage(message: WebviewMessage): void {
getVsCodeApi().postMessage(message);
}

export function getState<T>(): T | undefined {
return getVsCodeApi().getState() as T | undefined;
}

export function setState<T>(state: T): void {
getVsCodeApi().setState(state);
}
39 changes: 39 additions & 0 deletions packages/shared/src/react/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { useCallback, useEffect, useState } from "react";

import { getState, setState } from "./api";

import type { WebviewMessage } from "../index";

/**
* Hook to listen for messages from the extension
*/
export function useMessage<T = unknown>(
handler: (message: WebviewMessage<T>) => void,
): void {
useEffect((): (() => void) => {
const listener = (event: MessageEvent<WebviewMessage<T>>): void => {
handler(event.data);
};
window.addEventListener("message", listener);
return (): void => {
window.removeEventListener("message", listener);
};
}, [handler]);
}

/**
* Hook to manage webview state with VS Code's state API
*/
export function useVsCodeState<T>(initialState: T): [T, (state: T) => void] {
const [state, setLocalState] = useState<T>((): T => {
const saved = getState<T>();
return saved ?? initialState;
});

const setVsCodeState = useCallback((newState: T): void => {
setLocalState(newState);
setState(newState);
}, []);

return [state, setVsCodeState];
}
2 changes: 2 additions & 0 deletions packages/shared/src/react/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { getVsCodeApi, postMessage, getState, setState } from "./api";
export { useMessage, useVsCodeState } from "./hooks";
11 changes: 11 additions & 0 deletions packages/shared/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"extends": "./tsconfig.webview.json",
"compilerOptions": {
"composite": true,
"declaration": true,
"noEmit": false,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
13 changes: 13 additions & 0 deletions packages/shared/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
}
}
40 changes: 40 additions & 0 deletions packages/shared/vite.config.base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
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: {
output: {
entryFileNames: "index.js",
assetFileNames: "index.[ext]",
},
},
// No sourcemaps in production (not needed in packaged extension)
sourcemap: !production,
},
resolve: {
alias: {
"@coder/shared": resolve(dirname, "../shared/src"),
},
},
});
}
Loading