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)
Comment on lines +97 to +98
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah bummer is there no way to watch everything with one command?

```

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

**/*.ts would not match .tsx would it? Or wait does it?

...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",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For parity should this be watch:webviews?

"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";
Comment on lines +1 to +2
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

noice

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";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bit of a nit, but this is in react yet has no React-related code. It feels more webview related than React related to me.

Also instead of calling this @coder/shared what do you think about calling it @coder/webview?


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

// Singleton - acquireVsCodeApi can only be called once
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we expand on this a little, say something like trying to acquire the API more than once will throw an error.

let vscodeApi: WebviewApi<unknown> | undefined;

declare function acquireVsCodeApi(): WebviewApi<unknown>;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we throw a comment on here saying VS Code provides this inside webviews?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually do we need it? I removed it and still get completion and docs for acquireVsCodeApi, but maybe something is just cached.


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);
}
Comment on lines +15 to +25
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have not seen how this is used yet but do we need the wrappers? Seems like callers could grab the API via getVsCodeApi() and then call whatever they need, without having to duplicate everything.

Not a big deal either way.

If we keep these wrappers though there is probably no need to export getVsCodeApi() (it seems to be unused externally right now). Maybe the wrappers are better actually because it could be confusing why there is both a getVsCodeApi() and an acquireVsCodeApi() with such similar names.

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,
Comment on lines +31 to +32
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could be nice to have sourcemaps for stack traces.

},
resolve: {
alias: {
"@coder/shared": resolve(dirname, "../shared/src"),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there some common safe scheme people use for internal packages like these? I worry about the @coder namespace since that is a real namespace on NPM that we use. Something like @internal or @private or whatever, idk.

Although personally I would like to just see the direct imports in the code instead of aliases lol.

},
},
});
}
Loading