diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 55531450e0..fdcbfcff5e 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -101,14 +101,38 @@ jobs:
- name: Setup pnpm, Node.js, and dependencies
uses: ./.github/actions/setup
+ - name: Get Playwright version
+ id: playwright-version
+ run: echo "version=$(pnpm exec playwright-core --version | awk '{print $2}')" >> "$GITHUB_OUTPUT"
+
+ # Cache the browser to avoid relying on azure.archive.ubuntu.com, which
+ # can be flaky. Only the default branch is trusted to write the cache,
+ # so PR runs cannot poison it.
+ - name: Restore Chromium cache
+ id: playwright-chromium-cache
+ uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: ~/.cache/ms-playwright
+ key: playwright-chromium-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
+
- name: Install Chromium
- run: pnpm exec playwright-core install --with-deps chromium
+ run: pnpm exec playwright-core install chromium
+ timeout-minutes: 5
+
+ - name: Save Chromium cache
+ if: github.ref == 'refs/heads/main' && steps.playwright-chromium-cache.outputs.cache-hit != 'true'
+ uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: ~/.cache/ms-playwright
+ key: ${{ steps.playwright-chromium-cache.outputs.cache-primary-key }}
# Builds the Storybook (buildCommand in pixel.jsonc) and snapshots it.
- name: Snapshot
run: pnpm exec pixel-storybook
env:
PIXEL_KEY: ${{ secrets.PIXEL_KEY }}
+ # On pull_request, github.sha is a synthetic merge commit, not the PR head.
+ PIXEL_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }}
# Auto-approve on mainline to avoid blocking CI after squash merges.
PIXEL_AUTO_REVIEW: ${{ github.ref == 'refs/heads/main' }}
diff --git a/.storybook/main.ts b/.storybook/main.ts
index 5b262100d4..54f71a684c 100644
--- a/.storybook/main.ts
+++ b/.storybook/main.ts
@@ -6,7 +6,11 @@ import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = {
stories: ["../packages/*/src/**/*.stories.@(ts|tsx)"],
- addons: ["@storybook/addon-a11y", "@storybook/addon-docs"],
+ addons: [
+ "@storybook/addon-a11y",
+ "@storybook/addon-docs",
+ "storybook-addon-pseudo-states",
+ ],
framework: {
name: "@storybook/react-vite",
options: {},
diff --git a/.storybook/preview.ts b/.storybook/preview.ts
index ad5833c39f..ef464fb77a 100644
--- a/.storybook/preview.ts
+++ b/.storybook/preview.ts
@@ -1,5 +1,6 @@
///
+import { isPixel } from "@coder/pixel-storybook/storyapi";
import codiconCssUrl from "@vscode/codicons/dist/codicon.css?url";
import { createElement } from "react";
@@ -29,6 +30,11 @@ if (typeof window !== "undefined") {
});
}
+// Lets us skip motion animation during Pixel captures.
+if (typeof document !== "undefined" && isPixel()) {
+ document.documentElement.setAttribute("data-pixel", "true");
+}
+
// Inject codicon stylesheet immediately (before any components render)
// Must be a element with id "vscode-codicon-stylesheet" for vscode-elements
if (
diff --git a/AGENTS.md b/AGENTS.md
index e40c60cedd..ac0a27720c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -94,6 +94,15 @@ Non-negotiables:
- Extension panels must call **both** `buildCommandHandlers` and
`buildRequestHandlers` (empty `{}` is fine). This gives a compile error
when anyone adds an action to the API without a matching handler.
+- Every webview and Storybook build runs the React Compiler, so components
+ and hooks must follow the rules of React: no reading or writing a ref
+ during render, no mutating props, state, or anything already rendered,
+ and hooks called unconditionally. A component that breaks them is skipped
+ silently and loses its memoization. Parameter defaults that read another
+ prop (`focused = adapter?.focusedId === row.node.id`) are the usual
+ culprit; put those defaults in the body. `useMemo` and `useCallback` are
+ rarely needed, and when kept they must list every dependency, or
+ `react-hooks/preserve-manual-memoization` fails the lint.
## Code Style
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 135c3fe27d..6dda93f906 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,36 @@
from published versions since it shows up in the VS Code extension changelog
tab and is confusing to users. Add it back between releases if needed. -->
+## [v1.16.3](https://github.com/coder/vscode-coder/releases/tag/v1.16.3) 2026-09-14
+
+### Changed
+
+- Store your session token in the OS keyring by default on macOS and Windows,
+ shared with the `coder` CLI: signing in here also signs in the CLI. Requires
+ Coder CLI 2.29.0 or later; Linux and older CLIs keep using a file. To opt
+ out, set `coder.useKeyring` to `false`.
+- Ask whether to sign the `coder` CLI out too when you sign out, or remove
+ credentials with **Coder: Manage Credentials**, and the CLI shares your
+ session.
+- Pass `coder.useKeyring` to the CLI as `--use-keyring`, so it overrides the
+ `CODER_USE_KEYRING` environment variable.
+- Treat the `CODER_CONFIG_DIR` environment variable like `--global-config` in
+ `coder.globalFlags`.
+- Require Coder CLI 2.32.0 or later, up from 2.31.0, to sign in with the CLI's
+ session or share its config directory.
+- Ask before signing in with the `coder` CLI's session when it belongs to a
+ different user.
+- Show **Open Settings** when the CLI cannot store the token at login, and
+ **Show Output** when signing out cannot remove every credential.
+- Group the workspaces view's **...** menu: **Switch Deployment** and
+ **Logout** first, then **Network Check**.
+
+### Fixed
+
+- Pass `--allow-redirects` to Coder CLI 2.38.0 or later, which otherwise
+ refuses a redirected deployment URL and fails `coder login`, `coder logout`,
+ and `coder ssh`.
+
## [v1.16.2](https://github.com/coder/vscode-coder/releases/tag/v1.16.2) 2026-08-25
### Fixed
diff --git a/README.md b/README.md
index 7588f51956..2010fcfa58 100644
--- a/README.md
+++ b/README.md
@@ -18,8 +18,6 @@ The Coder Remote extension connects your editor to
(formerly Windsurf), and other VS Code forks.
- **Workspace sidebar** - browse, search, and create workspaces. View agent
metadata and app statuses at a glance.
-- **Coder Tasks** - create, monitor, and manage AI agent tasks directly from
- the sidebar with real-time log streaming.
- **Multi-deployment support** - connect to multiple Coder deployments and
switch between them without losing credentials.
- **Dev container support** - open dev containers running inside workspaces.
diff --git a/package.json b/package.json
index 203b9b23d9..982ec1a0f9 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "coder-remote",
"displayName": "Coder",
- "version": "1.16.2",
+ "version": "1.16.3",
"description": "Open any workspace with a single click.",
"categories": [
"Other"
@@ -195,7 +195,7 @@
"ignoreSync": true
},
"coder.globalFlags": {
- "markdownDescription": "Global flags to pass to every Coder CLI invocation. Enter each flag as a separate array item, in order. Do **not** include the `coder` command itself. See the [CLI reference](https://coder.com/docs/reference/cli) for available global flags.\n\nSupports `${env:VAR}`, `${userHome}`, and a leading `~`. For `--flag=value` items the expansion applies to the value half, so `--cfg=~/coder` works.\n\nSet `--global-config` here to point the CLI at a shared config directory (e.g. `--global-config=~/.config/coderv2` to share login/auth with the Coder CLI); requires a deployment on 2.31.0+ and is ignored when `#coder.useKeyring#` is active. The `--use-keyring` flag is ignored; use `#coder.useKeyring#` instead.\n\nFor `--header-command`, precedence is: `#coder.headerCommand#` setting, then `CODER_HEADER_COMMAND` environment variable, then the value specified here.",
+ "markdownDescription": "Global flags to pass to every Coder CLI invocation. Enter each flag as a separate array item, in order. Do **not** include the `coder` command itself. See the [CLI reference](https://coder.com/docs/reference/cli) for available global flags.\n\nSupports `${env:VAR}`, `${userHome}`, and a leading `~`. For `--flag=value` items the expansion applies to the value half, so `--cfg=~/coder` works.\n\nTo share a config directory with the `coder` CLI, add `--global-config` here (for example `--global-config=~/.config/coderv2`) or set `CODER_CONFIG_DIR`. Requires Coder CLI 2.32.0 or later. A `--use-keyring` item is ignored; use `#coder.useKeyring#` instead.\n\nFor `--header-command`, precedence is: `#coder.headerCommand#` setting, then `CODER_HEADER_COMMAND` environment variable, then the value specified here.",
"type": "array",
"items": {
"type": "string"
@@ -204,9 +204,9 @@
"ignoreSync": true
},
"coder.useKeyring": {
- "markdownDescription": "Store session tokens in the OS keyring (macOS Keychain, Windows Credential Manager) instead of plaintext files. Requires CLI >= 2.29.0 (>= 2.31.0 to sync login from CLI to VS Code). This will attempt to sync between the CLI and VS Code since they share the same keyring entry. It will log you out of the CLI if you log out of the IDE, and vice versa. Has no effect on Linux.",
+ "markdownDescription": "Store session tokens in the OS keyring (macOS Keychain, Windows Credential Manager) instead of a file. Requires Coder CLI 2.29.0 or later; 2.32.0 or later to sign in with the CLI's existing session. Has no effect on Linux.\n\nThe keyring entry is shared with the `coder` CLI: signing in here also signs in the CLI, and signing out asks whether to sign out the CLI too.",
"type": "boolean",
- "default": false,
+ "default": true,
"scope": "application"
},
"coder.networkThreshold.latencyMs": {
@@ -678,14 +678,17 @@
"view/title": [
{
"command": "coder.logout",
+ "group": "deployment@2",
"when": "coder.authenticated && view == myWorkspaces"
},
{
"command": "coder.netcheck",
+ "group": "diagnostics@1",
"when": "coder.authenticated && view == myWorkspaces"
},
{
"command": "coder.switchDeployment",
+ "group": "deployment@1",
"when": "coder.authenticated && view == myWorkspaces"
},
{
@@ -781,28 +784,28 @@
"dependencies": {
"@abraham/reflection": "^0.13.0",
"@opentelemetry/api": "^1.9.1",
- "@opentelemetry/api-logs": "^0.221.0",
+ "@opentelemetry/api-logs": "^0.222.0",
"@peculiar/x509": "^2.0.0",
"@repo/shared": "workspace:*",
- "axios": "^1.19.0",
+ "axios": "^1.20.0",
"date-fns": "catalog:",
"eventsource": "^5.1.1",
"fflate": "^0.8.3",
"find-process": "^2.1.1",
"jsonc-parser": "^3.3.1",
"openpgp": "^6.3.1",
- "pretty-bytes": "^7.1.1",
+ "pretty-bytes": "^7.1.3",
"proper-lockfile": "^4.1.2",
"proxy-agent": "^8.0.2",
"semver": "^7.8.5",
"strip-ansi": "^7.2.0",
"ua-parser-js": "^1.0.41",
"ws": "^8.21.3",
- "zod": "^4.4.3"
+ "zod": "^4.5.4"
},
"devDependencies": {
"@coder/pixel-storybook": "^0.3.0",
- "@eslint-react/eslint-plugin": "^5.18.6",
+ "@eslint-react/eslint-plugin": "^5.18.7",
"@eslint/js": "^10.0.1",
"@eslint/markdown": "^8.0.3",
"@repo/mocks": "workspace:*",
@@ -812,7 +815,8 @@
"@storybook/react-vite": "catalog:",
"@tanstack/react-query": "catalog:",
"@testing-library/jest-dom": "^7.0.1",
- "@testing-library/react": "^16.3.2",
+ "@testing-library/react": "^16.3.3",
+ "@testing-library/user-event": "catalog:",
"@tsconfig/node22": "^22.0.6",
"@types/mocha": "^10.0.10",
"@types/node": "^22.20.1",
@@ -824,8 +828,8 @@
"@types/vscode": "1.105.0",
"@types/vscode-webview": "catalog:",
"@types/ws": "^8.18.1",
- "@typescript-eslint/eslint-plugin": "^8.67.0",
- "@typescript-eslint/parser": "^8.67.0",
+ "@typescript-eslint/eslint-plugin": "^8.69.0",
+ "@typescript-eslint/parser": "^8.69.0",
"@vitejs/plugin-react": "catalog:",
"@vitest/coverage-v8": "^4.1.11",
"@vscode/codicons": "catalog:",
@@ -840,24 +844,25 @@
"dayjs": "^1.11.23",
"electron": "42.5.1",
"esbuild": "^0.28.2",
- "eslint": "^10.9.0",
+ "eslint": "^10.9.1",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.5",
"eslint-plugin-import-x": "^4.17.1",
- "eslint-plugin-package-json": "^1.7.1",
+ "eslint-plugin-package-json": "^1.8.0",
"eslint-plugin-react-hooks": "^7.1.1",
- "eslint-plugin-storybook": "^10.5.10",
- "globals": "^17.11.0",
+ "eslint-plugin-storybook": "^10.6.0",
+ "globals": "^17.12.0",
"jsdom": "^30.0.1",
"jsonc-eslint-parser": "^3.3.0",
- "memfs": "^4.68.1",
+ "memfs": "^4.69.1",
"playwright-core": "^1.62.1",
"prettier": "^3.9.6",
"react": "catalog:",
"react-dom": "catalog:",
"storybook": "catalog:",
+ "storybook-addon-pseudo-states": "catalog:",
"typescript": "catalog:",
- "typescript-eslint": "^8.67.0",
+ "typescript-eslint": "^8.69.0",
"utf-8-validate": "^6.0.6",
"vite": "catalog:",
"vitest": "^4.1.11"
@@ -865,7 +870,7 @@
"extensionPack": [
"ms-vscode-remote.remote-ssh"
],
- "packageManager": "pnpm@11.23.0+sha512.f00082e5b283a199b74e079da28d155c008fe232f44c8a06ea7ddfa014ecf719fc362f790ecc67b28106ddac2afb24c49a9a079159da560b2ce7d1e98efd11af",
+ "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457",
"engines": {
"vscode": "^1.105.0",
"node": ">= 22"
diff --git a/packages/mocks/src/workspaces.ts b/packages/mocks/src/workspaces.ts
index 13378b1968..77b3458f1b 100644
--- a/packages/mocks/src/workspaces.ts
+++ b/packages/mocks/src/workspaces.ts
@@ -2,9 +2,11 @@
* Test factories for Coder SDK workspace types.
*/
+import type { AgentMetadataState } from "@repo/shared";
import type {
Workspace,
WorkspaceAgent,
+ WorkspaceAgentMetadata,
WorkspaceBuild,
WorkspaceResource,
} from "coder/site/src/api/typesGenerated";
@@ -51,13 +53,21 @@ const defaultBuild: WorkspaceBuild = {
template_version_preset_id: null,
};
-/** Create a Workspace with sensible defaults for a running task workspace. */
+/**
+ * Create a Workspace with sensible defaults for a running task workspace.
+ * `agents` puts them on a single resource, the common shape in tests.
+ */
export function workspace(
overrides: Omit, "latest_build"> & {
latest_build?: Partial;
+ agents?: WorkspaceAgent[];
} = {},
): Workspace {
- const { latest_build: buildOverrides, ...rest } = overrides;
+ const { latest_build: buildOverrides, agents, ...rest } = overrides;
+ const build = { ...defaultBuild, ...buildOverrides };
+ if (agents) {
+ build.resources = [resource({ agents })];
+ }
return {
id: "workspace-1",
created_at: "2024-01-01T00:00:00Z",
@@ -75,7 +85,7 @@ export function workspace(
template_active_version_id: "version-1",
template_require_active_version: false,
template_use_classic_parameter_flow: false,
- latest_build: { ...defaultBuild, ...buildOverrides },
+ latest_build: build,
latest_app_status: null,
outdated: false,
name: "test-workspace",
@@ -126,6 +136,41 @@ export function agent(overrides: Partial = {}): WorkspaceAgent {
};
}
+/** Create a WorkspaceAgentMetadata report with sensible defaults. */
+export function agentMetadata(
+ overrides: {
+ result?: Partial;
+ description?: Partial;
+ } = {},
+): WorkspaceAgentMetadata {
+ return {
+ result: {
+ collected_at: "2024-01-01T00:00:00Z",
+ age: 0,
+ value: "42",
+ error: "",
+ ...overrides.result,
+ },
+ description: {
+ display_name: "CPU",
+ key: "cpu",
+ script: "cpu.sh",
+ interval: 5,
+ timeout: 1,
+ ...overrides.description,
+ },
+ };
+}
+
+/** An agent whose socket is open, but which has not reported yet. */
+export const PENDING_METADATA: AgentMetadataState = { kind: "pending" };
+
+/** An agent that reported `agentMetadata()`. */
+export const REPORTED_METADATA: AgentMetadataState = {
+ kind: "reported",
+ metadata: [agentMetadata()],
+};
+
/** Create a WorkspaceResource with sensible defaults. */
export function resource(
overrides: Partial = {},
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 4f9b15143b..684e49e72e 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -30,5 +30,6 @@ export type {
NetcheckSeverity,
} from "./netcheck/types";
-// Workspaces API
+// Workspaces types and API
+export * from "./workspaces/types";
export { WorkspacesApi } from "./workspaces/api";
diff --git a/packages/shared/src/workspaces/api.ts b/packages/shared/src/workspaces/api.ts
index 508b2e5b9e..2f0b6a20f5 100644
--- a/packages/shared/src/workspaces/api.ts
+++ b/packages/shared/src/workspaces/api.ts
@@ -1 +1,31 @@
-export const WorkspacesApi = {} as const;
+/**
+ * Workspaces API - Type-safe message definitions for the Workspaces webview.
+ *
+ * The extension owns the data and pushes it; the webview renders what it is
+ * given and sends back the actions the user takes.
+ */
+
+import { defineCommand, defineNotification } from "../ipc/protocol";
+
+import type {
+ OpenWorkspaceParams,
+ SetFilterParams,
+ ViewInDashboardParams,
+ WatchAgentsParams,
+ WorkspacesState,
+} from "./types";
+
+export const WorkspacesApi = {
+ // Notifications
+ /** The whole state, whenever any of it changes */
+ stateChanged: defineNotification("stateChanged"),
+ // Commands
+ /** Webview signals its subscription is live and asks for the state */
+ ready: defineCommand("ready"),
+ openWorkspace: defineCommand("openWorkspace"),
+ viewInDashboard: defineCommand("viewInDashboard"),
+ refresh: defineCommand("refresh"),
+ setFilter: defineCommand("setFilter"),
+ /** Watch metadata for these agents only, so idle rows cost nothing */
+ watchAgents: defineCommand("watchAgents"),
+} as const;
diff --git a/packages/shared/src/workspaces/types.ts b/packages/shared/src/workspaces/types.ts
new file mode 100644
index 0000000000..41eb4539d7
--- /dev/null
+++ b/packages/shared/src/workspaces/types.ts
@@ -0,0 +1,70 @@
+import type {
+ Workspace,
+ WorkspaceAgent,
+ WorkspaceAgentMetadata,
+} from "coder/site/src/api/typesGenerated";
+
+// Re-export SDK types for convenience
+export type { Workspace, WorkspaceAgent, WorkspaceAgentMetadata };
+
+export type WorkspaceFilter = "mine" | "shared" | "all";
+
+/** A workspace page in the dashboard, opened in the browser. */
+export type DashboardPage = "workspace" | "settings";
+
+/** What the panel may offer for the current session. */
+export interface WorkspacesCapabilities {
+ readonly authenticated: boolean;
+ /** Filters the user may select, in display order. */
+ readonly filters: readonly WorkspaceFilter[];
+}
+
+/**
+ * What the list is doing. `loading` is set only for a list the user waits on:
+ * the first one for a filter, or a refresh. Polls never set it.
+ */
+export type WorkspaceListStatus =
+ | { readonly kind: "loading" }
+ | { readonly kind: "ready" }
+ | { readonly kind: "failed"; readonly error: string };
+
+/** What one agent reports. A failure replaces its metadata in the UI. */
+export type AgentMetadataState =
+ | { readonly kind: "pending" }
+ | {
+ readonly kind: "reported";
+ readonly metadata: readonly WorkspaceAgentMetadata[];
+ }
+ | { readonly kind: "failed"; readonly error: string };
+
+/** Keyed by agent id. */
+export type AgentMetadataMap = Readonly>;
+
+/** Everything the panel renders. Pushed whole whenever any of it changes. */
+export interface WorkspacesState {
+ readonly capabilities: WorkspacesCapabilities;
+ readonly filter: WorkspaceFilter;
+ readonly workspaces: readonly Workspace[];
+ readonly status: WorkspaceListStatus;
+ readonly metadata: AgentMetadataMap;
+}
+
+export interface OpenWorkspaceParams {
+ readonly workspaceId: string;
+ /** Which agent to connect to. Picked interactively when omitted. */
+ readonly agentId?: string;
+}
+
+export interface ViewInDashboardParams {
+ readonly workspaceId: string;
+ readonly page: DashboardPage;
+}
+
+export interface SetFilterParams {
+ readonly filter: WorkspaceFilter;
+}
+
+export interface WatchAgentsParams {
+ /** The agents whose metadata the webview is showing. */
+ readonly agentIds: readonly string[];
+}
diff --git a/packages/ui/README.md b/packages/ui/README.md
index ec79369344..74ffecbc26 100644
--- a/packages/ui/README.md
+++ b/packages/ui/README.md
@@ -7,6 +7,11 @@ Its stable separation boundary is the public root exports, no monorepo runtime
imports, and component CSS using only semantic `--ui-*` tokens. A future package
build can emit those same entry points without API changes.
+Consumers compile these components with the React Compiler, so they follow the
+rules of React and lean on it for memoization. A component that breaks the
+rules is skipped silently rather than reported, which for a list or a tree
+costs a re-render per row, so check with the compiler and not only the linter.
+
## CSS
Import the semantic token mapping and codicon assets once in each real webview
@@ -38,12 +43,126 @@ Every component forwards `className` and `style` to its root element, and
default rules use single-class specificity, so a consumer class imported
after the library overrides any default (width, height, spacing).
-Where VS Code's stable rendering and its Modern UI preview
-(`workbench.experimental.modernUI`) diverge, components follow Modern UI,
-and new components should too. Webviews get no signal for the setting, so
-the default cannot follow the host. Until the design settles,
-`data-ui-style="stable"` on the document root restores the stable-parity
-menu motion; Storybook's "UI style" toolbar switch toggles it live.
+VS Code currently uses its stable UI by default; Modern UI remains behind the
+experimental `workbench.experimental.modernUI` setting. `@repo/ui`
+intentionally uses Modern UI as its package default because webviews receive no
+host signal for that setting. The divergence is isolated: set
+`data-ui-style="stable"` on the document root to restore stable row geometry,
+focus behavior, and menu motion. Storybook's "UI style" toolbar switch toggles
+that override live.
+
+## Tree
+
+`Tree` is controlled: `nodes` describe the hierarchy, `expandedIds` controls
+branches, and the single- or multi-selection props control selection. Each
+visible node renders as a flat `treeitem`, while normal keyboard navigation
+keeps DOM focus on the `tree` container and identifies the active row with
+`aria-activedescendant`. Focus and selection are independent.
+
+```tsx
+const [selectedItemId, setSelectedItemId] = useState("src");
+const [expandedIds, setExpandedIds] = useState(["src"]);
+
+;
+```
+
+Ids must be unique across the whole tree, and a duplicate throws. A string
+`label` is also the accessible name; a rich label must provide `textValue`. `children` marks a branch, including an empty array for a branch
+whose children are still loading. `icon`, `action`, and `className` customize
+the row. Actions stay live on plain hover, as in the native list, and are
+isolated from row selection and expansion.
+
+Arrow Up/Down, Home, End, PageUp/PageDown, and buffered prefix/fuzzy typing
+move the active row through visible rows. Arrow Right
+expands a branch or enters it; Arrow Left collapses it or moves to its parent.
+
+`expandMode="singleClick"` is the default: clicking a branch selects
+and toggles it, and Enter does the same. With `expandMode="doubleClick"`, a
+single click or Enter only selects and a double click toggles expansion. Space
+toggles a branch without selecting it, or selects a leaf. A normal-row twistie
+toggles without changing selection. Alt-click recursively toggles descendant
+branches unless Alt is configured as the multi-selection modifier.
+
+Escape clears selection. It also clears the active focus mark when the tree has
+at most one selected row; after a larger multi-selection, a second Escape
+clears the remaining focus mark. Once neither selection nor a focus mark
+remains, Escape is left to the host. The root `onKeyDown` runs first, so a host
+can intercept shortcuts with `preventDefault()`.
+
+`multiSelect` uses `selectedItemIds` and `onSelectedItemsChange` and sets
+`aria-multiselectable`. `multiSelectModifier` chooses the toggle modifier:
+`"ctrlCmd"` (the default) uses Ctrl/Cmd and `"alt"` uses Alt. Shift-click and
+Shift+Arrow extend from the selection anchor; modifier clicks take precedence
+over expansion. Ctrl/Cmd+A selects the visible rows in the active sibling
+scope.
+
+`stickyScroll` pins ancestors against the nearest scrolling ancestor. `true`
+uses a maximum of seven pinned rows; a number supplies the maximum, and the
+widget is also capped at 40% of the viewport. The pinned region is a separate
+tab stop: Arrow Up/Down move among pinned ancestors, Arrow Down/Right from the
+deepest row enters its first visible child, Enter reveals, focuses, and selects
+the real row, Arrow Left reveals and focuses it and collapses an expanded
+branch, and Space only reveals and focuses it. A plain pointer click reveals,
+focuses, and selects; a pinned twistie additionally toggles the branch.
+Selection-modifier clicks update selection without revealing the real row.
+
+Webviews do not receive `workbench.tree.*` settings automatically. Consumers
+that mirror native sticky-scroll preferences must read
+`workbench.tree.enableStickyScroll` and
+`workbench.tree.stickyScrollMaxItemCount` in the extension host and send the
+values to the webview.
+
+```mermaid
+flowchart LR
+ accTitle: Tree architecture
+ accDescr: Data and input flow through the pure Tree modules into the React and DOM adapter.
+
+ Props[Nodes and controlled props] --> Model[treeModel.ts]
+ Events[Pointer and keyboard events] --> Policy[treePolicy.ts]
+ Policy --> Commands[Tree commands]
+ Model --> Transition[treeTransition.ts]
+ Commands --> Transition
+ Transition --> Adapter[useTreeAdapter.ts]
+ Adapter --> Rows[Tree.tsx and TreeRow.tsx]
+ Adapter --> Sticky[StickyScroll.tsx]
+ Rows --> Hover[TreeHover.tsx]
+```
+
+The model, policy, and transitions stay pure. The adapter owns React and DOM
+integration. The flat visible model supports future windowing, but the Tree is
+not currently virtualized.
+
+Rows are 22px tall and keep the VS Code twistie gutter. For Explorer-style file
+trees whose branches have no icons, `variant="explorer"` aligns leaf icons with
+branch twisties; do not combine it with branch icons. Indent guides appear on
+hover, selected ancestor paths stay active, and the focused path is active only
+while the tree has focus. The package default uses inset Modern UI rows;
+`data-ui-style="stable"` restores edge-to-edge square rows and stable focus
+styling.
+
+Labels hover with the node's text value, so truncated rows stay readable.
+Set `tooltip` for richer content or `null` to opt out. One bubble serves the
+whole tree, as in the native list: an invisible anchor moves to whatever the
+pointer reaches, taking its x from the cursor and its y from the target's box,
+the way a native hover placed at the mouse does. Each new target waits out the
+show delay, except within a row's action bar, where crossing between buttons is
+instant, the exception native grants a dense cluster of targets. Ctrl+K Ctrl+I opens the focused
+row's hover with no delay at all, and moving the focus closes it.
## Overlays
@@ -62,15 +181,55 @@ tooltips.
`TooltipProvider` ancestor. Mount one provider per app so that a pointer
moving between nearby triggers skips the show delay, like native hovers.
The delay defaults to 500ms, matching VS Code's `workbench.hover.delay`,
-and tooltips stop growing at half the window height.
+and tooltips stop growing at half the window height. Components that own
+their hovers fall back to a private provider when the app has none, so
+`Tree` rows and `IconButton` work unwrapped. A private provider keeps its own
+skip-delay, though, so an app with several of them makes every hover wait out
+the full delay; mount one provider and they share it. `IconButton` hints with
+its label like a native action bar item; pass `tooltip` to say something else,
+or `null` for a button that stays quiet.
+
+`HoverDelegateScope` hands every `Tooltip` inside it to one shared bubble
+instead of a bubble each, the way a VS Code list serves its rows and their
+action bars from a single hover widget. `Tree` uses it, which is also what
+lets one place decide when a hover is instant rather than delayed.
Overlay content is portalled to `body`, inherits webview typography from
there, and shares the `.ui-overlay` base for stacking, border, shadow,
-and scrolling. Menus default to the Modern UI motion: they scale and fade in
-from the trigger corner and fade out on close, with Radix holding unmount
-until the exit animation ends. High contrast, `forced-colors`, and
+scrolling, and highlighted rows. Menus default to the Modern UI motion:
+they scale and fade in from the trigger corner and fade out on close, with
+Radix holding unmount until the exit animation ends. High contrast, `forced-colors`, and
`prefers-reduced-motion` are handled.
+## Form controls
+
+`Input`, `Textarea`, `Checkbox`, `Select`, and `Field`/`Label` cover forms
+the way VS Code's own settings editor does: text field, number field,
+checkbox, and dropdown. Richer shapes map onto that vocabulary instead of
+getting bespoke widgets: a switch renders as `Checkbox`, a radio group or
+slider-bounded number as `Select` or a number `Input`, a multi-select as
+stacked `Checkbox` controls inside a `Field`.
+
+`Input` and `Textarea` are controlled with `value` and `onChange(next)`;
+`Checkbox` uses `checked` and `onChange(next)`. Native-element props and
+refs pass through to the control; `className` and `style` target the root.
+`Select` wraps `@radix-ui/react-select` and preserves its controlled
+(`value` / `onValueChange`) and uncontrolled (`defaultValue`) modes, with
+flat compound exports such as `SelectTrigger` and `SelectItem`, as the
+menus do.
+`Input` renders `children` after the control for trailing in-field
+actions; `PasswordInput` uses that slot for a reveal toggle styled like the
+find widget's option buttons.
+
+`Field` lays out a semibold `Label`, children, description, and error text.
+It does not clone children or require a form context, so native elements
+and third-party controls work the same way: connect `htmlFor` to the
+control's `id`, and pass `descriptionId` / `errorId` to give the rendered
+text IDs the control can point `aria-describedby` at. The consumer owns
+`aria-describedby`, `aria-invalid`, validation, and when to announce
+errors. For a group of checkboxes, use a native `fieldset` with a `legend`
+for the group name rather than pointing a single label at several controls.
+
## Known gaps
- Overlay shadows are darker than native in dark themes: menus in VS Code
@@ -79,7 +238,6 @@ until the exit animation ends. High contrast, `forced-colors`, and
- Keybinding hints show the contributed defaults the consumer passes, not
user remaps: VS Code exposes no API for extensions to resolve a command's
effective keybinding.
-- List/selection-row tokens are deferred to the Tree suite (#1037).
## Codicons
@@ -91,10 +249,12 @@ without a generated source file or a runtime list in the public API.
ESLint rejects `@repo/*` imports and relative cross-package imports in
`packages/ui` TypeScript and TSX source. `react` remains a peer dependency;
-the only runtime dependencies are the Radix overlay primitives and
+the only runtime dependencies are the Radix primitives and
`@vscode/codicons`. Public consumers import from the package root or its
declared CSS exports.
Shared internals are reached through `package.json` subpath imports (`#cx`,
`#codicons`, `#storybook`). These resolve only inside this package and ship
-with it, so they survive a standalone NPM split.
+with it, so they survive a standalone NPM split. Component families keep
+their own internals (contexts, stores) inside their folder and import them
+relatively, so a family can lift out wholesale.
diff --git a/packages/ui/package.json b/packages/ui/package.json
index a2ea866658..f027f307c2 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -19,6 +19,7 @@
"imports": {
"#cx": "./src/cx.ts",
"#codicons": "./src/codicons.ts",
+ "#ref": "./src/ref.ts",
"#storybook": "./src/storybook.ts"
},
"scripts": {
@@ -27,6 +28,8 @@
"dependencies": {
"@radix-ui/react-context-menu": "^2.3.7",
"@radix-ui/react-dropdown-menu": "^2.1.24",
+ "@radix-ui/react-select": "^2.3.7",
+ "@radix-ui/react-slot": "^1.3.3",
"@radix-ui/react-tooltip": "^1.2.16",
"@vscode/codicons": "catalog:"
},
diff --git a/packages/ui/src/components/Checkbox/Checkbox.css b/packages/ui/src/components/Checkbox/Checkbox.css
new file mode 100644
index 0000000000..4123f510c9
--- /dev/null
+++ b/packages/ui/src/components/Checkbox/Checkbox.css
@@ -0,0 +1,49 @@
+.ui-checkbox {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ cursor: pointer;
+ user-select: none;
+}
+
+.ui-checkbox:has(> :disabled) {
+ opacity: var(--ui-disabled-opacity);
+ cursor: default;
+}
+
+/* Invisible over the box, keeping the native hit target and focus source */
+.ui-checkbox__input {
+ position: absolute;
+ width: 18px;
+ height: 18px;
+ margin: 0;
+ opacity: 0;
+ cursor: inherit;
+}
+
+/* Native checkbox geometry (checkbox.css): 18px box, 3px parity-pinned radius */
+.ui-checkbox__box {
+ flex: none;
+ width: 18px;
+ height: 18px;
+ color: var(--ui-checkbox-foreground);
+ background: var(--ui-checkbox-background);
+ border: 1px solid var(--ui-checkbox-border);
+ border-radius: 3px;
+}
+
+.ui-checkbox__input:focus + .ui-checkbox__box {
+ border-color: var(--ui-focus-border);
+}
+
+@media (forced-colors: active) {
+ .ui-checkbox__box {
+ color: Highlight;
+ border-color: CanvasText;
+ }
+
+ .ui-checkbox__input:focus + .ui-checkbox__box {
+ border-color: Highlight;
+ }
+}
diff --git a/packages/ui/src/components/Checkbox/Checkbox.stories.tsx b/packages/ui/src/components/Checkbox/Checkbox.stories.tsx
new file mode 100644
index 0000000000..099640eff6
--- /dev/null
+++ b/packages/ui/src/components/Checkbox/Checkbox.stories.tsx
@@ -0,0 +1,46 @@
+import { useState } from "react";
+import { within } from "storybook/test";
+
+import { PIXEL_ALL_THEMES } from "#storybook";
+
+import { Checkbox } from "./Checkbox";
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+const CheckboxStates = (): React.JSX.Element => {
+ const [checked, setChecked] = useState(true);
+ return (
+
+ );
+}
diff --git a/packages/ui/src/components/Tree/sticky/stickyState.ts b/packages/ui/src/components/Tree/sticky/stickyState.ts
new file mode 100644
index 0000000000..a180208547
--- /dev/null
+++ b/packages/ui/src/components/Tree/sticky/stickyState.ts
@@ -0,0 +1,72 @@
+import { ROW_HEIGHT_PX, type TreeRowModel } from "../treeModel";
+
+/** VS Code caps the sticky widget at 40% of the viewport. */
+const MAX_VIEWPORT_RATIO = 0.4;
+
+export interface StickyState {
+ /** Ids of the pinned ancestor chain, outermost first. */
+ readonly ids: readonly string[];
+ /** Upward shift in px while the last pinned subtree scrolls out. */
+ readonly pushOffset: number;
+}
+
+export const NO_STICKY: StickyState = { ids: [], pushOffset: 0 };
+
+/**
+ * The ancestor chain to pin, like VS Code's findStickyState: the ancestors
+ * of the topmost row not covered by the widget, capped by `maxCount` and by
+ * viewport share. Pinned rows cover rows below, which can deepen the chain,
+ * so grow to a fixpoint.
+ */
+export function computeStickyState(
+ rows: readonly TreeRowModel[],
+ scrolledPx: number,
+ viewportPx: number,
+ maxCount: number,
+): StickyState {
+ const cap = Math.min(
+ maxCount,
+ Math.floor((viewportPx * MAX_VIEWPORT_RATIO) / ROW_HEIGHT_PX),
+ );
+ if (scrolledPx <= 0 || cap <= 0) {
+ return NO_STICKY;
+ }
+ const topIndex = Math.floor(scrolledPx / ROW_HEIGHT_PX);
+ let count = 0;
+ let chain: readonly string[] = [];
+ for (;;) {
+ const rowChain = rows[topIndex + count]?.pathIds ?? [];
+ const next = Math.min(rowChain.length, cap);
+ if (next <= count) {
+ break;
+ }
+ count = next;
+ chain = rowChain;
+ }
+ const ids = chain.slice(0, count);
+ if (ids.length === 0) {
+ return NO_STICKY;
+ }
+ return { ids, pushOffset: pushOffset(rows, scrolledPx, ids) };
+}
+
+/** How far the widget shifts up as the last pinned subtree ends. */
+function pushOffset(
+ rows: readonly TreeRowModel[],
+ scrolledPx: number,
+ ids: readonly string[],
+): number {
+ const lastId = ids.at(-1);
+ let endIndex = -1;
+ rows.forEach((row, index) => {
+ if (row.node.id === lastId || row.pathIds.includes(lastId ?? "")) {
+ endIndex = index;
+ }
+ });
+ if (endIndex === -1) {
+ return 0;
+ }
+ const subtreeBottom = (endIndex + 1) * ROW_HEIGHT_PX;
+ const widgetBottom = scrolledPx + ids.length * ROW_HEIGHT_PX;
+ return Math.min(0, subtreeBottom - widgetBottom);
+}
diff --git a/packages/ui/src/components/Tree/treeModel.ts b/packages/ui/src/components/Tree/treeModel.ts
new file mode 100644
index 0000000000..9850ab3094
--- /dev/null
+++ b/packages/ui/src/components/Tree/treeModel.ts
@@ -0,0 +1,105 @@
+import type { ReactNode } from "react";
+
+import type { CodiconName } from "#codicons";
+
+/** VS Code's tree row height; Tree.css --ui-tree-row-height must match. */
+export const ROW_HEIGHT_PX = 22;
+
+/** A string label doubles as the text value; a rich label must supply one. */
+type TreeNodeLabel =
+ | { readonly label: string; readonly textValue?: string }
+ | { readonly label: ReactNode; readonly textValue: string };
+
+/** One node of tree data. `children` marks a branch, `[]` one still loading. */
+export type TreeNode = TreeNodeLabel & {
+ readonly id: string;
+ readonly icon?: CodiconName;
+ /** Hover content; defaults to the text value, `null` opts out. */
+ readonly tooltip?: ReactNode;
+ readonly action?: ReactNode;
+ readonly className?: string;
+ readonly children?: readonly TreeNode[];
+};
+
+export interface TreeRowModel {
+ readonly node: TreeNode;
+ /** Ancestor ids, outermost first; the ARIA level is one past its length. */
+ readonly pathIds: readonly string[];
+ /** Flat rows have no group element, so each declares its own set. */
+ readonly posInSet: number;
+ readonly setSize: number;
+ readonly textValue: string;
+ /** undefined on leaves. */
+ readonly expanded: boolean | undefined;
+}
+
+export interface TreeModel {
+ /** Rows under expanded ancestors only, in render order. */
+ readonly visibleRows: readonly TreeRowModel[];
+ /** Every row, hidden ones included. */
+ readonly rows: readonly TreeRowModel[];
+ readonly rowsById: ReadonlyMap;
+ readonly visibleIds: ReadonlySet;
+}
+
+/** The row's hover content; empty when the node opts out. */
+export function rowTooltip(row: TreeRowModel): ReactNode {
+ const { tooltip } = row.node;
+ return tooltip === undefined ? row.textValue : tooltip;
+}
+
+export function parentId(row: TreeRowModel): string | undefined {
+ return row.pathIds.at(-1);
+}
+
+/** Ids are unique tree wide, as in VS Code, so a duplicate throws. */
+export function createTreeModel(
+ nodes: readonly TreeNode[],
+ expandedIds: ReadonlySet,
+): TreeModel {
+ const visibleRows: TreeRowModel[] = [];
+ const rows: TreeRowModel[] = [];
+ const rowsById = new Map();
+
+ const visit = (
+ siblings: readonly TreeNode[],
+ pathIds: readonly string[],
+ visible: boolean,
+ ): void => {
+ siblings.forEach((node, index) => {
+ if (rowsById.has(node.id)) {
+ throw new Error(`Tree node id "${node.id}" must be unique.`);
+ }
+ const expanded = node.children ? expandedIds.has(node.id) : undefined;
+ const row: TreeRowModel = {
+ node,
+ pathIds,
+ posInSet: index + 1,
+ setSize: siblings.length,
+ textValue:
+ node.textValue ?? (typeof node.label === "string" ? node.label : ""),
+ expanded,
+ };
+ rows.push(row);
+ rowsById.set(node.id, row);
+ if (visible) {
+ visibleRows.push(row);
+ }
+ if (node.children) {
+ visit(
+ node.children,
+ [...pathIds, node.id],
+ visible && expanded === true,
+ );
+ }
+ });
+ };
+
+ visit(nodes, [], true);
+ return {
+ visibleRows,
+ rows,
+ rowsById,
+ visibleIds: new Set(visibleRows.map((row) => row.node.id)),
+ };
+}
diff --git a/packages/ui/src/components/Tree/treePolicy.ts b/packages/ui/src/components/Tree/treePolicy.ts
new file mode 100644
index 0000000000..1bd7d55fcb
--- /dev/null
+++ b/packages/ui/src/components/Tree/treePolicy.ts
@@ -0,0 +1,299 @@
+/**
+ * The VS Code key and pointer bindings, as the commands a gesture means for a
+ * row. "Policy" because it decides intent only: `treeTransition.ts` applies it.
+ */
+
+import { parentId, type TreeRowModel } from "./treeModel";
+
+/** Mirrors `workbench.tree.expandMode`, values included. */
+export type TreeExpandMode = "singleClick" | "doubleClick";
+
+/** Mirrors `workbench.list.multiSelectModifier`, values included. */
+export type TreeMultiSelectModifier = "ctrlCmd" | "alt";
+
+export interface TreeCommandBehavior {
+ readonly expandMode: TreeExpandMode;
+ readonly multiSelect: boolean;
+ readonly multiSelectModifier: TreeMultiSelectModifier;
+}
+
+/** The modifier keys a gesture carries, as a DOM event reports them. */
+export interface TreeModifiers {
+ readonly ctrlKey: boolean;
+ readonly metaKey: boolean;
+ readonly altKey: boolean;
+ readonly shiftKey: boolean;
+}
+
+interface SelectOptions {
+ /** Adds to or removes from the selection instead of replacing it. */
+ readonly toggle: boolean;
+ /** Selects from the anchor through this row. */
+ readonly range: boolean;
+ /** Whether selected rows hidden under a collapsed branch survive. */
+ readonly preserveHidden: boolean;
+}
+
+type RowCommand = {
+ readonly type: Type;
+ readonly id: string;
+} & Options;
+
+export type TreeCommand =
+ | RowCommand<"focus">
+ /** Selects the row's sibling group, widening to its parent once full. */
+ | RowCommand<"selectScope">
+ | RowCommand<
+ "move",
+ {
+ readonly offset: -1 | 1;
+ /** A viewport's worth of rows rather than one. */
+ readonly page: boolean;
+ /** Extends the selection to the row moved to. */
+ readonly extend: boolean;
+ }
+ >
+ | RowCommand<"select", SelectOptions>
+ | RowCommand<"toggle", { readonly recursive: boolean }>
+ | RowCommand<"typeahead", { readonly key: string }>
+ | {
+ readonly type: "dismiss";
+ readonly clearSelection: boolean;
+ readonly clearFocus: boolean;
+ };
+
+interface CommandInput extends TreeCommandBehavior {
+ readonly row: TreeRowModel;
+ readonly modifiers: TreeModifiers;
+}
+
+export interface PointerCommandInput extends CommandInput {
+ /** A pinned row selects without the expand-on-click its body would get. */
+ readonly source: "row" | "sticky";
+ readonly onTwistie: boolean;
+ /** `MouseEvent.detail`, so 2 on a double click. */
+ readonly detail: number;
+}
+
+export interface KeyboardCommandInput extends CommandInput {
+ readonly key: string;
+ readonly visibleRows: readonly TreeRowModel[];
+ /** Whether a control inside the row, not the row, has focus. */
+ readonly fromAction: boolean;
+ readonly selectedCount: number;
+ readonly hasFocusedRow: boolean;
+}
+
+interface KeyboardOutcome {
+ readonly commands: readonly TreeCommand[];
+ readonly preventDefault: boolean;
+ /** Set when a row action navigates, so the row takes focus back. */
+ readonly focusRowElementId: string | undefined;
+}
+
+const NO_COMMANDS: readonly TreeCommand[] = [];
+
+/** The keys the tree claims even while a row action has focus. */
+const NAVIGATION_KEYS: ReadonlySet = new Set([
+ "ArrowDown",
+ "ArrowUp",
+ "ArrowLeft",
+ "ArrowRight",
+ "PageDown",
+ "PageUp",
+ "Home",
+ "End",
+]);
+
+const focusCommand = (id: string): TreeCommand => ({ type: "focus", id });
+const selectCommand = (
+ id: string,
+ options: Partial = {},
+): TreeCommand => ({
+ type: "select",
+ id,
+ toggle: false,
+ range: false,
+ preserveHidden: true,
+ ...options,
+});
+const toggleCommand = (id: string, recursive = false): TreeCommand => ({
+ type: "toggle",
+ id,
+ recursive,
+});
+
+/** Whether the gesture adds to the selection rather than replacing it. */
+export function isSelectionModifier(
+ modifiers: TreeModifiers,
+ behavior: TreeCommandBehavior,
+): boolean {
+ if (!behavior.multiSelect) {
+ return false;
+ }
+ return behavior.multiSelectModifier === "alt"
+ ? modifiers.altKey
+ : modifiers.ctrlKey || modifiers.metaKey;
+}
+
+/** Whether the gesture is about selection at all, ranges included. */
+export function isSelectionGesture(
+ modifiers: TreeModifiers,
+ behavior: TreeCommandBehavior,
+): boolean {
+ return (
+ isSelectionModifier(modifiers, behavior) ||
+ (behavior.multiSelect && modifiers.shiftKey)
+ );
+}
+
+/** The commands a click on `row` means, twistie clicks included. */
+export function pointerCommands(
+ input: PointerCommandInput,
+): readonly TreeCommand[] {
+ const { row, source, expandMode, detail, modifiers } = input;
+ const id = row.node.id;
+
+ if (isSelectionGesture(modifiers, input)) {
+ // Hidden rows drop out: a selection the user cannot see cannot be judged.
+ const select = selectCommand(id, {
+ toggle: isSelectionModifier(modifiers, input),
+ range: modifiers.shiftKey,
+ preserveHidden: false,
+ });
+ return source === "sticky" ? [select] : [focusCommand(id), select];
+ }
+
+ // Alt expands recursively unless it is the selection modifier.
+ const toggle = toggleCommand(
+ id,
+ modifiers.altKey && input.multiSelectModifier !== "alt",
+ );
+ if (input.onTwistie) {
+ return source === "sticky"
+ ? [focusCommand(id), selectCommand(id), toggle]
+ : [focusCommand(id), toggle];
+ }
+ const togglesBody =
+ source === "row" &&
+ row.expanded !== undefined &&
+ (expandMode === "singleClick" ? detail <= 1 : detail === 2);
+ return togglesBody
+ ? [focusCommand(id), selectCommand(id), toggle]
+ : [focusCommand(id), selectCommand(id)];
+}
+
+/** The commands a key press means, plus who keeps the event afterwards. */
+export function keyboardCommands(input: KeyboardCommandInput): KeyboardOutcome {
+ const { key, row, visibleRows, modifiers } = input;
+ const id = row.node.id;
+ // A key pressed inside a row action belongs to it, unless it navigates.
+ if (input.fromAction && !NAVIGATION_KEYS.has(key)) {
+ return {
+ commands: NO_COMMANDS,
+ preventDefault: false,
+ focusRowElementId: undefined,
+ };
+ }
+ const outcome = (
+ commands: readonly TreeCommand[],
+ preventDefault = true,
+ ): KeyboardOutcome => ({
+ commands,
+ preventDefault,
+ focusRowElementId: input.fromAction ? id : undefined,
+ });
+ const selectionModifier = isSelectionModifier(modifiers, input);
+
+ // Ctrl/Cmd+A, which native scopes to the sibling group before widening.
+ if (
+ selectionModifier &&
+ !modifiers.shiftKey &&
+ key.toLocaleLowerCase() === "a"
+ ) {
+ return outcome([{ type: "selectScope", id }]);
+ }
+
+ switch (key) {
+ case "ArrowDown":
+ case "ArrowUp":
+ case "PageDown":
+ case "PageUp": {
+ const page = key === "PageDown" || key === "PageUp";
+ const offset = key === "ArrowDown" || key === "PageDown" ? 1 : -1;
+ return outcome([
+ {
+ type: "move",
+ id,
+ offset,
+ page,
+ extend: !page && input.multiSelect && modifiers.shiftKey,
+ },
+ ]);
+ }
+ case "Home":
+ case "End": {
+ const target = key === "Home" ? visibleRows[0] : visibleRows.at(-1);
+ return outcome(target ? [focusCommand(target.node.id)] : NO_COMMANDS);
+ }
+ case "ArrowRight": {
+ if (row.expanded === false) {
+ return outcome([toggleCommand(id)]);
+ }
+ const child = row.expanded
+ ? visibleRows[visibleRows.indexOf(row) + 1]
+ : undefined;
+ return outcome(
+ child?.pathIds.includes(id)
+ ? [focusCommand(child.node.id)]
+ : NO_COMMANDS,
+ );
+ }
+ case "ArrowLeft": {
+ if (row.expanded === true) {
+ return outcome([toggleCommand(id)]);
+ }
+ const parent = parentId(row);
+ return outcome(parent ? [focusCommand(parent)] : NO_COMMANDS);
+ }
+ case "Enter": {
+ // Ctrl+Shift+Enter toggles this row and leaves the rest selected.
+ if (selectionModifier && modifiers.shiftKey) {
+ return outcome([selectCommand(id, { toggle: true })]);
+ }
+ const select = selectCommand(id, { toggle: selectionModifier });
+ const alsoToggles =
+ row.expanded !== undefined && input.expandMode === "singleClick";
+ return outcome(alsoToggles ? [select, toggleCommand(id)] : [select]);
+ }
+ case " ":
+ // A leaf has nothing to toggle, so Space selects it instead.
+ return outcome([
+ row.expanded === undefined
+ ? selectCommand(id, { toggle: selectionModifier })
+ : toggleCommand(id),
+ ]);
+ case "Escape": {
+ const clearSelection = input.selectedCount > 0;
+ const clearFocus = input.selectedCount <= 1 && input.hasFocusedRow;
+ return outcome(
+ [{ type: "dismiss", clearSelection, clearFocus }],
+ clearSelection || input.hasFocusedRow,
+ );
+ }
+ default: {
+ // A bare printable key types ahead; anything else is the host's.
+ const typesAhead =
+ key.length === 1 &&
+ !modifiers.ctrlKey &&
+ !modifiers.metaKey &&
+ !modifiers.altKey;
+ return outcome(
+ typesAhead
+ ? [{ type: "typeahead", id, key: key.toLocaleLowerCase() }]
+ : NO_COMMANDS,
+ typesAhead,
+ );
+ }
+ }
+}
diff --git a/packages/ui/src/components/Tree/treeTransition.ts b/packages/ui/src/components/Tree/treeTransition.ts
new file mode 100644
index 0000000000..42be5cb2ff
--- /dev/null
+++ b/packages/ui/src/components/Tree/treeTransition.ts
@@ -0,0 +1,512 @@
+/**
+ * The interaction state props cannot hold: focus, the tab stop, the selection
+ * anchor, the type-ahead buffer, and container focus. `deriveTreeInteractionView`
+ * reads it against the current model and resolves what the rows render;
+ * `transitionTree` folds commands into it.
+ */
+
+import { parentId, type TreeModel, type TreeRowModel } from "./treeModel";
+
+import type { TreeCommand } from "./treePolicy";
+
+/** How long a type-ahead query keeps collecting keys, as in the native list. */
+const TYPE_QUERY_MS = 800;
+
+/** The focused row, with its ancestors to fall back on if it disappears. */
+interface FocusTarget {
+ readonly id: string;
+ readonly pathIds: readonly string[];
+}
+
+export interface TreeInteractionState {
+ readonly focusTarget?: FocusTarget;
+ /** The row Tab returns to, which outlives a row leaving the viewport. */
+ readonly tabTargetId?: string;
+ /** The selection whose tab stop the user already moved away from. */
+ readonly dismissedSelectionKey?: string;
+ /** The selection the anchor belongs to; a new one from props resets it. */
+ readonly anchorKey: string;
+ /** Where a range selection measures from. */
+ readonly anchorId?: string;
+ readonly hasDomFocus: boolean;
+ readonly typeQuery?: string;
+ readonly typeExpires?: number;
+}
+
+/** What the rows render from, derived fresh on every render. */
+interface TreeInteractionView {
+ readonly state: TreeInteractionState;
+ readonly controlledKey: string;
+ readonly selectedIds: ReadonlySet;
+ readonly focusedId: string | undefined;
+ readonly anchorId: string | undefined;
+ readonly guideOwnerIds: ReadonlySet;
+ readonly tabStopId: string | undefined;
+}
+
+interface TransitionInput {
+ readonly model: TreeModel;
+ readonly controlledIds: readonly string[];
+ readonly expandedIds: readonly string[];
+ readonly multiSelect: boolean;
+ /** Rows a page key should travel, measured against the scroller. */
+ readonly pageOffset?: number;
+ readonly now: number;
+}
+
+interface TreeTransition {
+ readonly state: TreeInteractionState;
+ /** Set only when the commands changed it, since selection is controlled. */
+ readonly selection?: readonly string[];
+ readonly expandedIds?: readonly string[];
+ readonly focusTree: boolean;
+}
+
+/** Selections compare by value: the ids arrive fresh in props each render. */
+const selectionKey = (ids: readonly string[]): string =>
+ JSON.stringify([...new Set(ids)].sort());
+const NO_SELECTION_KEY = selectionKey([]);
+
+const focusTarget = (row: TreeRowModel): FocusTarget => ({
+ id: row.node.id,
+ pathIds: row.pathIds,
+});
+
+export function initialTreeInteractionState(
+ controlledIds: readonly string[],
+): TreeInteractionState {
+ return {
+ anchorKey: selectionKey(controlledIds),
+ anchorId: controlledIds[0],
+ hasDomFocus: false,
+ };
+}
+
+/**
+ * Points the state at rows the model still has, returning it unchanged when it
+ * already does; callers compare by identity to spot data moving under them.
+ */
+function reconcile(
+ state: TreeInteractionState,
+ model: TreeModel,
+): TreeInteractionState {
+ const { rowsById, visibleIds } = model;
+ if (state.focusTarget && !rowsById.has(state.focusTarget.id)) {
+ const fallbackId = state.focusTarget.pathIds.findLast((id) =>
+ visibleIds.has(id),
+ );
+ const fallback = fallbackId ? rowsById.get(fallbackId) : undefined;
+ return {
+ ...state,
+ focusTarget: fallback ? focusTarget(fallback) : undefined,
+ tabTargetId: fallbackId,
+ };
+ }
+ if (state.tabTargetId && !rowsById.has(state.tabTargetId)) {
+ return { ...state, tabTargetId: undefined };
+ }
+ return state;
+}
+
+/** The guides VS Code draws solid: the paths down to selection and focus. */
+function activeGuideOwners(
+ visibleRows: readonly TreeRowModel[],
+ selectedIds: ReadonlySet,
+ focusedId: string | undefined,
+): ReadonlySet {
+ const owners = new Set();
+ for (const row of visibleRows) {
+ if (!selectedIds.has(row.node.id) && focusedId !== row.node.id) {
+ continue;
+ }
+ const ownerId = row.expanded ? row.node.id : parentId(row);
+ if (ownerId) {
+ owners.add(ownerId);
+ }
+ }
+ return owners;
+}
+
+export function deriveTreeInteractionView(
+ state: TreeInteractionState,
+ model: TreeModel,
+ controlledIds: readonly string[],
+): TreeInteractionView {
+ const { visibleRows, rowsById, visibleIds } = model;
+ const nextState = reconcile(state, model);
+ const { focusTarget: focus, tabTargetId } = nextState;
+ const focusedId = focus && visibleIds.has(focus.id) ? focus.id : undefined;
+ // Focus kept out of view holds the tab stop, so Tab cannot move the user.
+ const hiddenFocus =
+ focus !== undefined && !focusedId && rowsById.has(focus.id);
+ const selectedIds = new Set(controlledIds);
+ const controlledKey = selectionKey(controlledIds);
+ const claimedSelection =
+ nextState.dismissedSelectionKey === controlledKey
+ ? undefined
+ : visibleRows.find((row) => selectedIds.has(row.node.id))?.node.id;
+ const tabTarget =
+ tabTargetId && visibleIds.has(tabTargetId) ? tabTargetId : undefined;
+
+ return {
+ state: nextState,
+ controlledKey,
+ selectedIds,
+ focusedId,
+ anchorId:
+ nextState.anchorKey === controlledKey
+ ? nextState.anchorId
+ : controlledIds[0],
+ guideOwnerIds: activeGuideOwners(
+ visibleRows,
+ selectedIds,
+ nextState.hasDomFocus ? focusedId : undefined,
+ ),
+ tabStopId:
+ claimedSelection ??
+ tabTarget ??
+ (hiddenFocus ? undefined : visibleRows[0]?.node.id),
+ };
+}
+
+/** Adopts `row` as the focused row on first entry, never after. */
+export function treeFocusChanged(
+ state: TreeInteractionState,
+ focused: boolean,
+ row?: TreeRowModel,
+): TreeInteractionState {
+ if (!focused) {
+ return state.hasDomFocus ? { ...state, hasDomFocus: false } : state;
+ }
+ return {
+ ...state,
+ focusTarget: state.focusTarget ?? (row ? focusTarget(row) : undefined),
+ hasDomFocus: true,
+ };
+}
+
+/** Focus moved to `row`, which also becomes the tab stop from now on. */
+export function rowFocused(
+ state: TreeInteractionState,
+ row: TreeRowModel,
+ controlledKey: string,
+): TreeInteractionState {
+ return {
+ ...state,
+ focusTarget: focusTarget(row),
+ tabTargetId: row.node.id,
+ dismissedSelectionKey: controlledKey,
+ };
+}
+
+/**
+ * The native range: the run of selected rows around the anchor is released
+ * first, so shrinking a range back over itself deselects what it passes.
+ */
+function selectionRange(
+ visibleRows: readonly TreeRowModel[],
+ selectedIds: ReadonlySet,
+ anchorId: string,
+ targetId: string,
+): Set | undefined {
+ const rowIds = visibleRows.map((row) => row.node.id);
+ const anchor = rowIds.indexOf(anchorId);
+ const target = rowIds.indexOf(targetId);
+ if (anchor < 0 || target < 0) {
+ return undefined;
+ }
+ const ids = new Set(selectedIds);
+ let start = anchor;
+ let end = anchor;
+ while (start > 0 && ids.has(rowIds[start - 1] ?? "")) {
+ start--;
+ }
+ while (end < rowIds.length - 1 && ids.has(rowIds[end + 1] ?? "")) {
+ end++;
+ }
+ for (const id of rowIds.slice(start, end + 1)) {
+ ids.delete(id);
+ }
+ for (const id of rowIds.slice(
+ Math.min(anchor, target),
+ Math.max(anchor, target) + 1,
+ )) {
+ ids.add(id);
+ }
+ return ids;
+}
+
+interface SelectionResult {
+ readonly ids: ReadonlySet;
+ readonly anchorId: string;
+}
+
+/** The selection a `select` command produces, and the anchor it leaves. */
+function selectRow(
+ model: TreeModel,
+ selectedIds: ReadonlySet,
+ anchorId: string | undefined,
+ multiSelect: boolean,
+ row: TreeRowModel,
+ options: { toggle: boolean; range: boolean; preserveHidden: boolean },
+): SelectionResult {
+ const id = row.node.id;
+ if (!multiSelect) {
+ return { ids: new Set([id]), anchorId: id };
+ }
+ const ids = new Set(
+ options.preserveHidden
+ ? selectedIds
+ : [...selectedIds].filter((selectedId) =>
+ model.visibleIds.has(selectedId),
+ ),
+ );
+ if (options.range && anchorId) {
+ const rangeIds = selectionRange(model.visibleRows, ids, anchorId, id);
+ if (rangeIds) {
+ return { ids: rangeIds, anchorId };
+ }
+ }
+ if (options.toggle && ids.delete(id)) {
+ return { ids, anchorId: id };
+ }
+ if (!options.toggle) {
+ ids.clear();
+ }
+ ids.add(id);
+ return { ids, anchorId: id };
+}
+
+/**
+ * `list.selectAll` on a tree: the row's sibling group, widening to include the
+ * parent once that whole group is already selected.
+ */
+function scopedSelection(
+ model: TreeModel,
+ selectedIds: ReadonlySet,
+ row: TreeRowModel,
+): Set {
+ const scopeId = parentId(row);
+ const scoped = model.rows.filter(
+ (candidate) => scopeId === undefined || candidate.pathIds.includes(scopeId),
+ );
+ const ids = new Set(scoped.map((candidate) => candidate.node.id));
+ const scope = scopeId ? model.rowsById.get(scopeId) : undefined;
+ if (
+ scope &&
+ scoped.every((candidate) => selectedIds.has(candidate.node.id))
+ ) {
+ ids.add(scope.node.id);
+ }
+ return ids;
+}
+
+function togglingBranches(
+ row: TreeRowModel,
+ model: TreeModel,
+ recursive: boolean,
+): readonly TreeRowModel[] {
+ if (!recursive) {
+ return [row];
+ }
+ return model.rows.filter(
+ (candidate) =>
+ candidate.node.children !== undefined &&
+ (candidate === row || candidate.pathIds.includes(row.node.id)),
+ );
+}
+
+/**
+ * Expansion is data, so the ids come back in tree order. Ids the data does not
+ * have are kept, so a branch that loads later reopens.
+ */
+function toggleExpansion(
+ row: TreeRowModel,
+ model: TreeModel,
+ expandedIds: readonly string[],
+ recursive: boolean,
+): readonly string[] {
+ const next = new Set(expandedIds);
+ for (const branch of togglingBranches(row, model, recursive)) {
+ if (row.expanded) {
+ next.delete(branch.node.id);
+ } else {
+ next.add(branch.node.id);
+ }
+ }
+ return [
+ ...model.rows
+ .filter((candidate) => next.has(candidate.node.id))
+ .map((candidate) => candidate.node.id),
+ ...[...next].filter((id) => !model.rowsById.has(id)),
+ ];
+}
+
+/**
+ * Prefix first, then a fuzzy subsequence, as the native list does. A repeated
+ * single key walks the rows starting with it instead of matching the run.
+ */
+function typeaheadMatch(
+ visibleRows: readonly TreeRowModel[],
+ query: string,
+ current: TreeRowModel,
+): TreeRowModel | undefined {
+ const repeated =
+ query.length > 1 && [...query].every((key) => key === query[0]);
+ const value = (repeated ? query[0] : query)?.toLocaleLowerCase() ?? "";
+ const from =
+ query.length === 1 || repeated
+ ? visibleRows.indexOf(current) + 1
+ : visibleRows.indexOf(current);
+ const ordered = visibleRows.map(
+ (_, offset) => visibleRows[(from + offset) % visibleRows.length],
+ );
+ const fuzzy = (row: TreeRowModel): boolean => {
+ let index = 0;
+ for (const character of row.textValue.toLocaleLowerCase()) {
+ if (character === value[index] && ++index === value.length) {
+ return true;
+ }
+ }
+ return false;
+ };
+ return (
+ ordered.find((row) =>
+ row?.textValue.toLocaleLowerCase().startsWith(value),
+ ) ?? ordered.find((row) => row && fuzzy(row))
+ );
+}
+
+export function transitionTree(
+ state: TreeInteractionState,
+ commands: readonly TreeCommand[],
+ input: TransitionInput,
+): TreeTransition {
+ const { model } = input;
+ const view = deriveTreeInteractionView(state, model, input.controlledIds);
+ let nextState = view.state;
+ let selectedIds = view.selectedIds;
+ let currentKey = view.controlledKey;
+ let anchorId = view.anchorId;
+ let selection: readonly string[] | undefined;
+ let expandedIds: readonly string[] | undefined;
+ let focusTree = false;
+
+ const setAnchor = (id: string | undefined): void => {
+ anchorId = id;
+ nextState = { ...nextState, anchorKey: currentKey, anchorId: id };
+ };
+ const select = (ids: ReadonlySet, nextAnchor?: string): void => {
+ selection = model.rows
+ .filter((row) => ids.has(row.node.id))
+ .map((row) => row.node.id);
+ selectedIds = new Set(selection);
+ currentKey = selectionKey(selection);
+ nextState = { ...nextState, dismissedSelectionKey: currentKey };
+ if (nextAnchor !== undefined) {
+ setAnchor(nextAnchor);
+ }
+ };
+ const focus = (row: TreeRowModel | undefined): void => {
+ if (!row || !model.visibleIds.has(row.node.id)) {
+ return;
+ }
+ nextState = rowFocused(nextState, row, currentKey);
+ focusTree = true;
+ };
+
+ for (const command of commands) {
+ const row = "id" in command ? model.rowsById.get(command.id) : undefined;
+ switch (command.type) {
+ case "focus":
+ focus(row);
+ break;
+ case "select":
+ if (row) {
+ const result = selectRow(
+ model,
+ selectedIds,
+ anchorId,
+ input.multiSelect,
+ row,
+ command,
+ );
+ select(result.ids, result.anchorId);
+ }
+ break;
+ case "selectScope":
+ if (row) {
+ select(scopedSelection(model, selectedIds, row));
+ }
+ break;
+ case "move": {
+ if (!row) {
+ break;
+ }
+ const rows = model.visibleRows;
+ const offset = command.page
+ ? (input.pageOffset ?? command.offset)
+ : command.offset;
+ const index = rows.indexOf(row) + offset;
+ const target = rows[Math.min(Math.max(index, 0), rows.length - 1)];
+ if (!target) {
+ break;
+ }
+ if (command.extend) {
+ const rangeAnchor = anchorId ?? row.node.id;
+ const ids = selectionRange(
+ rows,
+ selectedIds,
+ rangeAnchor,
+ target.node.id,
+ );
+ if (ids) {
+ select(ids, rangeAnchor);
+ }
+ } else {
+ setAnchor(target.node.id);
+ }
+ focus(target);
+ break;
+ }
+ case "toggle":
+ if (row?.expanded !== undefined) {
+ expandedIds = toggleExpansion(
+ row,
+ model,
+ expandedIds ?? input.expandedIds,
+ command.recursive,
+ );
+ }
+ break;
+ case "typeahead": {
+ if (!row) {
+ break;
+ }
+ const query =
+ nextState.typeQuery && input.now < (nextState.typeExpires ?? 0)
+ ? nextState.typeQuery + command.key
+ : command.key;
+ nextState = {
+ ...nextState,
+ typeQuery: query,
+ typeExpires: input.now + TYPE_QUERY_MS,
+ };
+ focus(typeaheadMatch(model.visibleRows, query, row));
+ break;
+ }
+ case "dismiss":
+ if (command.clearSelection) {
+ select(new Set());
+ }
+ if (command.clearFocus) {
+ nextState = { ...nextState, focusTarget: undefined };
+ focusTree = true;
+ }
+ currentKey = NO_SELECTION_KEY;
+ setAnchor(undefined);
+ break;
+ }
+ }
+ return { state: nextState, selection, expandedIds, focusTree };
+}
diff --git a/packages/ui/src/components/Tree/useTreeAdapter.ts b/packages/ui/src/components/Tree/useTreeAdapter.ts
new file mode 100644
index 0000000000..af66f89106
--- /dev/null
+++ b/packages/ui/src/components/Tree/useTreeAdapter.ts
@@ -0,0 +1,339 @@
+import {
+ type KeyboardEvent,
+ type MouseEvent,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+
+import {
+ closestRow,
+ hitTwistie,
+ nestedInteractiveTarget,
+ scrollableAncestor,
+} from "./rowDom";
+import {
+ createTreeModel,
+ ROW_HEIGHT_PX,
+ rowTooltip,
+ type TreeNode,
+ type TreeRowModel,
+} from "./treeModel";
+import {
+ isSelectionGesture,
+ keyboardCommands,
+ pointerCommands,
+ type TreeCommand,
+ type TreeCommandBehavior,
+ type TreeExpandMode,
+ type TreeMultiSelectModifier,
+} from "./treePolicy";
+import {
+ deriveTreeInteractionView,
+ initialTreeInteractionState,
+ rowFocused,
+ transitionTree,
+ treeFocusChanged,
+ type TreeInteractionState,
+} from "./treeTransition";
+
+import type { TreeHoverControl } from "./TreeHover";
+
+const NO_IDS: readonly string[] = [];
+const NO_GUIDES = "";
+const MODIFIER_KEYS: ReadonlySet = new Set([
+ "Alt",
+ "Control",
+ "Meta",
+ "Shift",
+]);
+
+/** Single selection, or multi-selection, never a mix of the two APIs. */
+export type SelectionProps =
+ | {
+ readonly multiSelect?: false;
+ readonly selectedItemId?: string;
+ readonly onSelectedItemChange?: (itemId: string | undefined) => void;
+ readonly selectedItemIds?: never;
+ readonly onSelectedItemsChange?: never;
+ }
+ | {
+ readonly multiSelect: true;
+ readonly selectedItemIds?: readonly string[];
+ readonly onSelectedItemsChange?: (itemIds: readonly string[]) => void;
+ readonly selectedItemId?: never;
+ readonly onSelectedItemChange?: never;
+ };
+
+interface AdapterOptions {
+ readonly nodes: readonly TreeNode[];
+ readonly expandedIds: readonly string[];
+ readonly onExpandedIdsChange?: (expandedIds: readonly string[]) => void;
+ readonly expandMode: TreeExpandMode;
+ readonly multiSelectModifier: TreeMultiSelectModifier;
+ readonly onKeyDown?: (event: KeyboardEvent) => void;
+ readonly treeRef: React.RefObject;
+ readonly hoverControl?: TreeHoverControl;
+}
+
+function rowElement(tree: HTMLElement | null, id: string): HTMLElement | null {
+ return (
+ tree?.querySelector(`[data-tree-id="${CSS.escape(id)}"]`) ??
+ null
+ );
+}
+
+function controlledIds(selection: SelectionProps): readonly string[] {
+ if (selection.multiSelect) {
+ return selection.selectedItemIds ?? NO_IDS;
+ }
+ return selection.selectedItemId === undefined
+ ? NO_IDS
+ : [selection.selectedItemId];
+}
+
+/**
+ * Where the pure modules meet React and the DOM. Events arrive delegated from
+ * the container, which leaves rows as memoized presentation.
+ */
+export function useTreeAdapter(options: AdapterOptions & SelectionProps) {
+ const { nodes, expandedIds, treeRef } = options;
+ // Explicit: memoized rows compare against these row objects, and a consumer
+ // of the published package may not run the React Compiler.
+ const model = useMemo(
+ () => createTreeModel(nodes, new Set(expandedIds)),
+ [nodes, expandedIds],
+ );
+ const { visibleRows, rowsById } = model;
+ const selected = controlledIds(options);
+ const chordRef = useRef(false);
+ const [state, setState] = useState(() =>
+ initialTreeInteractionState(selected),
+ );
+ const view = deriveTreeInteractionView(state, model, selected);
+ // Identity, not value: the view returns this same state unless the data
+ // moved, and then the reconciled one renders instead.
+ if (view.state !== state) {
+ setState(view.state);
+ }
+ const behavior: TreeCommandBehavior = {
+ expandMode: options.expandMode,
+ multiSelect: Boolean(options.multiSelect),
+ multiSelectModifier: options.multiSelectModifier,
+ };
+
+ /**
+ * How far a page key travels: to the far edge of the viewport, or a whole
+ * viewport once the focused row is already sitting on it.
+ */
+ const pageOffset = (row: TreeRowModel, direction: 1 | -1): number => {
+ const tree = treeRef.current;
+ const scroller = tree ? scrollableAncestor(tree) : undefined;
+ if (!tree || !scroller) {
+ return direction;
+ }
+ const viewport = scroller.getBoundingClientRect();
+ if (viewport.height > 0) {
+ const inView = [
+ ...tree.querySelectorAll("[data-tree-id]"),
+ ].filter((element) => {
+ const bounds = element.getBoundingClientRect();
+ return bounds.bottom > viewport.top && bounds.top < viewport.bottom;
+ });
+ const edge = direction === 1 ? inView.at(-1) : inView[0];
+ const edgeId = edge?.dataset.treeId;
+ const edgeRow = edgeId ? rowsById.get(edgeId) : undefined;
+ const offset = edgeRow
+ ? visibleRows.indexOf(edgeRow) - visibleRows.indexOf(row)
+ : 0;
+ if (offset !== 0) {
+ return offset;
+ }
+ scroller.scrollBy?.(0, direction * scroller.clientHeight);
+ }
+ return (
+ direction * Math.max(1, Math.floor(scroller.clientHeight / ROW_HEIGHT_PX))
+ );
+ };
+
+ const dispatch = (commands: readonly TreeCommand[]): void => {
+ const move = commands.find((command) => command.type === "move");
+ const moved = move ? rowsById.get(move.id) : undefined;
+ const result = transitionTree(state, commands, {
+ model,
+ controlledIds: selected,
+ expandedIds,
+ multiSelect: behavior.multiSelect,
+ pageOffset:
+ move?.page && moved ? pageOffset(moved, move.offset) : undefined,
+ now: Date.now(),
+ });
+ setState(result.state);
+ if (result.selection) {
+ if (options.multiSelect) {
+ options.onSelectedItemsChange?.(result.selection);
+ } else {
+ options.onSelectedItemChange?.(result.selection[0]);
+ }
+ }
+ if (result.expandedIds) {
+ options.onExpandedIdsChange?.(result.expandedIds);
+ }
+ if (result.focusTree) {
+ treeRef.current?.focus();
+ }
+ };
+
+ const rowFor = (target: EventTarget | null): TreeRowModel | undefined => {
+ const id = closestRow(target)?.dataset.treeId;
+ return id ? rowsById.get(id) : undefined;
+ };
+ const onFocusIn = (target: EventTarget | null): void => {
+ const row = rowFor(target);
+ // A row focused in its own right becomes the focus target; entering the
+ // container only adopts one.
+ if (row && target === closestRow(target)) {
+ setState((current) => rowFocused(current, row, view.controlledKey));
+ }
+ // Only an entry with no focus target adopts a row: a focus mark the same
+ // gesture just cleared must not come back when focus returns here.
+ const entered = view.state.focusTarget
+ ? undefined
+ : (row ?? rowsById.get(view.tabStopId ?? ""));
+ setState((current) => treeFocusChanged(current, true, entered));
+ };
+ const onPointer = (
+ row: TreeRowModel,
+ event: MouseEvent,
+ onTwistie: boolean,
+ source: "row" | "sticky",
+ ): void => {
+ dispatch(
+ pointerCommands({
+ ...behavior,
+ row,
+ source,
+ onTwistie,
+ detail: event.detail,
+ modifiers: event,
+ }),
+ );
+ };
+ const onClick = (event: MouseEvent): void => {
+ const element = closestRow(event.target);
+ const row = rowFor(event.target);
+ if (!row || !element) {
+ return;
+ }
+ // Focusable content and the action bar handle their own clicks.
+ if (
+ nestedInteractiveTarget(event.target, element) ||
+ (event.target instanceof Element &&
+ event.target.closest(".ui-tree-item__action"))
+ ) {
+ return;
+ }
+ onPointer(row, event, hitTwistie(row, event.target), "row");
+ };
+ /** VS Code binds `list.showHover` to the Ctrl+K Ctrl+I chord. */
+ const showHoverChord = (
+ event: KeyboardEvent,
+ ): "pending" | "show" | undefined => {
+ const held = (event.ctrlKey || event.metaKey) && !event.altKey;
+ const key = held ? event.key.toLowerCase() : "";
+ const armed = chordRef.current;
+ chordRef.current = !armed && key === "k";
+ if (chordRef.current) {
+ return "pending";
+ }
+ return armed && key === "i" ? "show" : undefined;
+ };
+ const showHover = (row: TreeRowModel | undefined): void => {
+ const element = row
+ ? rowElement(treeRef.current, row.node.id)?.querySelector(
+ ".ui-tree-item__content",
+ )
+ : undefined;
+ options.hoverControl?.current?.(
+ row && element ? { content: rowTooltip(row), element } : undefined,
+ true,
+ );
+ };
+ const onKeyDown = (event: KeyboardEvent): void => {
+ options.onKeyDown?.(event);
+ if (event.defaultPrevented) {
+ return;
+ }
+ const row =
+ rowFor(event.target) ??
+ (view.focusedId ? rowsById.get(view.focusedId) : undefined) ??
+ (view.tabStopId ? rowsById.get(view.tabStopId) : undefined) ??
+ visibleRows[0];
+ if (!row) {
+ return;
+ }
+ // A hover the keyboard opened stays only until the next real key.
+ if (!MODIFIER_KEYS.has(event.key)) {
+ const chord = showHoverChord(event);
+ if (chord) {
+ if (chord === "show") {
+ showHover(row);
+ }
+ event.preventDefault();
+ return;
+ }
+ showHover(undefined);
+ }
+ const interactive = nestedInteractiveTarget(
+ event.target,
+ event.currentTarget,
+ );
+ const result = keyboardCommands({
+ ...behavior,
+ key: event.key,
+ row,
+ visibleRows,
+ fromAction:
+ interactive instanceof HTMLElement &&
+ interactive.dataset.treeId === undefined,
+ selectedCount: view.selectedIds.size,
+ hasFocusedRow: view.focusedId !== undefined,
+ modifiers: event,
+ });
+ if (result.focusRowElementId) {
+ rowElement(treeRef.current, result.focusRowElementId)?.focus();
+ }
+ dispatch(result.commands);
+ if (result.preventDefault) {
+ event.preventDefault();
+ }
+ };
+
+ return {
+ model,
+ focusedId: view.focusedId,
+ tabStopId: view.tabStopId,
+ hasDomFocus: view.state.hasDomFocus,
+ selectedIds: view.selectedIds,
+ /** One character per ancestor, `1` where its guide is active. */
+ guideFlags: (row: TreeRowModel): string =>
+ view.guideOwnerIds.size === 0
+ ? NO_GUIDES
+ : row.pathIds
+ .map((id) => (view.guideOwnerIds.has(id) ? "1" : "0"))
+ .join(""),
+ dispatch,
+ isSelectionGesture: (event: MouseEvent) =>
+ isSelectionGesture(event, behavior),
+ onFocusIn,
+ onBlurOut: () => {
+ showHover(undefined);
+ setState((current) => treeFocusChanged(current, false));
+ },
+ onClick,
+ onPointer,
+ onKeyDown,
+ };
+}
+
+export type TreeAdapter = ReturnType;
diff --git a/packages/ui/src/components/overlay.css b/packages/ui/src/components/overlay.css
index a5c9a086bd..00026705b8 100644
--- a/packages/ui/src/components/overlay.css
+++ b/packages/ui/src/components/overlay.css
@@ -11,8 +11,40 @@
z-index: var(--ui-z-index-overlay);
}
+/* Highlightable rows; surfaces map their colors onto the
+ --ui-overlay-highlight-* names and keep their own geometry. */
+.ui-overlay__item {
+ border-radius: var(--ui-radius-medium);
+ cursor: default;
+ user-select: none;
+ outline: 1px solid transparent;
+ outline-offset: -1px;
+}
+
+/* An open submenu keeps its parent row highlighted, like native; the
+ selection border only resolves in high contrast. */
+.ui-overlay__item:is([data-highlighted], [data-state="open"]) {
+ forced-color-adjust: none;
+ color: var(--ui-overlay-highlight-foreground);
+ background: var(--ui-overlay-highlight-background);
+ outline-color: var(--ui-overlay-highlight-outline, transparent);
+}
+
+.ui-overlay__item[data-disabled] {
+ color: var(--ui-disabled-foreground);
+}
+
@media (forced-colors: active) {
.ui-overlay {
border-color: CanvasText;
}
+
+ .ui-overlay__item:is([data-highlighted], [data-state="open"]) {
+ color: HighlightText;
+ background: Highlight;
+ }
+
+ .ui-overlay__item[data-disabled] {
+ color: GrayText;
+ }
}
diff --git a/packages/ui/src/components/text-control.css b/packages/ui/src/components/text-control.css
new file mode 100644
index 0000000000..e2ec7e3a30
--- /dev/null
+++ b/packages/ui/src/components/text-control.css
@@ -0,0 +1,46 @@
+/* Shared text-control paint and inner-input layout; component files own
+ padding and native-element behavior. */
+.ui-text-control {
+ width: 100%;
+ color: var(--ui-input-foreground);
+ background: var(--ui-input-background);
+ border: 1px solid var(--ui-input-border);
+ border-radius: var(--ui-radius-small);
+}
+
+/* Single-line controls also carry .ui-control and keep the native 26px box */
+.ui-control.ui-text-control {
+ justify-content: flex-start;
+ height: 26px;
+}
+
+.ui-text-control:focus-within {
+ border-color: var(--ui-focus-border);
+}
+
+.ui-text-control__control {
+ min-width: 0;
+ flex: 1;
+ color: inherit;
+ background: transparent;
+ border: 0;
+ outline: 0;
+ font: inherit;
+}
+
+.ui-text-control::placeholder,
+.ui-text-control__control::placeholder {
+ color: var(--ui-input-placeholder-foreground);
+ opacity: 1;
+}
+
+/* Trailing in-field buttons: the password reveal and the search clear */
+.ui-text-control__action {
+ flex: none;
+ margin-inline-end: 1px;
+}
+
+.ui-text-control:disabled,
+.ui-text-control:has(> :disabled) {
+ opacity: var(--ui-disabled-opacity);
+}
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 89b11ffb0a..d6278d896e 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -1,4 +1,5 @@
export { Button, type ButtonProps } from "./components/Button/Button";
+export { Checkbox, type CheckboxProps } from "./components/Checkbox/Checkbox";
export {
EmptyState,
type EmptyStateProps,
@@ -7,15 +8,26 @@ export {
ErrorState,
type ErrorStateProps,
} from "./components/ErrorState/ErrorState";
+export {
+ Field,
+ type FieldProps,
+ Label,
+ type LabelProps,
+} from "./components/Field/Field";
export { Icon, type IconProps } from "./components/Icon/Icon";
export {
IconButton,
type IconButtonProps,
} from "./components/IconButton/IconButton";
+export { Input, type InputProps } from "./components/Input/Input";
export {
LoadingState,
type LoadingStateProps,
} from "./components/LoadingState/LoadingState";
+export {
+ PasswordInput,
+ type PasswordInputProps,
+} from "./components/PasswordInput/PasswordInput";
export {
ProgressBar,
type ProgressBarProps,
@@ -24,12 +36,21 @@ export {
SearchInput,
type SearchInputProps,
} from "./components/SearchInput/SearchInput";
+export {
+ Select,
+ SelectContent,
+ SelectItem,
+ type SelectItemProps,
+ SelectTrigger,
+ SelectValue,
+} from "./components/Select/Select";
export { Spinner, type SpinnerProps } from "./components/Spinner/Spinner";
export {
StatusPill,
type StatusPillProps,
type StatusPillTone,
} from "./components/StatusPill/StatusPill";
+export { Textarea, type TextareaProps } from "./components/Textarea/Textarea";
export type { CodiconName } from "./codicons";
export {
ContextMenu,
@@ -67,9 +88,14 @@ export {
type KeybindingPlatform,
} from "./keybinding";
export {
+ type HoverDelegate,
+ HoverDelegateScope,
+ type HoverTarget,
Tooltip,
type TooltipProps,
TooltipProvider,
type TooltipProviderProps,
} from "./components/Tooltip/Tooltip";
+export { Tree, type TreeProps } from "./components/Tree/Tree";
+export type { TreeNode } from "./components/Tree/treeModel";
export { useVscodeTheme, type VscodeThemeKind } from "./useVscodeTheme";
diff --git a/packages/ui/src/ref.ts b/packages/ui/src/ref.ts
new file mode 100644
index 0000000000..1ceeaca75b
--- /dev/null
+++ b/packages/ui/src/ref.ts
@@ -0,0 +1,16 @@
+import type { Ref } from "react";
+
+/**
+ * Hands a node to a consumer's `ref` prop, whichever form it takes, so a
+ * component can keep its own ref to a node it also forwards.
+ */
+export function setForwardedRef(
+ ref: Ref | undefined,
+ value: T | null,
+): void {
+ if (typeof ref === "function") {
+ ref(value);
+ } else if (ref) {
+ ref.current = value;
+ }
+}
diff --git a/packages/ui/src/tokens.css b/packages/ui/src/tokens.css
index e4345fa09f..7a15db40ca 100644
--- a/packages/ui/src/tokens.css
+++ b/packages/ui/src/tokens.css
@@ -79,6 +79,44 @@
var(--vscode-contrastBorder, transparent)
);
--ui-input-placeholder-foreground: var(--vscode-input-placeholderForeground);
+ --ui-checkbox-background: var(
+ --vscode-checkbox-background,
+ var(--ui-input-background)
+ );
+ --ui-checkbox-foreground: var(
+ --vscode-checkbox-foreground,
+ var(--ui-input-foreground)
+ );
+ --ui-checkbox-border: var(
+ --vscode-checkbox-border,
+ var(--vscode-contrastBorder, var(--ui-input-border))
+ );
+ --ui-dropdown-background: var(
+ --vscode-dropdown-background,
+ var(--ui-input-background)
+ );
+ --ui-dropdown-foreground: var(
+ --vscode-dropdown-foreground,
+ var(--ui-foreground)
+ );
+ --ui-dropdown-border: var(
+ --vscode-dropdown-border,
+ var(--vscode-contrastBorder, transparent)
+ );
+ --ui-dropdown-list-background: var(
+ --vscode-dropdown-listBackground,
+ var(--ui-dropdown-background)
+ );
+ /* Native select dropdowns highlight rows with the quick input list colors
+ (selectBoxStyles) and outline them with --ui-list-selection-outline. */
+ --ui-dropdown-list-focus-background: var(
+ --vscode-quickInputList-focusBackground,
+ var(--vscode-list-activeSelectionBackground, transparent)
+ );
+ --ui-dropdown-list-focus-foreground: var(
+ --vscode-quickInputList-focusForeground,
+ var(--vscode-list-activeSelectionForeground, var(--ui-dropdown-foreground))
+ );
--ui-button-background: var(--vscode-button-background, var(--ui-background));
--ui-button-foreground: var(--vscode-button-foreground, var(--ui-foreground));
--ui-button-border: var(
@@ -147,11 +185,70 @@
--ui-radius-circle: var(--vscode-cornerRadius-circle, 9999px);
/* Spacing, VS Code's scale (baseSizes.ts); names are px times ten */
+ --ui-spacing-40: var(--vscode-spacing-size40, 4px);
--ui-spacing-60: var(--vscode-spacing-size60, 6px);
--ui-spacing-120: var(--vscode-spacing-size120, 12px);
--ui-spacing-160: var(--vscode-spacing-size160, 16px);
--ui-spacing-240: var(--vscode-spacing-size240, 24px);
+ /* Lists and trees */
+ --ui-list-hover-background: var(--vscode-list-hoverBackground, transparent);
+ --ui-list-hover-foreground: var(
+ --vscode-list-hoverForeground,
+ var(--ui-foreground)
+ );
+ --ui-list-active-selection-background: var(
+ --vscode-list-activeSelectionBackground,
+ var(--ui-list-hover-background)
+ );
+ --ui-list-active-selection-foreground: var(
+ --vscode-list-activeSelectionForeground,
+ var(--ui-foreground)
+ );
+ --ui-list-inactive-selection-background: var(
+ --vscode-list-inactiveSelectionBackground,
+ var(--ui-list-active-selection-background)
+ );
+ --ui-list-inactive-selection-foreground: var(
+ --vscode-list-inactiveSelectionForeground,
+ var(--ui-foreground)
+ );
+ --ui-list-focus-outline: var(
+ --vscode-list-focusOutline,
+ var(--ui-focus-border)
+ );
+ /* No list.selectionOutline or list.hoverOutline color exists; native feeds
+ both from activeContrastBorder. */
+ --ui-list-selection-outline: var(--vscode-contrastActiveBorder, transparent);
+ --ui-list-inactive-focus-outline: var(
+ --vscode-list-inactiveFocusOutline,
+ transparent
+ );
+ --ui-list-hover-outline: var(--vscode-contrastActiveBorder, transparent);
+ --ui-list-focus-and-selection-outline: var(
+ --vscode-list-focusAndSelectionOutline,
+ var(--vscode-contrastActiveBorder, var(--ui-list-focus-outline))
+ );
+ /* Outside a webview, approximate the native guides (inactive is the
+ active stroke at 40%) instead of disappearing. */
+ --ui-tree-indent-guide-inactive: var(
+ --vscode-tree-inactiveIndentGuidesStroke,
+ color-mix(in srgb, currentColor 16%, transparent)
+ );
+ --ui-tree-indent-guide-active: var(
+ --vscode-tree-indentGuidesStroke,
+ color-mix(in srgb, currentColor 40%, transparent)
+ );
+ /* Pinned rows paint over what scrolls beneath them. */
+ --ui-tree-sticky-background: var(
+ --vscode-sideBarStickyScroll-background,
+ var(--ui-background)
+ );
+ --ui-tree-sticky-shadow: var(
+ --vscode-sideBarStickyScroll-shadow,
+ transparent
+ );
+
/* Menus */
--ui-menu-background: var(--vscode-menu-background);
--ui-menu-foreground: var(--vscode-menu-foreground);
diff --git a/packages/ui/src/vscode-parity.stories.tsx b/packages/ui/src/vscode-parity.stories.tsx
index d37c2067f4..d34d7407c4 100644
--- a/packages/ui/src/vscode-parity.stories.tsx
+++ b/packages/ui/src/vscode-parity.stories.tsx
@@ -1,16 +1,21 @@
import {
VscodeBadge,
VscodeButton,
+ VscodeCheckbox,
VscodeContextMenu,
VscodeIcon,
+ VscodeOption,
VscodeProgressBar,
VscodeProgressRing,
+ VscodeSingleSelect,
+ VscodeTextarea,
VscodeTextfield,
VscodeToolbarButton,
} from "@vscode-elements/react-elements";
import { useState } from "react";
import { Button } from "./components/Button/Button";
+import { Checkbox } from "./components/Checkbox/Checkbox";
import {
DropdownMenu,
DropdownMenuContent,
@@ -20,11 +25,20 @@ import {
DropdownMenuTrigger,
} from "./components/DropdownMenu/DropdownMenu";
import { IconButton } from "./components/IconButton/IconButton";
+import { Input } from "./components/Input/Input";
import { ProgressBar } from "./components/ProgressBar/ProgressBar";
import { SearchInput } from "./components/SearchInput/SearchInput";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "./components/Select/Select";
import { Spinner } from "./components/Spinner/Spinner";
import { StatusPill } from "./components/StatusPill/StatusPill";
-import { openMenu, PIXEL_ALL_THEMES } from "./storybook";
+import { Textarea } from "./components/Textarea/Textarea";
+import { PIXEL_ALL_THEMES } from "./storybook";
import type { Meta, StoryObj } from "@storybook/react-vite";
@@ -116,6 +130,37 @@ const Parity = (): React.JSX.Element => (
}
/>
+ undefined}
+ aria-label="Region"
+ style={{ width: "180px" }}
+ />
+ }
+ reference={
+
+ }
+ />
+ undefined}
+ aria-label="Init script"
+ style={{ width: "180px" }}
+ />
+ }
+ reference={
+
+ }
+ />
(
>
}
/>
+ undefined}>
+
+
+
+
+ US East (Pittsburgh)
+ EU North (Helsinki)
+
+
+ }
+ reference={
+
+
+ US East (Pittsburgh)
+
+ EU North (Helsinki)
+
+ }
+ />
+ undefined}>
+ Start on connect
+
+ }
+ reference={}
+ />
(
);
-/* The reference menu renders inline; ours is a real portalled DropdownMenu,
- so the play function opens it under its trigger. */
+/* defaultOpen + an invisible trigger mirror the reference's `show`. */
const MenuParity = (): React.JSX.Element => (
-
-
-
+
+
+ {/* A zero-height button still anchors the popper, so the menu opens
+ at the top of its grid column, level with the reference. */}
+
-
+ Start workspaceOpen logs
@@ -228,7 +315,4 @@ export const SideBySide: Story = {};
export const Menu: Story = {
render: () => ,
- play: async ({ canvasElement }) => {
- await openMenu(canvasElement, "Menu");
- },
};
diff --git a/packages/ui/storybook/Tree.demo.tsx b/packages/ui/storybook/Tree.demo.tsx
new file mode 100644
index 0000000000..b85c426fe2
--- /dev/null
+++ b/packages/ui/storybook/Tree.demo.tsx
@@ -0,0 +1,58 @@
+import { useState } from "react";
+
+import { Tree, type TreeProps } from "../src/components/Tree/Tree";
+
+import type { TreeNode } from "../src/components/Tree/treeModel";
+
+const NO_IDS: readonly string[] = [];
+
+/** Every branch id, so a demo tree starts fully open unless told otherwise. */
+function branchIds(nodes: readonly TreeNode[]): readonly string[] {
+ return nodes.flatMap((node) =>
+ node.children ? [node.id, ...branchIds(node.children)] : [],
+ );
+}
+
+type DistributiveOmit = T extends unknown
+ ? Omit>
+ : never;
+
+export type TreeDemoProps = DistributiveOmit<
+ TreeProps,
+ "onSelectedItemChange" | "onSelectedItemsChange"
+>;
+
+/** Holds the selection and expansion state a controlled `Tree` expects. */
+export function TreeDemo({
+ multiSelect,
+ selectedItemId,
+ selectedItemIds,
+ expandedIds,
+ ...treeProps
+}: TreeDemoProps): React.JSX.Element {
+ const [selectedId, setSelectedId] = useState(selectedItemId);
+ const [selectedIds, setSelectedIds] = useState(selectedItemIds ?? NO_IDS);
+ const [expanded, setExpanded] = useState(
+ () => expandedIds ?? branchIds(treeProps.nodes),
+ );
+ const selection = multiSelect
+ ? ({
+ multiSelect: true,
+ selectedItemIds: selectedIds,
+ onSelectedItemsChange: setSelectedIds,
+ } as const)
+ : ({
+ multiSelect: false,
+ selectedItemId: selectedId,
+ onSelectedItemChange: setSelectedId,
+ } as const);
+
+ return (
+
+ );
+}
diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json
index de3f039b95..d8416421b9 100644
--- a/packages/ui/tsconfig.json
+++ b/packages/ui/tsconfig.json
@@ -3,5 +3,5 @@
"compilerOptions": {
"resolveJsonModule": true
},
- "include": ["src", "storybook.preview.ts"]
+ "include": ["src", "storybook", "storybook.preview.ts"]
}
diff --git a/packages/workspaces/src/App.tsx b/packages/workspaces/src/App.tsx
index abed211177..fe16f87c2d 100644
--- a/packages/workspaces/src/App.tsx
+++ b/packages/workspaces/src/App.tsx
@@ -1,3 +1,11 @@
+import { useWorkspaces } from "./hooks/useWorkspaces";
+
+/** Placeholder: renders the pushed state until the panel UI lands. */
export default function App() {
- return
TODO
;
+ const { state } = useWorkspaces();
+
+ if (!state) {
+ return