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 ( +
+ + Start on connect + + undefined}> + Unchecked + + undefined}> + Disabled checked + + undefined}> + Disabled unchecked + +
+ ); +}; + +const meta: Meta = { + title: "UI/Checkbox", + component: CheckboxStates, + parameters: { pixel: PIXEL_ALL_THEMES }, +}; +export default meta; +type Story = StoryObj; + +export const States: Story = {}; + +export const Focused: Story = { + play: ({ canvasElement }) => { + within(canvasElement) + .getByRole("checkbox", { name: "Start on connect" }) + .focus(); + }, +}; diff --git a/packages/ui/src/components/Checkbox/Checkbox.tsx b/packages/ui/src/components/Checkbox/Checkbox.tsx new file mode 100644 index 0000000000..a4adfc5c21 --- /dev/null +++ b/packages/ui/src/components/Checkbox/Checkbox.tsx @@ -0,0 +1,43 @@ +import { type ComponentProps } from "react"; + +import { cx } from "#cx"; + +import "../control.css"; +import { Icon } from "../Icon/Icon"; + +import "./Checkbox.css"; + +export interface CheckboxProps extends Omit< + ComponentProps<"input">, + "checked" | "onChange" | "type" +> { + checked: boolean; + onChange: (checked: boolean) => void; +} + +/* The native input supplies state, focus, and semantics; the box paints + VS Code's checkbox geometry and shows a codicon check. */ +export function Checkbox({ + checked, + onChange, + className, + style, + children, + ...props +}: CheckboxProps): React.JSX.Element { + return ( + + ); +} diff --git a/packages/ui/src/components/ContextMenu/ContextMenu.tsx b/packages/ui/src/components/ContextMenu/ContextMenu.tsx index 951d149f68..93f877f3ec 100644 --- a/packages/ui/src/components/ContextMenu/ContextMenu.tsx +++ b/packages/ui/src/components/ContextMenu/ContextMenu.tsx @@ -25,7 +25,7 @@ export const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup; /** One selectable action row; a leading `Icon` sits in the gutter. */ export const ContextMenuItem = menuPart( ContextMenuPrimitive.Item, - "ui-menu__item", + "ui-overlay__item ui-menu__item", ); /** Non-interactive heading above a group. */ @@ -43,21 +43,21 @@ export const ContextMenuSeparator = menuPart( /** A toggleable row; checked shows a gutter check. */ export const ContextMenuCheckboxItem = menuPart( ContextMenuPrimitive.CheckboxItem, - "ui-menu__item", + "ui-overlay__item ui-menu__item", { indicator: ContextMenuPrimitive.ItemIndicator }, ); /** One choice in a radio group. */ export const ContextMenuRadioItem = menuPart( ContextMenuPrimitive.RadioItem, - "ui-menu__item", + "ui-overlay__item ui-menu__item", { indicator: ContextMenuPrimitive.ItemIndicator }, ); /** The row that opens its submenu. */ export const ContextMenuSubTrigger = menuPart( ContextMenuPrimitive.SubTrigger, - "ui-menu__item", + "ui-overlay__item ui-menu__item", { chevron: true }, ); diff --git a/packages/ui/src/components/DropdownMenu/DropdownMenu.tsx b/packages/ui/src/components/DropdownMenu/DropdownMenu.tsx index 92d3c4b093..017264562c 100644 --- a/packages/ui/src/components/DropdownMenu/DropdownMenu.tsx +++ b/packages/ui/src/components/DropdownMenu/DropdownMenu.tsx @@ -25,7 +25,7 @@ export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; /** One selectable action row; a leading `Icon` sits in the gutter. */ export const DropdownMenuItem = menuPart( DropdownMenuPrimitive.Item, - "ui-menu__item", + "ui-overlay__item ui-menu__item", ); /** Non-interactive heading above a group. */ @@ -43,21 +43,21 @@ export const DropdownMenuSeparator = menuPart( /** A toggleable row; checked shows a gutter check. */ export const DropdownMenuCheckboxItem = menuPart( DropdownMenuPrimitive.CheckboxItem, - "ui-menu__item", + "ui-overlay__item ui-menu__item", { indicator: DropdownMenuPrimitive.ItemIndicator }, ); /** One choice in a radio group. */ export const DropdownMenuRadioItem = menuPart( DropdownMenuPrimitive.RadioItem, - "ui-menu__item", + "ui-overlay__item ui-menu__item", { indicator: DropdownMenuPrimitive.ItemIndicator }, ); /** The row that opens its submenu. */ export const DropdownMenuSubTrigger = menuPart( DropdownMenuPrimitive.SubTrigger, - "ui-menu__item", + "ui-overlay__item ui-menu__item", { chevron: true }, ); diff --git a/packages/ui/src/components/Field/Field.css b/packages/ui/src/components/Field/Field.css new file mode 100644 index 0000000000..e2af3c4558 --- /dev/null +++ b/packages/ui/src/components/Field/Field.css @@ -0,0 +1,18 @@ +.ui-label { + display: block; + font-weight: var(--ui-font-weight-semibold); +} + +.ui-field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.ui-field__description { + color: var(--ui-description-foreground); +} + +.ui-field__error { + color: var(--ui-error-foreground); +} diff --git a/packages/ui/src/components/Field/Field.stories.tsx b/packages/ui/src/components/Field/Field.stories.tsx new file mode 100644 index 0000000000..8037c606c6 --- /dev/null +++ b/packages/ui/src/components/Field/Field.stories.tsx @@ -0,0 +1,57 @@ +import { useId, useState } from "react"; + +import { PIXEL_ALL_THEMES } from "#storybook"; + +import { Input } from "../Input/Input"; + +import { Field } from "./Field"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; + +const FieldStates = (): React.JSX.Element => { + const [region, setRegion] = useState("us-pittsburgh"); + const regionId = useId(); + const coresId = useId(); + return ( +
+ + + + + undefined} + aria-describedby={`${coresId}-error`} + aria-invalid="true" + /> + +
+ ); +}; + +const meta: Meta = { + title: "UI/Field", + component: FieldStates, + parameters: { pixel: PIXEL_ALL_THEMES }, +}; +export default meta; +type Story = StoryObj; + +export const States: Story = {}; diff --git a/packages/ui/src/components/Field/Field.tsx b/packages/ui/src/components/Field/Field.tsx new file mode 100644 index 0000000000..c0c7cc055c --- /dev/null +++ b/packages/ui/src/components/Field/Field.tsx @@ -0,0 +1,53 @@ +import { type ComponentProps, type ReactNode } from "react"; + +import { cx } from "#cx"; + +import "./Field.css"; + +export type LabelProps = ComponentProps<"label">; + +export function Label({ className, ...props }: LabelProps): React.JSX.Element { + return