diff --git a/.changeset/bright-owls-merge.md b/.changeset/bright-owls-merge.md new file mode 100644 index 00000000..ccca272f --- /dev/null +++ b/.changeset/bright-owls-merge.md @@ -0,0 +1,7 @@ +--- +"@spencer-kit/coder-studio": patch +--- + +Improve workspace, diagnostics, monitoring, and editor workflows with system +dependency installs, managed Vue LSP support, file history previews, and +refined settings and workspace surfaces. diff --git a/.gitignore b/.gitignore index b80eb563..e2b4a05d 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,8 @@ tsconfig.tsbuildinfo # Stitch design files .stitch/ + +# Rust build artefacts (from lsp-test/ fixture or any ad-hoc cargo) +target/ +Cargo.lock + diff --git a/docs/issue/rust-analyzer-indexing-no-progress-feedback.md b/docs/issue/rust-analyzer-indexing-no-progress-feedback.md new file mode 100644 index 00000000..2d0628dc --- /dev/null +++ b/docs/issue/rust-analyzer-indexing-no-progress-feedback.md @@ -0,0 +1,66 @@ +# rust-analyzer 启动期间 hover/definition 静默无响应,UI 无进度反馈 + +## 标题 + +`feat(web): surface rust-analyzer indexing progress in the LSP status notice` + +## 问题描述 + +打开第一个 `.rs` 文件时,rust-analyzer 会进入 `PrimeCaches` 阶段对工作区做初始化索引。这个阶段在 coder-studio 仓库根(中等仓库 + 大量 `node_modules`)下实测**会持续 ~25 秒**。 + +期间: + +- `initialize` LSP 请求几十毫秒就返回(rust-analyzer 设计上立刻确认 capabilities,workspace 加载是异步的) +- 我们的 `LspManager.ensureSession` 拿到 `summary.status === "ready"`,前端把 hover/definition provider 都注册好 +- 但用户**任何** hover/definition 请求都会被 rust-analyzer **立刻返回 `null`**——不是 hang、不是 timeout,而是它故意在 indexing 期间不给语义答案 +- Monaco 拿到 null 就什么都不显示 +- 用户感受:开了 `.rs` 文件之后随便点点都"完全没反应",像 LSP 没起来 + +25 秒后 rust-analyzer 发 `$/progress { kind: "end" }` 通知,从此 hover 正常工作。但**这中间的等待期对用户完全不可见**。 + +## 复现步骤 + +1. 干净环境,无 rust-analyzer 缓存。 +2. 在 coder-studio 仓库根新建 `Cargo.toml` + `probe.rs`(最小 bin 项目即可)。 +3. 重启 dev server,让 LSP 会话从干净状态启动。 +4. 浏览器中打开 `probe.rs`,立刻 hover 任一标识符。 +5. 观察前 ~25 秒所有 hover/definition/references 都没反应。 + +可以用 `scripts/probe-rust.mjs probe.rs` 直接复现 —— +它会同时记录 initialize 用时、首次 hover 响应、`$/progress end` 用时。 + +## 实际行为 + +- 前 25 秒:hover 返回 null,UI 安静 +- 之后:hover 工作,但用户多半已经放弃尝试了 + +## 桌面终端对比 + +VS Code 的官方 rust-analyzer 扩展会在 status bar 上显示 +`rust-analyzer: indexing X/Y` 进度条;Helix 会在底部状态栏显示同样信息。两者都监听 rust-analyzer +的 `$/progress` LSP 通知。 + +我们目前没监听任何 LSP 进度通知。 + +## 已确认事实 + +- `initialize` 响应快(~70ms 量级,与 indexing 解耦) +- rust-analyzer 通过标准 LSP `$/progress` + notification 通报进度,token 是 `"rustAnalyzer/Indexing"` 或类似 +- `LspSession`(`packages/server/src/lsp/session.ts`)目前没有 `connection.onNotification("$/progress", ...)` + 处理器 +- 前端 `LspStatusNotice` 目前只有 ready / installing / failed / disabled 四种状态显示 + +## 后续排查方向 + +- **server**:`LspSession` 监听 `$/progress` 通知,把 `WorkDoneProgressBegin` / + `WorkDoneProgressReport` / `WorkDoneProgressEnd` 转成 `lsp.progress.updated` 事件 + via `eventBus` +- **core / shared**:在 `LspEnsureSessionResult` 或独立 `LspProgress` 类型里加一个 "indexing" 状态 +- **web**:`LspStatusNotice` 渲染 "Indexing 12 / 47 …" 或简单的 spinner + percentage +- 范围只对 rust-analyzer + 任何主动发 `$/progress` 的 server(pylsp、gopls 通常不发) + +## 临时缓解 + +- 文档里告诉用户:"首次打开 `.rs` 文件需要等 ~30s 完成索引" +- 或检测 rust-analyzer 没回有效 hover 时,在编辑器里给一个 transient toast 提示 diff --git a/docs/research/2026-05-26-vscode-ecosystem-evaluation.md b/docs/research/2026-05-26-vscode-ecosystem-evaluation.md new file mode 100644 index 00000000..68842d50 --- /dev/null +++ b/docs/research/2026-05-26-vscode-ecosystem-evaluation.md @@ -0,0 +1,248 @@ +# VS Code Plugin Ecosystem Evaluation + +> Status: Research +> Date: 2026-05-26 +> Scope: VS Code plugin ecosystem adoption, UI freedom trade-offs, and Theia fit for Coder Studio + +## Goal + +Record the current product and architecture conclusions for a future decision on whether Coder Studio should move toward the VS Code plugin ecosystem. + +This document is a research memo, not an implementation spec. + +## Current Product Baseline + +Coder Studio currently behaves as a custom browser workspace rather than a VS Code-derived workbench. + +Relevant local references: + +- Browser workspace positioning: [README.zh-CN.md](../../../README.zh-CN.md) +- Product shell and route model: [docs/PRD.zh-CN.md](../PRD.zh-CN.md) +- Desktop shell: [packages/web/src/shells/desktop-shell.tsx](../../../packages/web/src/shells/desktop-shell.tsx) +- Mobile shell: [packages/web/src/shells/mobile-shell/index.tsx](../../../packages/web/src/shells/mobile-shell/index.tsx) +- Desktop workspace composition: [packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx](../../../packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx) +- Mobile workspace composition: [packages/web/src/features/workspace/views/mobile/workspace-mobile-view.tsx](../../../packages/web/src/features/workspace/views/mobile/workspace-mobile-view.tsx) + +Current product traits that matter for this evaluation: + +- custom desktop shell +- custom mobile shell +- route-driven pages such as welcome, login, workspace, settings, diagnostics +- mobile-first `Dock + Sheet` workspace flow +- agent/session/supervisor/review workflow integrated into a custom app shell + +## Core Conclusion + +Two goals conflict with each other: + +1. maximize VS Code plugin compatibility +2. preserve the current Coder Studio UI freedom + +They cannot both be optimized at the same time. + +The practical decision boundary is: + +- If plugin compatibility is the highest priority, the workbench shell must move closer to VS Code or a VS Code-compatible platform. +- If current UI freedom is the highest priority, Coder Studio can only reuse VS Code-adjacent protocol and editor capabilities, not the full plugin ecosystem. + +## What "Highest Plugin Compatibility" Implies + +If Coder Studio wants the strongest possible compatibility with the VS Code extension ecosystem, the product shell must largely yield to a VS Code-style workbench model. + +Implications: + +- main shell follows workbench conventions instead of a custom page shell +- extension lifecycle and contribution points become host-driven +- files/search/scm/terminal/commands/preferences should use workbench-native surfaces +- custom product features should live inside views, widgets, panels, commands, and webviews instead of owning the whole page layout + +This direction improves extension compatibility, but reduces control over: + +- overall shell layout +- global DOM and CSS ownership +- fully custom page routing as the main experience +- the current independent mobile `Dock + Sheet` interaction model + +## What "Keep Current UI Freedom" Implies + +If Coder Studio keeps its current UI freedom, it can still adopt selected VS Code-adjacent capabilities: + +- Monaco editor services +- LSP-backed language intelligence +- DAP-backed debugger plumbing +- syntax themes, snippets, and editor-level enhancements +- selected VS Code-like services via `monaco-vscode-api` + +But it cannot honestly promise: + +- broad compatibility with existing VS Code extensions +- full workbench contribution support +- strong compatibility for extensions that need a full extension host, Node runtime, terminal integration, SCM integration, or workbench UI contracts + +In short: + +- preserve UI freedom -> reuse protocols and editor services +- maximize extension compatibility -> adopt a workbench platform + +## Option Comparison + +### Option A: OpenVSCode Server / code-server style foundation + +Strengths: + +- highest extension compatibility +- closest to real VS Code workbench behavior +- least ambiguity about extension host expectations + +Costs: + +- current Coder Studio shell must largely give way to the workbench shell +- mobile-first interaction model is heavily reduced +- custom product UI becomes embedded features inside the workbench, not the main shell + +Best fit when: + +- extension compatibility is more important than product shell freedom + +### Option B: Theia + +Strengths: + +- meaningful VS Code extension compatibility +- more shell and layout customization than a strict VS Code workbench route +- supports custom frontend/backend extensions in addition to VS Code-style plugins +- better fit for a hybrid product that still wants a distinct identity + +Costs: + +- current route-driven shell still needs major refactoring +- desktop can be adapted, but mobile-specific shell patterns should not be assumed to map cleanly +- compatibility is good but not equal to official VS Code + +Best fit when: + +- the product wants a serious extension ecosystem story without fully surrendering product-level customization + +### Option C: Keep current architecture + `monaco-vscode-api` + +Strengths: + +- highest UI freedom +- lowest shell disruption +- best preservation of the current mobile and cross-device product language + +Costs: + +- extension compatibility remains limited +- many workbench responsibilities become self-owned platform work +- long-term maintenance cost is high because Coder Studio would be building a partial compatibility layer itself + +Best fit when: + +- product UI freedom is more important than extension ecosystem breadth + +## Recommended Position + +Based on the current discussion: + +- OpenVSCode-style foundation is the right answer only if extension compatibility clearly outweighs UI freedom. +- Theia is the best compromise if the product wants both some VS Code extension compatibility and some product-level UI control. +- Staying self-built is only the right answer if the team explicitly chooses product shell freedom over extension compatibility. + +For the current priority ordering discussed in research: + +- `plugin compatibility first` +- `do not fully lose all product customization` + +The best-fit direction is **Theia**, with the explicit understanding that this is still a compromise and not full VS Code equivalence. + +## Theia-Specific UI Impact + +If Coder Studio moves to Theia, the product should not plan on lifting the current UI into Theia unchanged. + +### UI areas that must be restructured + +- The current page shell model should be replaced by a Theia `ApplicationShell + widgets` model. +- `DesktopShell` and `MobileShell` should no longer be treated as peer top-level app shells. +- `/workspace` should stop being the dominant page abstraction. +- `/settings` should split into standard preferences plus one or more custom product widgets. +- `QuickOpen` and `CommandPalette` should stop competing with workbench-native command surfaces. + +### Desktop changes likely required + +- `TopBar` should shrink dramatically or disappear as a product-owned global shell. +- custom workspace tabs should likely move to commands, switchers, or a dedicated widget +- files/search/source control should prefer workbench-native views where possible +- status strip should be decomposed into status bar items +- custom panels should be repackaged as Theia widgets, not page regions + +### Mobile changes likely required + +This is the largest UI loss area. + +Current mobile behavior is a dedicated product model: + +- top bar +- single active session surface +- bottom dock +- fullscreen sheets for files, terminal, supervisor, and agent session selection + +This model should not be assumed to transfer cleanly to Theia. + +Research conclusion: + +- desktop workbench migration and mobile-shell preservation should be treated as separate concerns +- if mobile is strategically important, keep a separate mobile-optimized frontend rather than forcing Theia to become the mobile shell + +This mobile conclusion is an engineering judgment based on Theia's documented workbench and layout model, not a direct vendor promise. + +## Practical Mapping of Current UI to Theia + +Likely mapping: + +- welcome/start flow -> startup widget or launch dialog +- diagnostics -> dedicated widget or command-driven surface +- settings page -> preferences plus custom settings widget +- left activity/sidebar -> Theia view container(s) +- agent/session/supervisor/review -> custom Theia widgets +- bottom terminal panel -> Theia terminal integration or a wrapped custom terminal widget +- footer status strip -> status bar items + +Should not be treated as first-class Theia shell concepts: + +- current route-driven workspace page +- custom top-level desktop/mobile shell split +- mobile `Dock + Sheet` shell as the primary workbench model + +## Decision Triggers For Later + +Use this memo to revisit the decision when the team can answer these questions: + +1. Is extension compatibility more important than keeping the current mobile-first product shell? +2. Is partial extension compatibility sufficient, or does the team need as much real-world VS Code extension coverage as possible? +3. Is the product willing to split desktop and mobile into different frontend strategies? +4. Does the team want to own a partial compatibility platform over time? + +## Research Sources + +Official and primary references used in this evaluation: + +- VS Code Extension Host: https://code.visualstudio.com/api/advanced-topics/extension-host +- VS Code Extension Capabilities: https://code.visualstudio.com/api/extension-capabilities/overview +- VS Code Web Extensions: https://code.visualstudio.com/api/extension-guides/web-extensions +- VS Code Language Server Extension Guide: https://code.visualstudio.com/api/language-extensions/language-server-extension-guide +- VS Code Debugger Extension Guide: https://code.visualstudio.com/api/extension-guides/debugger-extension +- Theia Extensions: https://theia-ide.org/docs/extensions/ +- Theia Frontend Application Contributions: https://theia-ide.org/docs/frontend_application_contribution/ +- Theia Architecture: https://theia-ide.org/docs/architecture/ +- `monaco-vscode-api`: https://github.com/CodinGame/monaco-vscode-api + +## Final Summary + +The current research position is: + +- full UI freedom and highest extension compatibility are mutually competing goals +- VS Code-compatible workbench routes favor compatibility over shell freedom +- self-built routes favor shell freedom over compatibility +- Theia is the strongest compromise for Coder Studio if the team wants a serious plugin story without fully collapsing into a pure VS Code shell +- a future Theia path should treat desktop and mobile as different product surfaces rather than assuming one shell serves both equally well diff --git a/docs/superpowers/plans/2026-05-26-workspace-panel-editor-unification.md b/docs/superpowers/plans/2026-05-26-workspace-panel-editor-unification.md new file mode 100644 index 00000000..b1a23964 --- /dev/null +++ b/docs/superpowers/plans/2026-05-26-workspace-panel-editor-unification.md @@ -0,0 +1,1037 @@ +# Workspace Panel Editor Unification Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Unify the desktop and mobile `Explorer`, `Search`, and `Source Control` workspace panels into one compact, editor-like visual system with small-radius controls, shared spacing, and block-style selected states that no longer rely on left accent bars. + +**Architecture:** Keep the current workbench information architecture and component boundaries, then pull the three panels onto one shared sidebar grammar: common compact controls, common row states, common section headers, and common token-driven selected/focus behavior. Implement the visual contract in `components.theme.test.ts` first, add lightweight shared row/control hooks where the markup needs them, then converge `Explorer`, `Search`, `Git`, and `mobile-files-sheet` without introducing a parallel mobile design language. + +**Tech Stack:** React 19, TypeScript, Jotai, Vitest, Testing Library, vanilla CSS custom properties in `packages/web/src/styles/components.css` + +**Spec reference:** `docs/superpowers/specs/2026-05-26-workspace-panel-editor-unification-design.md` + +**Git hygiene:** The worktree may already contain unrelated user changes. Read files before patching them, stage only the files listed in each task, and never revert unrelated edits. + +--- + +## File Structure + +**Modified files:** +- `packages/web/src/styles/components.theme.test.ts` + - Lock the approved style contract: shared compact controls, shared selected blocks, no left selection bar, shared mobile/desktop token usage. +- `packages/web/src/styles/components.css` + - Add shared workbench primitives for sidebar controls and rows, then restyle `Explorer`, `Search`, `Git`, and mobile files surfaces to consume them. +- `packages/web/src/features/workspace/views/shared/open-editors-section.tsx` + - Add shared row class hooks to open-editor items. +- `packages/web/src/features/workspace/views/shared/quick-jump-section.tsx` + - Add shared control/row hooks so quick jump matches Explorer/Search grammar on mobile. +- `packages/web/src/features/workspace/views/shared/file-tree-panel.tsx` + - Add shared row class hooks to tree rows and search-result rows without changing file-tree behavior. +- `packages/web/src/features/workspace/views/shared/search-panel.tsx` + - Track the currently selected search match and expose a persistent block-selected state in addition to hover/focus. +- `packages/web/src/features/workspace/views/shared/git-panel.tsx` + - Add shared row hooks to worktree rows, diff rows, and history rows so Git consumes the same panel language. +- `packages/web/src/features/workspace/views/shared/file-tree-panel.test.tsx` + - Keep file tree structure and search-mode behavior stable while row hooks and selected-state styling change. +- `packages/web/src/features/workspace/views/shared/search-panel.test.tsx` + - Add regression coverage for persistent selected search rows and query-reset behavior. +- `packages/web/src/features/workspace/views/shared/git-panel.test.tsx` + - Keep active diff-row behavior and Git panel structure stable while styles and row hooks change. +- `packages/web/src/features/workspace/views/shared/explorer-panel.test.tsx` + - Preserve the Explorer header/action split while shared row/control classes are added below it. +- `packages/web/src/features/workspace/views/mobile/mobile-explorer-panel.test.tsx` + - Preserve Quick Jump / Open Editors / Workspace ordering and hidden embedded file search on mobile. +- `packages/web/src/features/workspace/views/mobile/mobile-files-sheet.test.tsx` + - Preserve three-tab switching and detail-surface behavior while the panel system is visually unified. + +**Testing commands used in this plan:** +- `pnpm --filter @coder-studio/web exec vitest run src/styles/components.theme.test.ts` +- `pnpm --filter @coder-studio/web exec vitest run src/features/workspace/views/shared/file-tree-panel.test.tsx src/features/workspace/views/shared/explorer-panel.test.tsx src/features/workspace/views/mobile/mobile-explorer-panel.test.tsx` +- `pnpm --filter @coder-studio/web exec vitest run src/features/workspace/views/shared/search-panel.test.tsx` +- `pnpm --filter @coder-studio/web exec vitest run src/features/workspace/views/shared/git-panel.test.tsx src/features/workspace/views/mobile/mobile-files-sheet.test.tsx` +- `pnpm --filter @coder-studio/web exec vitest run src/styles/components.theme.test.ts src/features/workspace/views/shared/file-tree-panel.test.tsx src/features/workspace/views/shared/search-panel.test.tsx src/features/workspace/views/shared/git-panel.test.tsx src/features/workspace/views/mobile/mobile-explorer-panel.test.tsx src/features/workspace/views/mobile/mobile-files-sheet.test.tsx` + +--- + +### Task 1: Lock The Shared Workbench Style Contract In Theme Tests + +**Files:** +- Modify: `packages/web/src/styles/components.theme.test.ts` +- Test: `packages/web/src/styles/components.theme.test.ts` + +- [ ] **Step 1: Rewrite the theme assertions around the approved selected-state and compact-control contract** + +Update the existing workspace sidebar assertions in `packages/web/src/styles/components.theme.test.ts` so they reflect the approved visual direction. + +Replace the desktop file-tree selected-row expectations with this contract: + +```ts + expect(rowSelected).not.toContain("border-left:"); + expect(rowSelected).not.toContain("padding-left: calc("); + expect(rowSelected).toContain("border: 1px solid var(--state-selected-border)"); + expect(rowSelected).toContain("background: var(--state-selected-bg)"); + expect(rowSelected).toContain("color: var(--text-primary)"); +``` + +Tighten the shared compact-control assertions for Explorer / Search / Quick Jump / Git: + +```ts + expect(search).toContain("border-radius: var(--radius-md)"); + expect(searchInput).toContain("border-radius: var(--radius-md)"); + expect(searchInput).not.toContain("border-radius: 4px"); + expect(quickOpenSearch).toContain("border-radius: var(--radius-md)"); + expect(gitCommitBlock).toContain("gap: var(--sp-2)"); +``` + +Extend the Search assertions to cover the persistent selected row: + +```ts + const searchMatchActive = getLastRuleBlock(".workspace-search-panel__match--active"); + + expect(searchMatch).toContain("border-radius: var(--radius-md)"); + expect(searchMatch).toContain("border: 1px solid transparent"); + expect(searchMatchActive).toContain("border-color: var(--state-selected-border)"); + expect(searchMatchActive).toContain("background: var(--state-selected-bg)"); +``` + +Extend the Git assertions to cover the unified row-selection language: + +```ts + const gitRowActive = getLastRuleBlock(".git-panel .git-row.active"); + const gitHistoryRowCurrent = getLastRuleBlock(".git-history-row.current"); + + expect(gitRowActive).not.toContain("::before"); + expect(gitRowActive).toContain("border-color: var(--state-selected-border)"); + expect(gitRowActive).toContain("background: var(--state-selected-bg)"); + expect(gitHistoryRowCurrent).toContain("border-color: var(--state-selected-border)"); + expect(gitHistoryRowCurrent).toContain("background: var(--state-selected-bg)"); +``` + +- [ ] **Step 2: Run the style test to verify it fails** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/styles/components.theme.test.ts +``` + +Expected: +- FAIL because `file-tree-shell .tree-item.selected` still uses `border-left` +- FAIL because Git diff rows still use `::before` +- FAIL because Search has no persistent selected-row class +- FAIL because several controls still use panel-sized or ad hoc radii instead of the shared compact token + +Leave the mobile selected-row assertions unchanged for now. Add the mobile block-selected contract in Task 6 so Task 2 can take the desktop/shared primitive layer fully green before the mobile surface pass. + +- [ ] **Step 3: Commit nothing yet** + +Do not stage or commit after the red run. The next tasks make the test contract pass. + +--- + +### Task 2: Add Shared Sidebar Control And Row Primitives + +**Files:** +- Modify: `packages/web/src/styles/components.css` +- Test: `packages/web/src/styles/components.theme.test.ts` + +- [ ] **Step 1: Add the shared control and row primitives to `components.css`** + +Insert a shared workbench primitive block near the existing `.workspace-sidebar-panel` rules in `packages/web/src/styles/components.css`: + +```css +.workspace-sidebar-control { + border: 1px solid var(--component-mix-border-default-84pct-transparent); + border-radius: var(--radius-md); + background: var(--component-mix-surface-panel-92pct-surface-page); + transition: + border-color var(--duration-fast) var(--ease-out), + background-color var(--duration-fast) var(--ease-out), + box-shadow var(--duration-fast) var(--ease-out); +} + +.workspace-sidebar-control:focus-within { + border-color: var(--component-mix-border-focus-64pct-transparent); + box-shadow: inset 0 0 0 var(--state-focus-ring-width) + var(--component-mix-border-focus-64pct-transparent); +} + +.workspace-sidebar-row { + border: 1px solid transparent; + border-radius: var(--radius-md); + transition: + border-color var(--duration-fast) var(--ease-out), + background-color var(--duration-fast) var(--ease-out), + color var(--duration-fast) var(--ease-out), + box-shadow var(--duration-fast) var(--ease-out); +} + +.workspace-sidebar-row:hover { + background: var(--surface-hover); +} + +.workspace-sidebar-row:focus-visible { + outline: none; + box-shadow: inset 0 0 0 var(--state-focus-ring-width) + var(--component-mix-border-focus-64pct-transparent); +} + +.workspace-sidebar-row--selected { + border-color: var(--state-selected-border); + background: var(--state-selected-bg); + color: var(--text-primary); +} +``` + +- [ ] **Step 2: Replace the old one-off row and control chrome with the shared primitives** + +Update the existing selector bodies in `packages/web/src/styles/components.css` so they stop fighting the shared primitive block: + +```css +.workspace-open-editors__item { + min-height: 28px; + padding: 0 var(--sp-2); + border-radius: var(--radius-md); +} + +.workspace-quick-jump__search, +.file-tree-shell .file-tree-search, +.file-tree-shell .file-tree-search--desktop, +.workspace-search-panel__input, +.git-panel .git-commit-input { + border-radius: var(--radius-md); +} + +.file-tree-shell .tree-item.selected { + border: 1px solid var(--state-selected-border); + background: var(--state-selected-bg); + color: var(--text-primary); +} + +.git-panel .git-row.active { + border-color: var(--state-selected-border); + background: var(--state-selected-bg); +} +``` + +Delete the old left-accent implementation entirely: + +```css +.git-panel .git-row.active::before { + content: none; +} +``` + +- [ ] **Step 3: Re-run the theme test to verify the shared primitive layer is taking effect** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/styles/components.theme.test.ts +``` + +Expected: +- PASS for the shared row/control assertions added in Task 1 +- PASS for the existing workspace surface assertions + +- [ ] **Step 4: Commit the shared primitive layer** + +```bash +git add \ + packages/web/src/styles/components.css \ + packages/web/src/styles/components.theme.test.ts +git commit -m "refactor(web): add shared workspace panel primitives" +``` + +--- + +### Task 3: Converge Explorer Rows, Sections, And Compact Controls + +**Files:** +- Modify: `packages/web/src/styles/components.css` +- Modify: `packages/web/src/features/workspace/views/shared/open-editors-section.tsx` +- Modify: `packages/web/src/features/workspace/views/shared/quick-jump-section.tsx` +- Modify: `packages/web/src/features/workspace/views/shared/file-tree-panel.tsx` +- Modify: `packages/web/src/features/workspace/views/shared/explorer-panel.test.tsx` +- Modify: `packages/web/src/features/workspace/views/shared/file-tree-panel.test.tsx` +- Modify: `packages/web/src/features/workspace/views/mobile/mobile-explorer-panel.test.tsx` +- Test: `packages/web/src/features/workspace/views/shared/file-tree-panel.test.tsx` +- Test: `packages/web/src/features/workspace/views/shared/explorer-panel.test.tsx` +- Test: `packages/web/src/features/workspace/views/mobile/mobile-explorer-panel.test.tsx` + +- [ ] **Step 1: Add focused Explorer regressions before tightening the styling** + +Add this regression to `packages/web/src/features/workspace/views/shared/file-tree-panel.test.tsx`: + +```tsx + it("keeps the shared row hook on selected desktop tree items without restoring the embedded search field", () => { + const store = createStore(); + store.set(wsClientAtom, { sendCommand: vi.fn() } as never); + store.set( + fileTreeAtomFamily("ws-test"), + new Map([ + [ + ".", + [{ path: "src/app.tsx", name: "app.tsx", kind: "file" }], + ], + ]) + ); + store.set(activeFilePathAtomFamily("ws-test"), "src/app.tsx"); + + render( + + + + ); + + const row = screen.getByText("app.tsx").closest(".tree-item") as HTMLElement; + + expect(row).toHaveClass("workspace-sidebar-row", "workspace-sidebar-row--selected"); + expect(screen.queryByLabelText("action.search_files")).toBeNull(); + }); +``` + +Add an Explorer-level regression to `packages/web/src/features/workspace/views/shared/explorer-panel.test.tsx` so the open-editor list is covered when the shared row hook lands: + +```tsx + it("keeps the active open editor on the shared selected-row contract", () => { + const store = createStore(); + store.set(wsClientAtom, { sendCommand: vi.fn() } as never); + store.set(openFilesAtomFamily("ws-test"), { + "src/app.tsx": { path: "src/app.tsx", isDirty: false, lineEnding: "lf" }, + }); + store.set(activeFilePathAtomFamily("ws-test"), "src/app.tsx"); + + render( + + + + ); + + expect(screen.getByRole("button", { name: "src/app.tsx" })).toHaveClass( + "workspace-open-editors__item", + "workspace-sidebar-row", + "workspace-sidebar-row--selected" + ); + }); +``` + +Add this regression to `packages/web/src/features/workspace/views/mobile/mobile-explorer-panel.test.tsx`: + +```tsx + expect( + screen.getByPlaceholderText(/Type a filename or path|输入文件名或路径/i).closest("label") + ).toHaveClass("workspace-sidebar-control"); +``` + +- [ ] **Step 2: Run the Explorer-focused tests to verify failure** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/workspace/views/shared/file-tree-panel.test.tsx \ + src/features/workspace/views/shared/explorer-panel.test.tsx \ + src/features/workspace/views/mobile/mobile-explorer-panel.test.tsx +``` + +Expected: +- FAIL because the new shared-row assertions are not implemented yet +- PASS for the unchanged Explorer header/action split + +- [ ] **Step 3: Add the Explorer markup hooks and tighten Explorer spacing around the shared primitives** + +In `packages/web/src/features/workspace/views/shared/open-editors-section.tsx`, move open-editor items onto the shared row contract: + +```tsx + + + {advancedExpanded ? ( +
+ {/* keep the existing four switch rows here unchanged */} +
+ ) : null} + +``` + +Add locale keys in `packages/web/src/locales/en.json`: + +```json + "advanced_settings": "Show advanced monitoring settings", +``` + +and in `packages/web/src/locales/zh.json`: + +```json + "advanced_settings": "显示高级监控设置", +``` + +- [ ] **Step 4: Run the subpage tests to verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/features/settings/components/monitoring-settings-subpage.test.tsx +``` + +Expected: PASS for the compact-control visibility, disabled auto-expansion, and advanced disclosure behavior. + +- [ ] **Step 5: Commit the compact controls refactor** + +```bash +git add \ + packages/web/src/features/settings/components/monitoring-settings-card.tsx \ + packages/web/src/features/settings/components/monitoring-settings-subpage.test.tsx \ + packages/web/src/locales/en.json \ + packages/web/src/locales/zh.json +git commit -m "refactor: compact monitoring control bar" +``` + +### Task 3: Tighten Monitoring Typography And Local Layout Styles + +**Files:** +- Modify: `packages/web/src/features/monitoring/page.tsx` +- Modify: `packages/web/src/styles/components.css` +- Modify: `packages/web/src/styles/components.theme.test.ts` + +- [ ] **Step 1: Write the failing theme-style assertions** + +Replace the old monitoring-subpage token test in `packages/web/src/styles/components.theme.test.ts` with: + +```ts + it("keeps the unified monitoring shell and compact typography on shared theme tokens", () => { + const shell = getLastRuleBlock(".settings-monitoring-shell"); + const controlBar = getLastRuleBlock(".settings-monitoring-control-bar"); + const controlSummary = getLastRuleBlock(".settings-monitoring-control-bar__summary"); + const advancedToggle = getLastRuleBlock(".settings-monitoring-advanced__toggle"); + const dashboardStage = getLastRuleBlock(".settings-monitoring-dashboard-stage"); + const dashboardCardTitle = getLastRuleBlock(".monitoring-card__header h2"); + const detailHeading = getLastRuleBlock(".monitoring-detail h3"); + + expect(shell).toContain("display: flex"); + expect(shell).toContain("flex-direction: column"); + expect(controlBar).toContain("border: 1px solid var(--surface-elevated-border)"); + expect(controlBar).toContain("background: var(--surface-elevated)"); + expect(controlBar).toContain("padding: var(--sp-4)"); + expect(controlSummary).toContain("font-size: var(--type-body-5-size)"); + expect(controlSummary).toContain("color: var(--text-secondary)"); + expect(advancedToggle).toContain("font-size: var(--type-body-5-size)"); + expect(advancedToggle).toContain("border-top: 1px solid var(--surface-elevated-border)"); + expect(dashboardStage).toContain("min-width: 0"); + expect(dashboardCardTitle).toContain("font-size: var(--type-heading-6-size)"); + expect(detailHeading).toContain("font-size: var(--type-body-3-size)"); + }); +``` + +- [ ] **Step 2: Run the theme test to verify it fails** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/styles/components.theme.test.ts +``` + +Expected: +- FAIL because the stylesheet still defines grid-based stage/dock rules and larger title selectors + +- [ ] **Step 3: Update the monitoring styles and local title semantics** + +In `packages/web/src/features/monitoring/page.tsx`, downgrade the empty-state heading and selected-entity heading emphasis: + +```tsx +
+

{t("monitoring.disabled_title")}

+

{t("monitoring.disabled_description")}

+ {onOpenSettings ? ( +
+ +
+ ) : null} +
+``` + +and: + +```tsx +

{t("monitoring.select_entity")}

+ {selectedEntity ? ( + <> +

{selectedEntity.label}

+ {entityDetailRows(selectedEntity, t).map((row) => ( + + ))} +``` + +In `packages/web/src/styles/components.css`, replace the old monitoring shell rules with: + +```css +.settings-monitoring-shell { + display: flex; + flex-direction: column; + gap: var(--sp-4); +} + +.settings-monitoring-control-bar, +.settings-monitoring-dashboard-stage { + min-width: 0; + border: 1px solid var(--surface-elevated-border); + background: var(--surface-elevated); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-sm); +} + +.settings-monitoring-control-bar { + display: flex; + flex-direction: column; + gap: var(--sp-4); + padding: var(--sp-4); +} + +.settings-monitoring-control-bar__copy { + display: flex; + flex-direction: column; + gap: var(--sp-1); +} + +.settings-monitoring-control-bar__eyebrow { + color: var(--text-tertiary); + font-size: var(--type-body-6-size); + line-height: var(--type-body-6-line-height); + font-weight: var(--type-body-6-weight); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.settings-monitoring-control-bar__summary { + color: var(--text-secondary); + font-size: var(--type-body-5-size); + line-height: var(--type-body-5-line-height); + font-weight: var(--type-body-5-weight); + max-width: 72ch; +} + +.settings-monitoring-dashboard-stage { + padding: var(--sp-4); +} + +.settings-monitoring-core-controls { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--sp-3); +} + +.monitoring-settings-row--compact, +.settings-toggle-row--compact { + margin-bottom: 0; +} + +.settings-monitoring-advanced { + display: flex; + flex-direction: column; + gap: var(--sp-3); +} + +.settings-monitoring-advanced__toggle { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-3); + width: 100%; + padding-top: var(--sp-3); + border-top: 1px solid var(--surface-elevated-border); + color: var(--text-primary); + text-align: left; + font-size: var(--type-body-5-size); + line-height: var(--type-body-5-line-height); + font-weight: var(--type-body-5-weight); +} + +.monitoring-card__header h2, +.monitoring-tree .monitoring-card__header h2, +.monitoring-detail .monitoring-card__header h2, +.monitoring-card__title { + font-size: var(--type-heading-6-size); + line-height: var(--type-heading-6-line-height); + font-weight: var(--font-medium); +} + +.monitoring-detail__entity-title { + font-size: var(--type-body-3-size); + line-height: var(--type-body-3-line-height); + font-weight: var(--font-medium); +} + +.monitoring-card p, +.monitoring-detail p, +.settings-monitoring-shell p { + font-size: var(--type-body-5-size); + line-height: var(--type-body-5-line-height); +} + +@media (max-width: 900px) { + .settings-monitoring-control-bar, + .settings-monitoring-dashboard-stage { + padding: var(--sp-3); + } + + .settings-monitoring-core-controls { + grid-template-columns: 1fr; + } +} +``` + +Delete the obsolete selectors: + +```css +.settings-monitoring-shell--desktop +.settings-monitoring-shell--dock-priority .settings-monitoring-dock +.settings-monitoring-stage +.settings-monitoring-stage__header +.settings-monitoring-stage__eyebrow +.settings-monitoring-stage__title +.settings-monitoring-stage__summary +.settings-monitoring-dock +.settings-monitoring-dock__panel +.settings-monitoring-dock__header +.settings-monitoring-dock__copy +.settings-monitoring-dock__eyebrow +.settings-monitoring-dock__title +.settings-monitoring-dock__summary +.settings-monitoring-mobile-entry +.settings-monitoring-mobile-entry__header +.settings-monitoring-mobile-entry__copy +.settings-monitoring-mobile-entry__eyebrow +.settings-monitoring-mobile-entry__title +.settings-monitoring-mobile-entry__badge +.settings-monitoring-mobile-entry__summary +.settings-monitoring-mobile-entry__action +.settings-monitoring-dock-toggle +.settings-monitoring-dock__body +.settings-monitoring-dock__body > .settings-card--monitoring +.settings-monitoring-dock__body > .settings-card--monitoring .settings-toggle-row:last-child +.settings-monitoring-dock__body > .settings-card--monitoring .settings-info-row:last-child +``` + +- [ ] **Step 4: Run the theme test to verify it passes** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/styles/components.theme.test.ts +``` + +Expected: PASS for the unified shell selectors, token-backed surfaces, and reduced local typography. + +- [ ] **Step 5: Commit the monitoring style refinement** + +```bash +git add \ + packages/web/src/features/monitoring/page.tsx \ + packages/web/src/styles/components.css \ + packages/web/src/styles/components.theme.test.ts +git commit -m "style: refine monitoring layout and typography" +``` + +### Task 4: Run Final Verification And Land The Plan Scope + +**Files:** +- Modify: `packages/web/src/features/settings/components/monitoring-settings-subpage.tsx` +- Modify: `packages/web/src/features/settings/components/monitoring-settings-subpage.test.tsx` +- Modify: `packages/web/src/features/settings/components/monitoring-settings-card.tsx` +- Modify: `packages/web/src/features/settings/components/settings-page.test.tsx` +- Modify: `packages/web/src/features/monitoring/page.tsx` +- Modify: `packages/web/src/styles/components.css` +- Modify: `packages/web/src/styles/components.theme.test.ts` +- Modify: `packages/web/src/locales/en.json` +- Modify: `packages/web/src/locales/zh.json` + +- [ ] **Step 1: Run the full focused test suite** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/settings/components/monitoring-settings-subpage.test.tsx \ + src/features/settings/components/settings-page.test.tsx \ + src/styles/components.theme.test.ts +``` + +Expected: PASS across the refined monitoring shell, the settings integration, and token-backed styles. + +- [ ] **Step 2: Run the web typecheck** + +Run: + +```bash +pnpm --filter @coder-studio/web exec tsc -p tsconfig.json --noEmit +``` + +Expected: PASS with no TypeScript errors. + +- [ ] **Step 3: Commit any follow-up fixes from verification** + +```bash +git add \ + packages/web/src/features/settings/components/monitoring-settings-subpage.tsx \ + packages/web/src/features/settings/components/monitoring-settings-subpage.test.tsx \ + packages/web/src/features/settings/components/monitoring-settings-card.tsx \ + packages/web/src/features/settings/components/settings-page.test.tsx \ + packages/web/src/features/monitoring/page.tsx \ + packages/web/src/styles/components.css \ + packages/web/src/styles/components.theme.test.ts \ + packages/web/src/locales/en.json \ + packages/web/src/locales/zh.json +git commit -m "test: verify monitoring layout refinement" +``` + +## Self-Review + +### Spec coverage + +- Unified head control bar plus dashboard below: covered by Task 1 and Task 3 +- Advanced settings disclosure for the four low-frequency toggles: covered by Task 2 +- No mobile configuration entry card: covered by Task 1 tests and implementation +- Typography reduction localized to monitoring selectors: covered by Task 3 +- No backend/data-model changes: preserved by the file list and task scope + +### Placeholder scan + +- No `TODO`, `TBD`, or “implement later” placeholders remain +- Each code-edit step includes concrete snippets, concrete files, and concrete commands + +### Type consistency + +- `advancedExpanded` / `onAdvancedExpandedChange` are defined in Task 2 before being relied on by the shell from Task 1 +- The new selectors referenced in tests match the selectors introduced in Task 3 diff --git a/docs/superpowers/plans/2026-05-27-settings-monitoring-subpage-redesign.md b/docs/superpowers/plans/2026-05-27-settings-monitoring-subpage-redesign.md new file mode 100644 index 00000000..26da4b76 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-settings-monitoring-subpage-redesign.md @@ -0,0 +1,917 @@ +# Settings Monitoring Subpage Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move monitoring fully into `Settings > Monitoring`, remove the standalone `/monitoring` page, and ship a data-first desktop/mobile monitoring subpage with an integrated control dock. + +**Architecture:** Keep the existing monitoring websocket commands, formatting helpers, and sparkline logic, but stop rendering monitoring as a routed top-level page. Instead, add a dedicated `monitoring` settings section, extract the monitoring UI into a settings-owned subpage component, and reshape the layout into a desktop two-column stage/dock surface with a mobile single-column, data-first fallback. + +**Tech Stack:** TypeScript, React 19, React Router, Jotai, Vitest, Testing Library, and the shared token-driven stylesheet in `packages/web/src/styles/components.css`. + +**Spec reference:** `docs/superpowers/specs/2026-05-27-settings-monitoring-subpage-redesign-design.md` + +**Git hygiene:** The worktree already contains unrelated untracked files. Stage only the files listed in each task and never revert or sweep unrelated edits. + +--- + +## File Structure + +**Create:** +- `packages/web/src/features/settings/components/monitoring-settings-subpage.tsx` + - Settings-owned monitoring subpage surface that loads monitoring data, renders the desktop/mobile dashboard, and hosts the integrated control dock. +- `packages/web/src/features/settings/components/monitoring-settings-subpage.test.tsx` + - Regression coverage for desktop/mobile rendering, disabled state behavior, refresh, and embedded configuration behavior. + +**Modify:** +- `packages/web/src/features/settings/components/settings-sections.tsx` + - Add the `monitoring` section id and metadata. +- `packages/web/src/features/settings/components/settings-page.tsx` + - Remove monitoring from `General`, route the new settings section to the monitoring subpage, and wire shared monitoring settings persistence into the new component. +- `packages/web/src/features/settings/components/monitoring-settings-card.tsx` + - Convert the existing card into a reusable control-dock body that can render with optional embedded title/action chrome. +- `packages/web/src/features/monitoring/index.ts` + - Stop exporting the routed `MonitoringPage`; export reusable helpers/components only if needed by the settings subpage. +- `packages/web/src/features/monitoring/page.tsx` + - Remove or reduce the routed-page wrapper; keep only reusable monitoring helper components if they are still needed by the embedded subpage. +- `packages/web/src/features/monitoring/page.test.tsx` + - Remove standalone-route assumptions or replace the file with focused helper-level tests if shared monitoring helpers remain there. +- `packages/web/src/features/command-palette/components/command-palette.tsx` + - Update the command action to navigate to `/settings?section=monitoring`. +- `packages/web/src/features/command-palette/components/command-palette.test.tsx` + - Assert the command now deep-links into settings. +- `packages/web/src/shells/desktop-shell.tsx` + - Remove the `/monitoring` route and its auth-bypass special case. +- `packages/web/src/shells/mobile-shell/index.tsx` + - Remove the `/monitoring` route and its auth-bypass special case. +- `packages/web/src/shells/desktop-shell.test.tsx` + - Remove the `/monitoring` route expectation and assert unknown `/monitoring` no longer bypasses into monitoring. +- `packages/web/src/shells/mobile-shell/index.test.tsx` + - Remove the mobile `/monitoring` route expectation and assert the standalone path is no longer handled. +- `packages/web/src/features/settings/components/settings-page.test.tsx` + - Replace the old general-page monitoring assertions with navigation, section, and deep-link coverage. +- `packages/web/src/styles/components.css` + - Add `settings-monitoring-*` layout and responsive styles; remove standalone monitoring page shell styles that are no longer used. +- `packages/web/src/styles/components.theme.test.ts` + - Lock the new monitoring subpage selectors to token-driven surfaces and responsive layout rules. +- `packages/web/src/locales/en.json` + - Add or update settings-monitoring copy for embedded title, config entry card, and disabled-state language if needed. +- `packages/web/src/locales/zh.json` + - Chinese counterparts for the new settings-monitoring copy. + +**Testing commands used in this plan:** +- `pnpm --filter @coder-studio/web exec vitest run src/features/settings/components/settings-page.test.tsx src/features/command-palette/components/command-palette.test.tsx src/shells/desktop-shell.test.tsx src/shells/mobile-shell/index.test.tsx` +- `pnpm --filter @coder-studio/web exec vitest run src/features/settings/components/monitoring-settings-subpage.test.tsx` +- `pnpm --filter @coder-studio/web exec vitest run src/styles/components.theme.test.ts` +- `pnpm --filter @coder-studio/web exec vitest run src/features/settings/components/settings-page.test.tsx src/features/settings/components/monitoring-settings-subpage.test.tsx src/features/command-palette/components/command-palette.test.tsx src/shells/desktop-shell.test.tsx src/shells/mobile-shell/index.test.tsx src/styles/components.theme.test.ts` +- `pnpm --filter @coder-studio/web exec tsc -p tsconfig.json --noEmit` + +--- + +### Task 1: Move Monitoring Entry Points Into Settings + +**Files:** +- Modify: `packages/web/src/features/settings/components/settings-sections.tsx` +- Modify: `packages/web/src/features/settings/components/settings-page.tsx` +- Modify: `packages/web/src/features/settings/components/settings-page.test.tsx` +- Modify: `packages/web/src/features/command-palette/components/command-palette.tsx` +- Modify: `packages/web/src/features/command-palette/components/command-palette.test.tsx` +- Modify: `packages/web/src/shells/desktop-shell.tsx` +- Modify: `packages/web/src/shells/mobile-shell/index.tsx` +- Modify: `packages/web/src/shells/desktop-shell.test.tsx` +- Modify: `packages/web/src/shells/mobile-shell/index.test.tsx` + +- [ ] **Step 1: Write the failing navigation and route tests** + +Add a settings navigation regression to `packages/web/src/features/settings/components/settings-page.test.tsx`: + +```tsx + it("renders monitoring as a dedicated settings section and deep-links into it", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "settings.get") { + return { + "monitoring.enabled": true, + "monitoring.hostMetricsEnabled": true, + "monitoring.runtimeSummaryEnabled": true, + "monitoring.workspaceAttributionEnabled": true, + "monitoring.subprocessDrilldownEnabled": false, + "monitoring.sampleIntervalMs": 5000, + }; + } + + if (op === "monitoring.get") { + return { + settings: { + enabled: true, + hostMetricsEnabled: true, + runtimeSummaryEnabled: true, + workspaceAttributionEnabled: true, + subprocessDrilldownEnabled: false, + sampleIntervalMs: 5000, + }, + snapshot: { + sampledAt: 10, + mode: "standard", + host: null, + runtime: null, + workspaces: [], + sessions: [], + subprocessGroups: [], + backgroundGroups: [], + }, + history: { + host: { points: [] }, + runtime: null, + workspaces: {}, + sessions: {}, + subprocessGroups: {}, + }, + capabilities: { + loadAverageAvailable: true, + processMetricsAvailable: true, + subprocessHistoryLimited: false, + }, + telemetry: null, + }; + } + + return {}; + }); + const store = createConnectedStore(sendCommand); + + renderSettingsPage(store, { initialEntry: "/settings?section=monitoring" }); + + expect(await screen.findByRole("button", { name: "监控" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "监控" })).toHaveClass("settings-nav-item-active"); + expect(screen.queryByRole("button", { name: "打开监控" })).not.toBeInTheDocument(); + expect(screen.queryByText("通知")).not.toContainElement( + screen.queryByRole("switch", { name: "启用性能监控" }) + ); + }); +``` + +Update `packages/web/src/features/command-palette/components/command-palette.test.tsx`: + +```tsx + it("opens monitoring through the settings deep link", () => { + const store = createStore(); + store.set(localeAtom, "en"); + store.set(commandPaletteOpenAtom, true); + + render( + + + + ); + + fireEvent.click(screen.getByText("Performance monitoring")); + + expect(routerMocks.navigate).toHaveBeenCalledWith("/settings?section=monitoring"); + }); +``` + +Replace the standalone route assertions in the shell tests: + +```tsx + it("does not bypass auth-loading for standalone /monitoring anymore", () => { + window.history.replaceState({}, "", "/monitoring"); + + const store = createStore(); + store.set(connectionStatusAtom, "connected"); + store.set(authEnabledAtom, null); + store.set(authenticatedAtom, false); + + renderShell(store); + + expect(screen.getByText("正在连接工作区...")).toBeInTheDocument(); + expect(screen.queryByText("MonitoringPage")).not.toBeInTheDocument(); + }); +``` + +and the mobile equivalent: + +```tsx + it("does not resolve standalone /monitoring on mobile anymore", () => { + const store = createStore(); + store.set(connectionStatusAtom, "connected"); + store.set(authEnabledAtom, null); + store.set(authenticatedAtom, false); + + render( + + + + + + + ); + + expect(screen.queryByText("MonitoringPage")).not.toBeInTheDocument(); + expect(screen.getByText("正在连接工作区...")).toBeInTheDocument(); + }); +``` + +- [ ] **Step 2: Run the focused tests to verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/settings/components/settings-page.test.tsx \ + src/features/command-palette/components/command-palette.test.tsx \ + src/shells/desktop-shell.test.tsx \ + src/shells/mobile-shell/index.test.tsx +``` + +Expected: +- FAIL because `settings-sections.tsx` does not define `monitoring` +- FAIL because the settings page still renders monitoring in `General` +- FAIL because command palette still navigates to `/monitoring` +- FAIL because both shells still special-case the standalone route + +- [ ] **Step 3: Implement the settings-first navigation changes** + +Update `packages/web/src/features/settings/components/settings-sections.tsx`: + +```tsx +export type SettingsSection = + | "general" + | "monitoring" + | "appearance" + | "providers" + | "shortcuts" + | "about"; + +export const SETTINGS_SECTIONS = [ + { id: "general", labelKey: "settings.general", iconSemantic: "nav.settings.general" }, + { id: "monitoring", labelKey: "monitoring.title", iconSemantic: "nav.diagnostics" }, + { id: "providers", labelKey: "settings.providers", iconSemantic: "nav.settings.providers" }, + { id: "appearance", labelKey: "settings.appearance", iconSemantic: "nav.settings.appearance" }, + { id: "shortcuts", labelKey: "settings.shortcuts.title", iconSemantic: "nav.settings.shortcuts" }, + { id: "about", labelKey: "settings.about.title", iconSemantic: "nav.settings.about" }, +] as const satisfies readonly SettingsSectionMeta[]; +``` + +Update the mobile grouping in `packages/web/src/features/settings/components/settings-page.tsx`: + +```tsx +const MOBILE_SETTINGS_GROUPS = [ + { + titleKey: "settings.mobile_groups.workspace_runtime", + sections: ["general", "monitoring", "providers"], + }, + { + titleKey: "settings.mobile_groups.interface_interaction", + sections: ["appearance", "shortcuts", "about"], + }, +] as const; +``` + +Change the command palette action in `packages/web/src/features/command-palette/components/command-palette.tsx`: + +```tsx + { + id: "open-monitoring", + label: t("monitoring.command_label"), + description: t("monitoring.command_description"), + action: () => { + navigate("/settings?section=monitoring"); + }, + }, +``` + +Remove the standalone route and bypass conditions from both shells: + +```tsx + const shouldBypassAuthLoading = + location.pathname.startsWith("/settings") || + location.pathname.startsWith("/diagnostics") || + location.pathname === "/session-gate"; +``` + +and delete: + +```tsx + } /> +``` + +- [ ] **Step 4: Run the focused tests to verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/settings/components/settings-page.test.tsx \ + src/features/command-palette/components/command-palette.test.tsx \ + src/shells/desktop-shell.test.tsx \ + src/shells/mobile-shell/index.test.tsx +``` + +Expected: PASS with the new settings navigation and without any `/monitoring` route expectations. + +- [ ] **Step 5: Commit the navigation/route slice** + +```bash +git add \ + packages/web/src/features/settings/components/settings-sections.tsx \ + packages/web/src/features/settings/components/settings-page.tsx \ + packages/web/src/features/settings/components/settings-page.test.tsx \ + packages/web/src/features/command-palette/components/command-palette.tsx \ + packages/web/src/features/command-palette/components/command-palette.test.tsx \ + packages/web/src/shells/desktop-shell.tsx \ + packages/web/src/shells/mobile-shell/index.tsx \ + packages/web/src/shells/desktop-shell.test.tsx \ + packages/web/src/shells/mobile-shell/index.test.tsx +git commit -m "feat: move monitoring entrypoints into settings" +``` + +--- + +### Task 2: Build The Embedded Monitoring Settings Subpage + +**Files:** +- Create: `packages/web/src/features/settings/components/monitoring-settings-subpage.tsx` +- Create: `packages/web/src/features/settings/components/monitoring-settings-subpage.test.tsx` +- Modify: `packages/web/src/features/settings/components/settings-page.tsx` +- Modify: `packages/web/src/features/settings/components/monitoring-settings-card.tsx` +- Modify: `packages/web/src/features/monitoring/page.tsx` +- Modify: `packages/web/src/features/monitoring/index.ts` +- Modify: `packages/web/src/features/monitoring/page.test.tsx` + +- [ ] **Step 1: Write the failing embedded-subpage tests** + +Create `packages/web/src/features/settings/components/monitoring-settings-subpage.test.tsx`: + +```tsx +import { fireEvent, render, screen } from "@testing-library/react"; +import { createStore, Provider } from "jotai"; +import { describe, expect, it, vi } from "vitest"; +import { connectionStatusAtom, wsClientAtom } from "../../../atoms/connection"; +import { localeAtom } from "../../../atoms/app-ui"; +import { MonitoringSettingsSubpage } from "./monitoring-settings-subpage"; + +function buildResponse(overrides: Record = {}) { + return { + settings: { + enabled: true, + hostMetricsEnabled: true, + runtimeSummaryEnabled: true, + workspaceAttributionEnabled: true, + subprocessDrilldownEnabled: false, + sampleIntervalMs: 2000, + }, + snapshot: { + sampledAt: 10, + mode: "standard", + host: { + cpuPercent: 72, + memoryUsedBytes: 800, + memoryTotalBytes: 1000, + memoryAvailableBytes: 200, + loadAverage: [1, 1, 1], + uptimeSec: 60, + pressure: "elevated", + }, + runtime: { + serverCpuPercent: 10, + serverMemoryBytes: 100, + totalManagedCpuPercent: 30, + totalManagedMemoryBytes: 300, + managedProcessCount: 4, + cpuShareOfHostPercent: 41.67, + memoryShareOfHostPercent: 30, + }, + workspaces: [], + sessions: [], + subprocessGroups: [], + backgroundGroups: [], + ...overrides, + }, + history: { + host: { points: [{ sampledAt: 10, cpuPercent: 72, memoryBytes: 800 }] }, + runtime: { points: [{ sampledAt: 10, cpuPercent: 30, memoryBytes: 300, processCount: 4 }] }, + workspaces: {}, + sessions: {}, + subprocessGroups: {}, + }, + capabilities: { + loadAverageAvailable: true, + processMetricsAvailable: true, + subprocessHistoryLimited: false, + }, + telemetry: null, + }; +} + +describe("MonitoringSettingsSubpage", () => { + it("renders the desktop stage and control dock together", async () => { + const response = buildResponse(); + const sendCommand = vi.fn().mockResolvedValue(response); + const subscribe = vi.fn((_topics, handler) => { + handler("monitoring.snapshot.updated", response); + return () => {}; + }); + const store = createStore(); + store.set(localeAtom, "en"); + store.set(connectionStatusAtom, "connected"); + store.set(wsClientAtom, { sendCommand, subscribe } as never); + + render( + + + + ); + + expect(await screen.findByText("Performance monitoring")).toBeInTheDocument(); + expect(document.querySelector(".settings-monitoring__layout")).toBeTruthy(); + expect(document.querySelector(".settings-monitoring__stage")).toBeTruthy(); + expect(document.querySelector(".settings-monitoring__dock")).toBeTruthy(); + }); + + it("prioritizes the control panel when monitoring is disabled", async () => { + const response = buildResponse({ + mode: "disabled", + host: null, + runtime: null, + workspaces: [], + sessions: [], + subprocessGroups: [], + backgroundGroups: [], + }); + response.settings.enabled = false; + const store = createStore(); + store.set(localeAtom, "en"); + store.set(connectionStatusAtom, "connected"); + store.set(wsClientAtom, { + sendCommand: vi.fn().mockResolvedValue(response), + subscribe: vi.fn(() => () => {}), + } as never); + + render( + + + + ); + + expect(await screen.findByText("Monitoring disabled")).toBeInTheDocument(); + expect(document.querySelector(".settings-monitoring__dock--expanded")).toBeTruthy(); + }); +}); +``` + +Add a settings-page rendering assertion: + +```tsx + it("renders monitoring inside the settings content surface instead of general settings", async () => { + const sendCommand = vi.fn().mockResolvedValue({}); + const store = createConnectedStore(sendCommand); + + renderSettingsPage(store, { initialEntry: "/settings?section=monitoring" }); + + expect(await screen.findByText("性能监控")).toBeInTheDocument(); + expect(document.querySelector(".settings-content-surface .settings-monitoring")).toBeTruthy(); + expect(screen.queryByText("通知")).not.toContainElement( + screen.queryByRole("switch", { name: "启用性能监控" }) + ); + }); +``` + +- [ ] **Step 2: Run the embedded-subpage tests to verify failure** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/settings/components/monitoring-settings-subpage.test.tsx \ + src/features/settings/components/settings-page.test.tsx +``` + +Expected: +- FAIL because `MonitoringSettingsSubpage` does not exist +- FAIL because settings still render monitoring controls through `GeneralSettings` + +- [ ] **Step 3: Implement the embedded monitoring subpage and remove monitoring from General** + +Create `packages/web/src/features/settings/components/monitoring-settings-subpage.tsx` with this skeleton: + +```tsx +import type { MonitoringResponse, MonitoringSettings } from "@coder-studio/core"; +import { Topics } from "@coder-studio/core"; +import { useAtomValue } from "jotai"; +import { useEffect, useMemo, useState } from "react"; +import { connectionStatusAtom, wsClientAtom } from "../../../atoms/connection"; +import { Button, Notice, SegmentedControl, Tag } from "../../../components/ui"; +import { useViewport } from "../../../hooks/use-viewport"; +import { useTranslation } from "../../../lib/i18n"; +import { Sparkline } from "../../monitoring/sparkline"; +import { formatBytes, formatPercent, formatRefreshInterval, formatTimestamp } from "../../monitoring/formatters"; +import { MonitoringSettingsCard } from "./monitoring-settings-card"; + +export function MonitoringSettingsSubpage({ + monitoringSettings, + onMonitoringSettingsChange, +}: { + monitoringSettings: MonitoringSettings; + onMonitoringSettingsChange: (value: MonitoringSettings) => Promise | void; +}) { + const t = useTranslation(); + const viewport = useViewport(); + const isMobile = viewport === "mobile"; + const wsClient = useAtomValue(wsClientAtom); + const connectionStatus = useAtomValue(connectionStatusAtom); + const [response, setResponse] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [timeWindow, setTimeWindow] = useState<"5m" | "15m" | "30m">("15m"); + const [controlsOpen, setControlsOpen] = useState(false); + + useEffect(() => { + if (!wsClient || connectionStatus !== "connected") { + return; + } + + let cancelled = false; + + const load = async () => { + setLoading(true); + setError(null); + try { + const next = await wsClient.sendCommand("monitoring.get", {}, undefined); + if (!cancelled) { + setResponse(next); + } + } catch (loadError) { + if (!cancelled) { + setError(loadError instanceof Error ? loadError.message : t("monitoring.load_failed")); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + }; + + void load(); + const unsubscribe = wsClient.subscribe([Topics.monitoringSnapshotUpdated], (_topic, payload) => { + setResponse(payload as MonitoringResponse); + setLoading(false); + setError(null); + }); + + return () => { + cancelled = true; + unsubscribe(); + }; + }, [connectionStatus, t, wsClient]); + + const dockExpanded = !monitoringSettings.enabled || !response?.settings.enabled || controlsOpen; + + return ( +
+
+
+

{t("monitoring.title")}

+

{t("monitoring.description")}

+
+
+ + {response ? formatRefreshInterval(response.settings.sampleIntervalMs) : "--"} + + +
+
+ +
+
{/* move the existing overview/attribution/process content here */}
+ +
+
+ ); +} +``` + +In `packages/web/src/features/settings/components/settings-page.tsx`, remove `monitoringSettings` from `GeneralSettingsProps` and switch the section renderer: + +```tsx + case "monitoring": + return ( + + ); +``` + +and delete this block from `GeneralSettings`: + +```tsx +
+ navigate("/monitoring")} + settings={monitoringSettings} + /> +
+``` + +Update `MonitoringSettingsCard` to support embedded dock usage: + +```tsx +interface MonitoringSettingsCardProps { + readonly settings: MonitoringSettings; + readonly mode: MonitoringMode; + readonly onChange: (next: MonitoringSettings) => Promise | void; + readonly onOpenMonitoring?: () => void; + readonly showHeader?: boolean; + readonly docked?: boolean; +} +``` + +and gate the old ghost button/header chrome behind `showHeader !== false`. + +- [ ] **Step 4: Run the embedded-subpage tests to verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/settings/components/monitoring-settings-subpage.test.tsx \ + src/features/settings/components/settings-page.test.tsx +``` + +Expected: PASS with monitoring rendered only inside the dedicated settings section. + +- [ ] **Step 5: Commit the embedded-subpage slice** + +```bash +git add \ + packages/web/src/features/settings/components/monitoring-settings-subpage.tsx \ + packages/web/src/features/settings/components/monitoring-settings-subpage.test.tsx \ + packages/web/src/features/settings/components/settings-page.tsx \ + packages/web/src/features/settings/components/settings-page.test.tsx \ + packages/web/src/features/settings/components/monitoring-settings-card.tsx \ + packages/web/src/features/monitoring/page.tsx \ + packages/web/src/features/monitoring/index.ts \ + packages/web/src/features/monitoring/page.test.tsx +git commit -m "feat: embed monitoring inside settings" +``` + +--- + +### Task 3: Restyle The Monitoring Subpage For Desktop And Mobile + +**Files:** +- Modify: `packages/web/src/styles/components.css` +- Modify: `packages/web/src/styles/components.theme.test.ts` +- Modify: `packages/web/src/features/settings/components/monitoring-settings-subpage.test.tsx` +- Modify: `packages/web/src/locales/en.json` +- Modify: `packages/web/src/locales/zh.json` + +- [ ] **Step 1: Write the failing style and mobile-behavior tests** + +Add to `packages/web/src/features/settings/components/monitoring-settings-subpage.test.tsx`: + +```tsx + it("renders the mobile configuration entry collapsed while monitoring is enabled", async () => { + viewportMocks.viewport = "mobile"; + const response = buildResponse(); + const store = createStore(); + store.set(localeAtom, "en"); + store.set(connectionStatusAtom, "connected"); + store.set(wsClientAtom, { + sendCommand: vi.fn().mockResolvedValue(response), + subscribe: vi.fn(() => () => {}), + } as never); + + render( + + + + ); + + expect(await screen.findByRole("button", { name: "Configure monitoring" })).toBeInTheDocument(); + expect(document.querySelector(".settings-monitoring__dock--expanded")).toBeNull(); + }); +``` + +Add monitoring layout assertions to `packages/web/src/styles/components.theme.test.ts`: + +```ts + it("keeps the monitoring settings subpage on token-driven dashboard surfaces", () => { + const layout = getLastRuleBlock(".settings-monitoring__layout"); + const stage = getLastRuleBlock(".settings-monitoring__stage"); + const dock = getLastRuleBlock(".settings-monitoring__dock"); + const mobileDock = getLastRuleBlock("@media (max-width: 899px)", ".settings-monitoring__dock"); + + expect(layout).toContain("grid-template-columns: minmax(0, 1.7fr) minmax(280px, 0.95fr)"); + expect(stage).toContain("gap: var(--sp-4)"); + expect(dock).toContain("border: 1px solid var(--surface-elevated-border)"); + expect(dock).toContain("background: var(--surface-elevated)"); + expect(mobileDock).toContain("grid-column: 1"); + }); +``` + +- [ ] **Step 2: Run the monitoring subpage and theme tests to verify failure** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/settings/components/monitoring-settings-subpage.test.tsx \ + src/styles/components.theme.test.ts +``` + +Expected: +- FAIL because there is no mobile collapsed-dock behavior yet +- FAIL because `settings-monitoring__layout` and related selectors do not exist in the stylesheet + +- [ ] **Step 3: Implement the dashboard and responsive styles** + +Add this selector block to `packages/web/src/styles/components.css` near the existing monitoring rules: + +```css +.settings-monitoring { + display: grid; + gap: var(--sp-4); +} + +.settings-monitoring__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--sp-3); +} + +.settings-monitoring__layout { + display: grid; + grid-template-columns: minmax(0, 1.7fr) minmax(280px, 0.95fr); + gap: var(--sp-4); + align-items: start; +} + +.settings-monitoring__stage { + display: grid; + gap: var(--sp-4); +} + +.settings-monitoring__dock { + display: grid; + gap: var(--sp-3); + border: 1px solid var(--surface-elevated-border); + background: var(--surface-elevated); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-sm); + padding: var(--sp-4); +} + +.settings-monitoring__dock-entry { + display: none; +} + +@media (max-width: 899px) { + .settings-monitoring__layout { + grid-template-columns: minmax(0, 1fr); + } + + .settings-monitoring__dock { + grid-column: 1; + } + + .settings-monitoring__dock:not(.settings-monitoring__dock--expanded) { + display: none; + } + + .settings-monitoring__dock-entry { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-3); + border: 1px solid var(--surface-elevated-border); + background: var(--surface-elevated); + border-radius: var(--radius-lg); + padding: var(--sp-3); + } +} +``` + +Render the mobile entry card in `MonitoringSettingsSubpage`: + +```tsx + {isMobile && !dockExpanded ? ( + + ) : null} +``` + +Add locale keys: + +```json +"configure": "Configure monitoring" +``` + +and: + +```json +"configure": "配置监控" +``` + +- [ ] **Step 4: Run the style and monitoring subpage tests to verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/settings/components/monitoring-settings-subpage.test.tsx \ + src/styles/components.theme.test.ts +``` + +Expected: PASS with the desktop two-column layout and mobile collapsed-entry behavior locked down. + +- [ ] **Step 5: Commit the layout/style slice** + +```bash +git add \ + packages/web/src/styles/components.css \ + packages/web/src/styles/components.theme.test.ts \ + packages/web/src/features/settings/components/monitoring-settings-subpage.test.tsx \ + packages/web/src/locales/en.json \ + packages/web/src/locales/zh.json +git commit -m "feat: restyle monitoring settings subpage" +``` + +--- + +### Task 4: Verify The Full Web Slice + +**Files:** +- Test only; no new source files unless a regression is found during this step. + +- [ ] **Step 1: Run the full targeted web verification** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/settings/components/settings-page.test.tsx \ + src/features/settings/components/monitoring-settings-subpage.test.tsx \ + src/features/command-palette/components/command-palette.test.tsx \ + src/shells/desktop-shell.test.tsx \ + src/shells/mobile-shell/index.test.tsx \ + src/styles/components.theme.test.ts +``` + +Expected: PASS with no `/monitoring` route assumptions left. + +- [ ] **Step 2: Run the web typecheck** + +Run: + +```bash +pnpm --filter @coder-studio/web exec tsc -p tsconfig.json --noEmit +``` + +Expected: exit code `0`. + +- [ ] **Step 3: Commit the verification checkpoint** + +```bash +git add -A +git commit -m "test: verify monitoring settings subpage redesign" +``` + +Only include files touched by Tasks 1-3. If unrelated user files are still untracked, stage explicit paths instead of `-A`. + +--- + +## Self-Review + +- Spec coverage: the plan covers information architecture (`Task 1`), embedded monitoring composition (`Task 2`), and desktop/mobile visual redesign (`Task 3`), with explicit verification in `Task 4`. +- Placeholder scan: no `TODO`, `TBD`, or “similar to above” placeholders remain. +- Type consistency: the plan consistently uses `monitoring` as the new settings section id, `MonitoringSettingsSubpage` as the embedded component, and `/settings?section=monitoring` as the only navigation target. diff --git a/docs/superpowers/specs/2026-05-26-git-history-commit-file-diff-design.md b/docs/superpowers/specs/2026-05-26-git-history-commit-file-diff-design.md new file mode 100644 index 00000000..a7ec1b32 --- /dev/null +++ b/docs/superpowers/specs/2026-05-26-git-history-commit-file-diff-design.md @@ -0,0 +1,488 @@ +# Git History Commit File Diff Design + +> Date: 2026-05-26 +> Status: Approved for planning +> Scope: History commit file-list preview, per-file diff, and image diff hardening + +## 1. Goal + +Enhance the existing Git history surface so a user can click a historical commit, see the files changed by that commit, and then open a per-file diff from that list inside the same editor surface already used for normal file and diff review. + +This design also fixes a current image-diff weakness: when one side of the diff does not exist or the image asset cannot be loaded, the UI currently degrades into a broken image instead of a clear empty or error state. + +The result should make history review feel like an extension of the current editor workflow rather than a separate patch viewer. + +## 2. Current State + +Today the repo already has partial building blocks: + +- `git.log` returns recent history entries for the Git panel +- clicking a history row triggers `git.show` +- `git.show` returns a raw unified patch for the whole commit +- worktree file diffs already use structured payloads that can render in the shared editor surface +- image diffs already have a dedicated `ImageDiffPreview`, but it only handles the "URL missing" case well + +This leaves three product gaps: + +1. History review opens a whole-commit patch immediately instead of a changed-file list +2. History review cannot open structured per-file diffs through the shared editor pipeline +3. Image diff panes do not distinguish "no image on this side" from "image failed to load" + +## 3. In Scope + +- Clicking a history commit opens a commit-scoped changed-file list in the main preview/editor area +- Clicking a file inside that list opens a per-file diff for that historical commit +- Historical file diff rendering reuses the existing shared editor surface +- Text commit diffs render through the existing Monaco diff flow +- Image commit diffs render through the existing image diff flow +- Image diff panes show explicit empty/error states instead of broken images +- Server APIs and types needed to support structured historical commit review +- Regression coverage for desktop and mobile history review behavior + +## 4. Out of Scope + +This design does not include: + +- commit search, filtering, or pagination beyond the existing history limit model +- a side-by-side whole-commit patch viewer +- inline comments or review annotations +- blame, author drill-down, or parent/merge graph navigation +- arbitrary Git revision browsing outside recent commit history +- opening arbitrary non-image file bytes through `/api/file` +- free-form revision selectors such as `HEAD~1`, branch names, or object expressions on the file asset route + +## 5. Design Constraints + +- Reuse the existing editor shell and avoid adding a second runtime diff UI +- Avoid parsing raw patch text in the client for file list extraction +- Preserve current worktree diff behavior +- Preserve desktop and mobile "unified detail view" behavior already built around `gitDiffPreview` +- Keep image asset loading scoped and safe; do not turn `/api/file` into a general Git object browser +- Avoid regressions for commit previews that remain reachable without an active file + +## 6. Approaches Considered + +### 6.1 Structured Commit Detail + Structured Commit File Diff (Recommended) + +Add a commit-detail API that returns metadata plus changed files, and a commit-file-diff API that returns structured data matching the existing editor diff model. + +Advantages: + +- clean server/client contract +- no fragile patch parsing in the browser +- easy reuse of existing Monaco and image diff rendering +- rename handling can be explicit +- supports future extension without redesign + +Tradeoff: + +- requires new server commands, type additions, and editor-state expansion + +### 6.2 Structured Commit Detail + Raw Patch File Diff + +Return file lists structurally, but still return raw patch text for file diffs. + +Advantages: + +- slightly smaller server change + +Tradeoff: + +- text diff can work, but image/history parity with existing editor diff cannot +- likely creates follow-up rework + +### 6.3 Raw Patch Parsing in the Client + +Keep `git.show` and parse file sections in the web client. + +Advantages: + +- minimal server work + +Tradeoff: + +- brittle for rename, binary diffs, patch headers, and future maintenance +- duplicates Git parsing logic in the wrong layer + +### 6.4 Final Choice + +Use approach 6.1. + +The design should add explicit structured history-review APIs and route both worktree and commit diffs through the same editor experience. + +## 7. User Experience + +### 7.1 History Commit Entry + +When the user clicks a commit in the Git history section: + +- do not open the whole raw patch +- open a commit-scoped changed-file list in the main preview area +- show a header title based on `shortSha + subject` + +This preview becomes the root state for that commit review session. + +### 7.2 Commit File List + +The commit file list should show one row per changed file with: + +- filename +- parent directory +- status badge or semantic icon +- optional rename source path when relevant + +Expected statuses: + +- `added` +- `modified` +- `deleted` +- `renamed` + +The list exists inside the same main editor/detail surface used elsewhere in the workspace, not inside the left Git sidebar. + +### 7.3 Commit File Diff Navigation + +When the user clicks a file in the commit file list: + +- switch the right-side detail surface from `commit-file-list` to `commit-file-diff` +- render text diffs with the existing Monaco diff path +- render image diffs with the existing image diff path + +When the user closes a `commit-file-diff` preview: + +- return to the parent `commit-file-list` + +When the user closes the `commit-file-list` preview: + +- clear the history preview entirely + +This backstep behavior is required so history review feels navigable rather than disposable. + +### 7.4 Worktree Diff Parity + +Worktree file diffs should keep their current behavior, but share the same rendering path as historical commit file diffs. The editor surface should not need to care whether a diff came from the worktree or a historical commit beyond the preview kind and data source. + +## 8. State Model + +### 8.1 Single Preview Entry Point + +Keep `gitDiffPreview` as the single preview entry point for Git-related detail content. + +Do not create a second history-only preview atom or panel state model. + +### 8.2 Preview Kinds + +Expand the preview state into a discriminated union with explicit kinds: + +- `worktree-file-diff` +- `commit-file-list` +- `commit-file-diff` + +Representative shape: + +```ts +type GitDiffPreview = + | WorktreeFileDiffPreview + | CommitFileListPreview + | CommitFileDiffPreview; +``` + +`worktree-file-diff` keeps the existing current-file diff semantics. + +`commit-file-list` contains: + +- commit identity (`sha`, `shortSha`, `subject`) +- title for the editor header +- changed files array + +`commit-file-diff` contains: + +- commit identity +- parent commit identity if needed by image/history resolution +- file identity (`path`, `oldPath?`) +- structured diff payload for the editor renderer +- enough backlink context to return to the parent file list on close + +### 8.3 Editor Mode Interaction + +Do not expand `editorMode` into history-specific modes. + +The commit file list is not a Monaco mode; it is just another content branch rendered inside the existing editor shell. + +The editor surface should branch on preview kind: + +- file editor / preview behavior for normal files +- diff behavior for worktree file diffs +- list behavior for `commit-file-list` +- diff behavior for `commit-file-diff` + +## 9. Server Design + +### 9.1 New Command: `git.commitDetail` + +Input: + +- `workspaceId` +- `sha` + +Output: + +- `commit` + - `sha` + - `shortSha` + - `subject` + - `authorName` + - `authoredAt` + - `parentSha?` +- `files[]` + - `path` + - `oldPath?` + - `status` + - `renderAs` + +Purpose: + +- provide the changed-file list for a historical commit +- avoid browser-side patch parsing + +Implementation should derive this data directly from Git in the server layer. + +### 9.2 New Command: `git.commitFileDiff` + +Input: + +- `workspaceId` +- `sha` +- `path` +- `oldPath?` + +Output should align with the existing `git.diff` payload model as much as possible: + +- `renderAs` +- `status` +- `diff` +- `originalContent?` +- `modifiedContent?` +- `originalRevision?` +- `modifiedRevision?` +- `originalPath?` +- `modifiedPath?` + +Text files should provide `originalContent` and `modifiedContent` so Monaco can render the diff directly. + +Image files should provide enough revision/path metadata for the existing image diff component to build the correct `beforeUrl` and `afterUrl`. + +### 9.3 Rename Handling + +Rename support must be explicit. + +The file list should display the current path and preserve `oldPath` for context. The diff command should compare: + +- old path at the parent revision +- new path at the selected commit revision + +This avoids history diff failures caused by assuming the same path exists on both sides. + +## 10. File Asset Revision Model + +### 10.1 Current Limitation + +`/api/file` currently accepts `HEAD` and `INDEX` image revisions only. That is enough for worktree image diff, but not enough for historical commit image diff. + +### 10.2 Required Expansion + +Extend the image asset route to accept a restricted commit SHA selector in addition to `HEAD` and `INDEX`. + +Allowed revisions after this change: + +- `HEAD` +- `INDEX` +- full or short commit SHA that passes the same strict revision validation used by Git commands + +Explicitly do not allow: + +- `HEAD~1` +- branch names +- tags +- free-form object expressions +- arbitrary `rev:path` input + +This keeps the route narrow and predictable while still enabling historical image diff rendering. + +### 10.3 Historical Image Diff Resolution + +For historical image diffs: + +- base image should resolve from `oldPath or path` at `parentSha` +- current image should resolve from `path` at `sha` + +For an added image: + +- base side should be absent +- current side should point at `path + sha` + +For a deleted image: + +- base side should point at `oldPath or path + parentSha` +- current side should be absent + +## 11. Image Diff Hardening + +### 11.1 Current Problem + +`ImageDiffPreview` currently handles a missing URL by rendering a simple empty state, but a URL that resolves to an error still results in a broken image element. This is especially visible when one side of a historical or worktree image diff no longer exists. + +### 11.2 Required Behavior + +Each image diff pane should manage three distinct states: + +1. `empty` + - no URL should exist for this side +2. `loaded` + - image loaded successfully +3. `error` + - URL exists but the image could not be loaded + +### 11.3 Pane Messaging + +Expected pane copy: + +- added image, base side missing: `No base image` +- deleted image, current side missing: `No current image` +- URL present but failed to load: `Preview unavailable` + +The failure copy can share the same visual treatment already used by `ImagePreview`. + +### 11.4 Styling + +Keep the current image-diff panel layout and visual framing, but ensure empty and error states fill the pane cleanly instead of exposing browser-native broken image UI. + +This is a correctness and polish fix, not a layout redesign. + +## 12. Frontend Rendering Plan + +### 12.1 Shared Editor Surface + +The main runtime path should move fully onto the shared editor surface used by `CodeEditorHost` and `EditorSurface`. + +Historical commit review should not depend on the legacy raw patch viewer for the main application flow. + +### 12.2 Commit File List Renderer + +Add a commit-file-list content renderer inside the shared editor surface. It should reuse existing row semantics where practical: + +- path splitting logic +- semantic icons +- keyboard-accessible row buttons + +### 12.3 Legacy Raw Patch Viewer + +`GitDiffViewer` should no longer be the primary runtime path for commit history review. + +After the new flow lands: + +- either keep it only for UI preview scenes +- or remove it once no runtime path depends on it + +The important constraint is avoiding two parallel runtime diff experiences. + +## 13. Testing Strategy + +### 13.1 Server Coverage + +Add tests for: + +- `git.commitDetail` returning file lists and commit metadata +- `git.commitDetail` including rename information +- `git.commitFileDiff` returning text diff content +- `git.commitFileDiff` returning image diff metadata +- `/api/file` allowing restricted commit SHA revisions +- `/api/file` rejecting invalid revision selectors + +### 13.2 Frontend Coverage + +Add tests for: + +- clicking a history commit opens a commit file list +- clicking a file opens a commit file diff +- closing a commit file diff returns to the parent commit file list +- closing a commit file list clears the preview +- commit previews remain reachable without an active file +- mobile detail views continue to route history previews through the same unified surface + +### 13.3 Image Diff Coverage + +Add tests for: + +- added image shows `No base image` +- deleted image shows `No current image` +- load failure shows `Preview unavailable` +- no broken-image fallback leaks into the rendered UI state + +## 14. Risks and Mitigations + +### Risk 1: Preview state becomes harder to reason about + +Mitigation: + +- use an explicit discriminated union for `gitDiffPreview` +- keep one preview entry point instead of multiple partially overlapping atoms + +### Risk 2: Historical image diff broadens file-route attack surface + +Mitigation: + +- accept only image paths +- allow only `HEAD`, `INDEX`, or strict commit SHA revisions +- reuse existing workspace path safety checks + +### Risk 3: Runtime diff UI forks between old and new viewers + +Mitigation: + +- route the new feature through the shared editor surface only +- shrink `GitDiffViewer` to non-runtime usage or remove it after migration + +### Risk 4: Rename handling fails for historical paths + +Mitigation: + +- include `oldPath` in commit detail results +- compare parent revision old path against commit revision new path explicitly + +## 15. Implementation Order + +Recommended implementation sequence: + +1. extend core Git preview types for commit file list/diff states +2. add server Git helpers plus `git.commitDetail` +3. add `git.commitFileDiff` +4. extend `/api/file` to accept restricted commit SHA image revisions +5. add frontend commit-file-list preview rendering +6. route commit-file-diff through the shared editor surface +7. harden image diff pane empty/error states +8. retire or reduce the runtime role of `GitDiffViewer` +9. run focused and regression tests + +This order keeps the data contract in place before the UI depends on it. + +## 16. Verification Expectations + +Before implementation is considered complete, verification should include: + +- focused server tests for Git commands and file asset revisions +- focused web tests for Git panel, editor surface, and image diff behavior +- lint on touched files +- relevant full test suites for server and web layers if the targeted tests pass + +## 17. Summary + +This design replaces the current whole-commit raw patch jump with a structured, editor-native history review flow: + +- click commit +- inspect changed files +- open a file diff in the existing editor surface + +It also hardens image diff behavior so missing or failed image sides produce explicit UI states rather than broken images. + +The core decision is to keep one Git preview system and make history review a first-class extension of it, not a separate viewer. diff --git a/docs/superpowers/specs/2026-05-27-diagnostics-monitoring-layout-refinement-design.md b/docs/superpowers/specs/2026-05-27-diagnostics-monitoring-layout-refinement-design.md new file mode 100644 index 00000000..1a191467 --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-diagnostics-monitoring-layout-refinement-design.md @@ -0,0 +1,274 @@ +# 诊断页监控区布局与排版收敛 · 设计文档 + +> **版本:** 1.0 +> **日期:** 2026-05-27 +> **状态:** Draft(待评审) +> **作者:** Codex + +## 0. 目标与范围 + +### 0.1 目标 + +优化诊断页中的监控区体验,解决两个直接问题: + +- 监控设置与监控面板当前是分裂的上下 / 双区结构,视觉重心不稳定 +- 标题和说明文字偏大,层级不够克制,整体不像紧凑的后台诊断工具 + +本轮目标是在不改监控数据结构和后端接口的前提下,把监控区重构为“头部一行设置 + 下方监控面板”的统一界面,并收敛字体层级。 + +### 0.2 用户确认方向 + +本轮已与用户确认以下设计方向: + +- 设置放在头部一行,监控面板放在下面 +- 桌面端和移动端一起调整 +- 整体观感偏更紧凑的后台工具风格,但以可读性优先 +- 不需要视觉 companion,使用文本方案推进 + +### 0.3 范围 + +包含: + +- `MonitoringSettingsSubpage` 的结构重排 +- `MonitoringSettingsCard` 的展示方式调整 +- 诊断页 / 设置页共享监控区的排版和字号收敛 +- 桌面端与移动端的响应式布局调整 +- 相关前端测试更新 + +不包含: + +- 监控 websocket 命令、采样逻辑或响应结构调整 +- 新增监控指标、图表或告警能力 +- 诊断页其余非监控模块的信息架构重做 +- 全站字体 token 体系调整 + +## 1. 现状与问题 + +相关实现主要位于: + +- `packages/web/src/features/settings/components/monitoring-settings-subpage.tsx` +- `packages/web/src/features/settings/components/monitoring-settings-card.tsx` +- `packages/web/src/features/monitoring/page.tsx` +- `packages/web/src/styles/components.css` + +当前问题分为三类。 + +### 1.1 结构分裂 + +当前监控区由两个强视觉容器组成: + +- `stage`:监控数据面板 +- `dock`:监控设置卡或移动端折叠入口 + +桌面端是双栏并置,移动端是“先看数据,再打开设置入口卡”。这使得设置和监控像两个并列模块,而不是一个统一的监控工作区。 + +### 1.2 标题层级重复 + +当前同时存在多组高强调标题: + +- `stage title` +- `dock title` +- 各数据卡标题 +- 归因树 / 详情面板 / 子进程钻取标题 + +它们的字号和权重过于接近,导致每块都像主标题,页面缺少明确的阅读锚点。 + +### 1.3 移动端配置入口冗余 + +移动端当前使用一个单独的“打开监控配置”入口卡。它虽然可用,但会打断同页操作链路,也让用户先看到一个配置入口,再回到数据,交互不够直接。 + +## 2. 设计目标 + +- 把监控设置收敛到监控区头部,不再形成独立大侧栏或入口卡 +- 让监控面板成为页面主体,保持“控制在上,数据在下”的单列认知 +- 降低重复标题权重,让数据卡标题成为主要阅读层级 +- 统一桌面端与移动端的布局原则,只在密度上响应式退化 +- 保持现有设置模型与可访问性查询方式尽可能稳定 + +## 3. 方案选择 + +### 3.1 方案 A:顶部紧凑控制条 + 下方监控面板(采用) + +结构: + +- 顶部统一控制条 +- 下方完整监控面板 +- 细项开关收进同一控制区内的“高级设置” + +优点: + +- 最符合用户“设置放头部一行”的目标 +- 监控数据成为页面主舞台 +- 桌面和移动端可以共享同一信息结构 + +代价: + +- 高级设置多一次展开动作 + +### 3.2 方案 B:所有设置项都常驻头部一行 + +优点: + +- 所有控制一眼可见 + +缺点: + +- 桌面端头部过满 +- 移动端只能依赖大面积横向滚动 +- 字体和层级更难收敛 + +### 3.3 方案 C:仅调整顺序,不改变设置卡形态 + +优点: + +- 改动最小 + +缺点: + +- 仍保留“大设置卡 + 大监控卡”的旧观感 +- 不能彻底解决结构分裂问题 + +### 3.4 结论 + +采用 **方案 A:顶部紧凑控制条 + 下方监控面板**。 + +## 4. 最终结构 + +### 4.1 页面骨架 + +监控区统一改成单列堆叠: + +1. `monitoring control bar` +2. `monitoring dashboard` + +不再保留当前的: + +- 桌面端 `stage + dock` 双区并列 +- 移动端“打开监控配置”的入口卡 + +### 4.2 头部控制条 + +默认显示高频控制: + +- `启用监控` +- `预设` +- `刷新频率` +- `高级设置` 展开入口 + +控制条在桌面端尽量单行铺开;在移动端允许换行,但顺序保持一致。 + +### 4.3 高级设置 + +低频细项收纳到同一个可展开的“高级设置”区域中,仍位于头部控制区内部,不形成独立大卡片。 + +包含开关: + +- `主机指标` +- `运行时概览` +- `工作区与会话归因` +- `子进程钻取` + +### 4.4 监控面板 + +控制条下方保留现有监控面板主体,顺序继续以数据优先为原则: + +- 总览卡片 +- 归因树 +- 详情面板 +- 子进程钻取 + +若监控关闭,控制条仍固定显示在上方;下方监控面板展示禁用空态,不要求用户切换到另一个区域再打开监控。 + +`刷新` 按钮和 `最后更新时间` 继续保留在监控面板工具栏中,不并入头部控制条。 + +## 5. 排版与层级 + +### 5.1 字体策略 + +沿用现有字体栈与 token,不新增全局排版规范: + +- Sans:`IBM Plex Sans`, `PingFang SC`, `Microsoft YaHei`, `Noto Sans SC` +- 排版 token:继续使用现有 `type-heading-*` 与 `type-body-*` + +问题不在字库,而在当前局部层级使用过重。 + +### 5.2 层级收敛原则 + +- 页面主标题保持不变 +- 监控区顶部说明降级为轻量区块说明,不再使用接近页面级的标题强调 +- 数据卡标题成为这一屏的主阅读锚点 +- 归因树、详情面板、子进程钻取与总览卡标题保持同级 +- 描述、状态、辅助信息统一降一档 + +### 5.3 具体排版约束 + +- `stage title / dock title` 这类局部标题不再使用当前高强调样式 +- 顶部控制条标签使用 `body-6` / `body-5` 级别 +- 控制条说明文字使用 `body-5` 或 `body-4` +- 数据卡标题控制在 `14px` 到 `16px` 的观感区间 +- 描述文案尽量落在 `12px / 13px` +- 时间、状态、辅助标签统一落在 `11px / 12px` + +结果目标是: + +- 标题数量不变,但视觉上只有一层真正醒目的标题 +- 数值与监控内容本身比文案更突出 + +## 6. 桌面端与移动端行为 + +### 6.1 桌面端 + +- 头部控制条优先单行显示 +- 高级设置展开后在控制条下方出现内部折叠区 +- 下方监控面板保持宽阔数据阅读区,不再被右侧设置卡分走横向注意力 + +### 6.2 移动端 + +- 不再显示旧的配置入口卡 +- 直接展示与桌面端同构的头部控制区 +- 宽度不足时允许控制条换行 +- 高级设置仍以内联折叠区形式展开,不跳出、不切屏 + +## 7. 实现边界 + +本轮尽量只修改以下前端边界: + +- `packages/web/src/features/settings/components/monitoring-settings-subpage.tsx` +- `packages/web/src/features/settings/components/monitoring-settings-card.tsx` +- `packages/web/src/features/monitoring/page.tsx` +- `packages/web/src/styles/components.css` +- 相关测试文件 + +不修改: + +- `monitoring.get` / `monitoring.recheck` 命令 +- `MonitoringResponse` / `MonitoringSettings` 数据模型 +- 后端监控聚合与历史逻辑 + +## 8. 测试要求 + +需要覆盖的回归点: + +- 桌面端控制条位于监控面板上方,旧 `dock` 结构不再作为首层布局存在 +- 移动端不再出现“打开监控配置”入口卡 +- `高级设置` 可展开并显示 4 个细项开关 +- `启用监控`、`预设`、`刷新频率` 等关键控件仍可通过现有无障碍角色查询命中 +- 监控关闭时控制条仍可直接操作,下方展示空态 + +## 9. 风险与取舍 + +- 将细项开关折叠进高级设置后,少数低频控制会多一次点击 +- 需要谨慎处理桌面 / 移动端共享结构,避免让控制条在小屏上过度拥挤 +- 需要同步更新与旧 `dock` / `mobile entry` 结构耦合的测试,避免把旧布局假设遗留在回归集中 + +## 10. 结论 + +本轮不重做监控能力本身,只重构其在诊断页 / 设置页中的界面组织方式。 + +最终交付是一个更统一、更紧凑的监控区: + +- 设置在上 +- 数据在下 +- 高级项内收 +- 标题降级 +- 桌面与移动端遵循同一结构原则 diff --git a/docs/superpowers/specs/2026-05-27-settings-monitoring-subpage-redesign-design.md b/docs/superpowers/specs/2026-05-27-settings-monitoring-subpage-redesign-design.md new file mode 100644 index 00000000..b5c66beb --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-settings-monitoring-subpage-redesign-design.md @@ -0,0 +1,560 @@ +# 设置 > 监控 子页重做 · 设计文档 + +> **版本:** 1.0 +> **日期:** 2026-05-27 +> **状态:** Draft(待评审) +> **作者:** Codex + +--- + +## 0. 文档说明 + +### 0.1 目标 + +重做 `Settings > Monitoring` 子页,把当前“监控设置混在 General、监控数据单独占一个页面”的分裂结构,收敛为一个设置内部的独立监控子页。 + +本轮目标不是扩展新的监控能力,也不是重做整个设置页,而是让监控在信息架构、视觉层级和桌面 / 移动端使用路径上都回到同一个产品面。 + +### 0.2 用户确认方向 + +本轮已和用户确认以下设计方向: + +- 不保留独立 `/monitoring` 页面 +- 监控只保留为设置里的独立子项 +- 进入监控子页后,先看到实时监控数据,再在同页调设置 +- 采用更强的 dashboard 化方向重做这个子页 +- 桌面端采用 `B` 方案:左侧数据主舞台,右侧配置 dock +- 要特别注意移动端样式和布局退化 + +### 0.3 本轮范围 + +包含: + +- 设置导航中新增 `Monitoring` 子项 +- 删除 `General` 中现有监控设置块 +- 删除独立 `/monitoring` 路由入口 +- 将监控展示与监控设置融合为单一设置子页 +- 桌面端和移动端的监控子页重做 +- 与本轮结构变化直接相关的组件拆分、样式调整和测试更新 + +不包含: + +- 监控服务端采样逻辑调整 +- 监控指标种类扩展 +- 设置页其他子项的整体重做 +- 诊断页、工作区页或顶部导航的系统级重构 +- 新增监控告警、控制动作或历史持久化 + +--- + +## 1. 现状与问题 + +当前监控能力分裂在两个地方: + +- `General` 中挂着 `MonitoringSettingsCard` +- Web 端另有独立的 `/monitoring` 页面负责展示监控数据 + +相关实现主要位于: + +- `packages/web/src/features/settings/components/settings-page.tsx` +- `packages/web/src/features/settings/components/monitoring-settings-card.tsx` +- `packages/web/src/features/monitoring/page.tsx` +- `packages/web/src/shells/desktop-shell.tsx` +- `packages/web/src/shells/mobile-shell/index.tsx` + +当前问题集中在四类: + +### 1.1 信息架构割裂 + +用户要理解监控功能,必须在两个地方来回切: + +- 在 `General` 里找开关和采样配置 +- 再进入独立监控页看结果 + +这破坏了“一个设置项对应一个可理解能力面”的结构规律,也让监控显得像附加功能而不是正式设置分区。 + +### 1.2 `General` 被非通用能力污染 + +`General` 应该承载通知、终端行为、运行偏好这类通用项,但监控已经具备: + +- 独立的开关组 +- 独立的状态语义 +- 独立的数据展示界面 + +继续放在 `General` 会让该分区边界变得模糊,也让移动端首页分组语义失真。 + +### 1.3 独立监控页与设置体系脱节 + +当前 `/monitoring` 是完整整页: + +- 有自己的一套页头 +- 有自己的一套内容壳 +- 与设置页没有层级连续性 + +结果是监控展示虽然能用,但不像“设置中的监控”,更像另一个工具页。 + +### 1.4 当前监控页样式仍偏功能可用,不够像产品级控制面板 + +现有监控页已经具备指标卡、趋势、归因与详情,但视觉结构仍偏平: + +- 数据区和配置区没有同页统一编排 +- 配置入口弱,切换设置和看数据之间仍存在页面级跳转感 +- 移动端只做了基础可用,没有形成“数据优先、配置后置”的清晰主次 + +--- + +## 2. 设计目标与非目标 + +### 2.1 设计目标 + +- 让监控成为设置里的正式独立子项,而不是 General 中的一块附属卡片 +- 在单一子页内同时承载“看数据”和“调设置” +- 进入子页后先看到监控数据,再在同页快速调整采样配置 +- 桌面端建立明确的双区结构:数据主舞台 + 配置 dock +- 移动端优先保障首屏数据阅读,再把配置区有控制地折叠 / 前置 +- 保留现有监控状态语义,但把视觉语言提升为更完整的 dashboard 子页 + +### 2.2 非目标 + +- 不改变监控数据来源、采样算法和设置模型 +- 不新增复杂图表体系 +- 不把监控子页做成工作区常驻面板 +- 不在本轮加入 stop session / stop workspace 等控制动作 +- 不重做整个设置页的全局样式系统 + +--- + +## 3. 方案选择 + +### 3.1 方案 A:顶部总览 + 底部配置 + +保留监控页主体,把配置区整体下移到页面底部。 + +优点: + +- 实现风险较低 +- 数据浏览顺序自然 + +缺点: + +- 设置入口离数据区太远 +- 不够像“监控工具台” + +### 3.2 方案 B:左侧数据主舞台 + 右侧配置 dock(采用) + +桌面端用双区布局: + +- 左侧承载总览、趋势、归因和详情 +- 右侧承载监控配置和状态说明 + +移动端收敛为单列,但保持“数据优先、配置后置”的同一原则。 + +优点: + +- 最符合用户确认的 dashboard 化方向 +- 数据和配置既在同一页,又有明确主次 +- 桌面端控制效率最高 + +缺点: + +- 需要重构现有监控页容器和设置卡片结构 +- 移动端需要单独设计折叠行为,不能直接照搬桌面布局 + +### 3.3 方案 C:分段式叙事页 + +把监控页编排成多个连续章节,配置区作为中后段出现。 + +优点: + +- 信息层级清楚 +- 移动端较容易收束 + +缺点: + +- 操作效率不如双区布局 +- 产品感更像报告页,不够像控制面板 + +### 3.4 结论 + +本轮采用 **方案 B:左侧数据主舞台 + 右侧配置 dock**。 + +原因: + +- 用户已明确选择该方向 +- 它最能表达“监控既是数据页,也是设置子页”的双重身份 +- 相比方案 A / C,更适合把当前分裂的监控体验收敛成一个完整能力面 + +--- + +## 4. 信息架构 + +### 4.1 设置层级调整 + +设置页新增独立 section: + +- `monitoring` + +并将导航顺序调整为: + +1. `general` +2. `monitoring` +3. `providers` +4. `appearance` +5. `shortcuts` +6. `about` + +原则: + +- `general` 保持通用设置属性 +- `monitoring` 独立承接监控能力 +- 不再把监控内容混入通用项 + +### 4.2 路由策略 + +本轮删除独立 `/monitoring` 页面入口,不保留兼容跳转。 + +监控的唯一进入方式改为: + +- `/settings?section=monitoring` + +所有原“打开监控”的跳转都更新为该设置深链接。 + +### 4.3 移动端设置首页 + +移动端设置首页把监控作为独立入口项展示,不并入 `General` 内容。 + +它仍属于设置目录的一部分,而不是额外的工作台入口。 + +--- + +## 5. 最终页面结构 + +### 5.1 桌面端结构 + +桌面端监控子页采用两栏布局: + +1. 左侧 `Data Stage` +2. 右侧 `Control Dock` + +左侧承担主阅读路径,右侧承担配置和状态管理。 + +整体结构: + +- 监控子页标题区 +- 数据主舞台 + - 顶部工具条 + - 关键指标卡 + - 趋势总览 + - 归因列表 + - 实体详情 +- 配置 dock + - 启用状态与模式摘要 + - 监控开关与采集层设置 + - 采样频率与说明 + - 禁用 / 降级提示 + +### 5.2 移动端结构 + +移动端改为单列堆叠,但保持同样的信息顺序: + +1. 标题区 +2. 数据总览卡 +3. 趋势总览 +4. 归因列表 +5. 详情区 +6. 配置入口卡 / 配置面板 + +移动端核心规则: + +- 默认先看数据 +- 配置区默认折叠为一个入口卡 +- 若监控当前处于禁用态,配置区自动前置并展开 + +### 5.3 标题区 + +监控子页不再使用现有独立 `MonitoringPage` 页头,而是在设置内容区内部使用局部标题区。 + +标题区承载: + +- 子页标题 +- 当前更新时间 +- 刷新按钮 +- 时间窗口切换(`5m / 15m / 30m`) + +目的: + +- 去掉“页面里再套一个页面”的感觉 +- 与设置子页层级保持一致 + +--- + +## 6. 数据区设计 + +### 6.1 关键指标卡 + +桌面端首屏使用一排关键指标卡,优先展示: + +- CPU / 压力 +- 内存 +- 受管进程数 +- 当前监控模式 / 刷新频率 + +要求: + +- 比当前监控卡更紧凑 +- 强化数字与状态标签的对比 +- 保持与设置页已有表面层级兼容 + +移动端退化为双列网格。 + +### 6.2 趋势总览 + +趋势区继续承载短时历史曲线,但它从“独立页面中的一张卡”升级为监控主舞台的第二层。 + +要求: + +- 在桌面端保持横向展开感 +- 在移动端压缩为单张紧凑卡 +- 与时间窗口切换直接形成上下文关系 + +### 6.3 归因列表与详情区 + +桌面端使用并列结构: + +- 左侧归因列表 +- 右侧选中实体详情 + +列表继续支持: + +- workspace +- session +- subprocess group + +详情区继续展示: + +- CPU +- Memory +- Process count +- Uptime + +移动端退化为: + +- 先看归因列表 +- 再看选中实体详情 + +### 6.4 禁用态与无数据态 + +禁用态不再呈现“空页面 + 去设置按钮”的整页空态。 + +改为: + +- 桌面端左侧显示精简说明卡,右侧配置 dock 作为主入口 +- 移动端直接前置展开配置区,并把启用说明放在数据区之前 + +无数据 / 等待 / 降级态则继续保留现有状态语义,但表现为局部 notice 和状态卡,而不是整页级错误页。 + +--- + +## 7. 配置 dock 设计 + +### 7.1 角色定位 + +当前 `MonitoringSettingsCard` 不再作为普通设置卡嵌在 General 中,而是升级为监控子页右侧 `Control Dock` 的核心内容。 + +这个 dock 负责承载: + +- 监控总开关 +- 预设模式 +- Host / Runtime / Attribution / Subprocess 开关 +- 采样频率 +- 禁用说明与降级提示 + +### 7.2 桌面端表现 + +桌面端配置区始终可见,但不要求做真正的全程 sticky/fixed。 + +设计要求: + +- 首屏内稳定存在 +- 视觉上比普通设置卡更像控制台侧栏 +- 不压过左侧数据区主次 + +建议特征: + +- 更完整的容器感 +- 更清晰的模块分段 +- 弱渐变 / 材质表面,而不是单纯平面卡 + +### 7.3 移动端表现 + +移动端配置区默认折叠成一个入口卡: + +- 文案提示可调监控设置 +- 点击后展开完整控制面板 + +若监控被禁用: + +- 入口卡自动转为默认展开 +- 让用户不需要先滚动或猜测配置在哪里 + +### 7.4 状态提示 + +配置区顶部需要有简洁状态摘要: + +- 已启用 / 已关闭 +- 当前模式(light / standard / deep) +- 当前刷新频率 + +其目的不是重复左侧数据,而是帮助用户在调整设置前先确认当前运行态。 + +--- + +## 8. 组件与代码结构 + +### 8.1 设置页层面 + +需要调整: + +- `packages/web/src/features/settings/components/settings-sections.tsx` +- `packages/web/src/features/settings/components/settings-page.tsx` + +主要变化: + +- 新增 `monitoring` section +- 从 `GeneralSettings` 中移除监控相关 props 与渲染 +- 新增 `MonitoringSettingsSubpage` 作为设置内容分支 + +### 8.2 监控组件拆分 + +当前 `packages/web/src/features/monitoring/page.tsx` 需要从“整页组件”重构为“可嵌入设置内容区的监控子页组件树”。 + +建议拆分为: + +- `MonitoringSettingsSubpage` + - 负责设置内监控子页整体编排 +- `MonitoringToolbar` + - 标题、刷新、时间窗口、状态摘要 +- `MonitoringDashboard` + - 指标卡、趋势、归因列表、详情区 +- `MonitoringControlDock` + - 配置与说明 + +### 8.3 逻辑复用原则 + +保留现有可复用逻辑: + +- 监控数据加载与订阅 +- 排序逻辑 +- 时间窗口过滤 +- 格式化方法 +- sparkline 组件 + +重构重点在: + +- 去页头化 +- 去独立路由化 +- 去 General 内嵌卡片化 +- 重新组织渲染层级与样式类名 + +--- + +## 9. 样式策略 + +### 9.1 样式原则 + +本轮不新起独立页面壳,而是在设置内容区内扩展监控子页的局部视觉系统。 + +要求: + +- 延续设置页已有的圆角、边框和表面语气 +- 允许监控子页比普通设置分区更强一点,但不能像完全独立应用 +- 去掉当前监控页“整页工具页”的观感 + +### 9.2 类名策略 + +建议新增一组明确的子页类名,例如: + +- `settings-monitoring` +- `settings-monitoring__header` +- `settings-monitoring__layout` +- `settings-monitoring__stage` +- `settings-monitoring__dock` +- `settings-monitoring__overview` +- `settings-monitoring__detail` + +可以继续复用已有通用类的内容块,但新布局不要继续完全依赖旧的 `.monitoring-page` 语义。 + +### 9.3 响应式规则 + +桌面端: + +- 使用双栏布局 +- 数据区更宽,配置区较窄 + +移动端: + +- 全部收为单列 +- 配置区默认折叠 +- 指标卡改为两列 +- 列表与详情顺序串联 + +--- + +## 10. 测试与验收 + +### 10.1 需要更新的测试面 + +至少覆盖以下四类回归: + +1. 设置导航可进入 `monitoring` +2. `General` 不再渲染监控设置块 +3. `/monitoring` 不再作为有效页面存在 +4. 监控子页在桌面端和移动端都按新结构渲染 + +### 10.2 建议补充的行为测试 + +- `Open monitoring` 相关操作跳到 `/settings?section=monitoring` +- 桌面端存在左侧数据主舞台和右侧配置 dock +- 移动端默认先显示数据,再显示折叠配置入口 +- 禁用态下配置区前置展开 +- 数据加载失败时仍维持设置页壳,不回退到旧整页错误态 + +### 10.3 验收标准 + +- 监控不再出现在 `General` +- 设置导航中能直接找到独立的“监控”子项 +- 打开监控子页后,用户首屏优先看到数据,而不是先看到设置表单 +- 桌面端监控页明显呈现“数据区 + 配置区”的 dashboard 结构 +- 移动端不会把桌面双栏硬压成拥挤长页,配置区行为清晰 +- 原有监控数据与设置能力保持可用 + +--- + +## 11. 风险与实现注意点 + +### 11.1 风险 + +- 当前 `MonitoringPage` 逻辑和整页容器耦合较深,拆分时容易把状态判断和页面壳混在一起 +- 删除独立 `/monitoring` 路由后,原测试与入口代码会出现系统性回归 +- 如果直接复用旧样式类,容易留下“设置页里嵌一个旧监控页”的拼接感 + +### 11.2 注意点 + +- 优先抽出监控数据面板,而不是把整页组件强行塞进设置页 +- 先处理信息架构,再处理样式强化,避免视觉先行导致结构混乱 +- 移动端配置区折叠必须和禁用态联动,否则用户会进入“无数据也找不到开关”的死角 + +--- + +## 12. 最终结论 + +本轮将监控从“General 中的设置卡 + 独立 `/monitoring` 页面”重构为单一的 `Settings > Monitoring` 子页。 + +最终方向是: + +- 信息架构上独立成设置子项 +- 视觉上采用 dashboard 化的设置子页 +- 桌面端为左侧数据主舞台 + 右侧配置 dock +- 移动端为数据优先、配置后置且禁用态前置展开 + +这能在不改变监控核心能力的前提下,显著提升监控功能的可理解性、一致性和产品完成度。 diff --git a/docs/superpowers/specs/2026-05-27-skill-library-management-design.md b/docs/superpowers/specs/2026-05-27-skill-library-management-design.md new file mode 100644 index 00000000..76a60780 --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-skill-library-management-design.md @@ -0,0 +1,644 @@ +# Skill Library Management — Design + +Date: 2026-05-27 +Status: Draft +Owner: spencer + +## Problem + +当前产品已经能管理 workspace、provider、LSP 工具和系统依赖,但还没有一条正式的 skill 管理链路: + +- 没有统一的公共 skill 库 +- 没有把一个 skill 挂到多个 agent 指定目录的能力 +- 没有为 built-in provider 与 custom provider 统一管理 skill 目录 +- 没有在工作区右侧侧栏中提供 skill 搜索、安装、挂载和修复入口 + +用户目标不是“把某个 skill 安装进某个固定 agent 目录”,而是: + +- 对接 `skills-hub` 做在线搜索、详情查看与安装 +- 先把 skill 安装到产品维护的公共目录 +- 再把同一个 skill 挂载到不同 agent 的 skill 目录 +- built-in agent 与 custom agent 都能单独配置 skill 目录 +- 产品层对用户暴露的是“挂载”语义,而不是要求用户理解 `symlink`、复制或同步实现差异 + +仓库当前已经有几块可复用能力,但都不能直接解决这个问题: + +- 右侧工作区侧栏目前只有 `文件 / 搜索 / Git` + - [`packages/web/src/features/workspace/views/shared/workspace-activity-bar.tsx`](../../../packages/web/src/features/workspace/views/shared/workspace-activity-bar.tsx) + - [`packages/web/src/features/workspace/atoms/layout.ts`](../../../packages/web/src/features/workspace/atoms/layout.ts) +- provider 安装、LSP 安装、系统依赖安装都已经验证了 “manager + structured job + command polling” 模式 + - [`packages/server/src/provider-runtime/install-manager.ts`](../../../packages/server/src/provider-runtime/install-manager.ts) + - [`packages/server/src/lsp-tools/install-manager.ts`](../../../packages/server/src/lsp-tools/install-manager.ts) + - [`packages/server/src/system-deps/install-manager.ts`](../../../packages/server/src/system-deps/install-manager.ts) +- built-in provider 与 custom provider 已经都在同一条 provider registry 中暴露给前端 + - [`packages/server/src/commands/provider.ts`](../../../packages/server/src/commands/provider.ts) + - [`packages/server/src/commands/custom-provider.ts`](../../../packages/server/src/commands/custom-provider.ts) + +因此缺口不是“少一个按钮”,而是缺少一个新的 `skills` 领域:它要同时管理远程来源、本地公共库、agent target 配置、挂载关系、健康检查和右侧面板交互。 + +## Goals + +- 在桌面 workspace 右侧活动栏新增 `Skills` 面板,与 `文件 / 搜索 / Git` 同级。 +- 引入应用级全局唯一的公共 skill 库,作为 skill 的唯一事实源。 +- 对接 `skills-hub` 官方 CLI,支持搜索、详情查看、安装和卸载。 +- 支持 built-in provider 与 custom provider 配置各自的 skill 目录。 +- 支持把公共库中的 skill 显式挂载到一个或多个 agent 目录。 +- 产品层暴露统一的“挂载”语义;底层默认优先 `symlink`,失败时允许退回 `copy/sync`。 +- 提供结构化安装 job、挂载状态、健康检查和修复动作。 + +## Non-Goals + +- v1 不做 workspace 级隔离;公共库和挂载配置都是应用级全局唯一。 +- v1 不做 skill bundle、org/team 安装流或复杂权限模型。 +- v1 不做多版本并存;同一 `slug` 在公共库中只保留一个当前版本。 +- v1 不把 skill 管理合并进 provider 设置页或 diagnostics 页。 +- v1 不尝试从 agent 原生目录反向推断全部状态;公共库索引和挂载关系表才是正式状态源。 +- v1 不做后台自动更新所有 skill。 + +## User Decisions Captured + +- 入口在工作区右侧活动栏,同级于 `文件 / 搜索 / Git`。 +- 默认视角是“全局技能库”,不是“按 agent 进入”。 +- 公共 skill 库是应用级全局唯一,不按 workspace 分片。 +- built-in provider 与 custom provider 都需要支持自定义 skill 目录。 +- 产品内直接提供 `skills-hub` 搜索、详情和安装,不是半托管模式。 +- 用户面对的是“挂载”语义;底层优先 `symlink`,失败可降级为复制/同步。 +- 本地自定义 skill 与已有 skill 未来可以纳入同一套管理,但 v1 以 `skills-hub + 公共库 + 挂载` 为主路径。 + +## Approaches Considered + +### Option A: 中心公共库 + agent 挂载投影(推荐) + +核心思路: + +- 产品维护一个公共 skill 库,作为唯一事实源。 +- `skills-hub` 负责远程搜索和内容获取。 +- 每个 agent 只保存自己的 `skillDir` 配置和挂载关系。 +- 挂载层负责把公共库中的 skill 投影到 agent 目录。 + +优点: + +- 符合产品目标:一个 skill 可以被多个 agent 共用。 +- built-in 与 custom provider 可以用统一模型管理。 +- 容易做健康检查、批量重挂载、异常修复和后续权限治理。 +- 不依赖某个 agent 的目录成为“主目录”。 + +缺点: + +- 需要新增公共库索引、target 配置、挂载关系和 manager。 +- 服务端职责比“直接跑官方 CLI”更重。 + +### Option B: 选择某个 agent 目录充当公共库 + +核心思路: + +- 让某个 agent 的官方 skills 目录兼任公共库。 +- 其他 agent 从这个目录做同步或链接。 + +优点: + +- 初看实现较短。 + +缺点: + +- 架构上把“公共库”绑死到某个 provider 的私有格式。 +- custom provider 很难获得干净的一致体验。 +- 后续修复和健康检查会混入 provider 特有约束。 + +### Option C: 让官方 CLI 输出位置成为事实源,我们只做扫描 + +核心思路: + +- 仍以 `skills-hub` 默认安装位置为正式来源。 +- 产品只做扫描、展示和辅助同步。 + +优点: + +- 产品实现最轻。 + +缺点: + +- 不满足“安装到公共目录再管理”的目标。 +- 状态会依赖外部工具目录,越来越难解释和修复。 +- 无法稳定支持 custom agent 目录。 + +## Final Choice + +采用 Option A。 + +产品引入独立的 `skills` 领域: + +- 公共库是唯一事实源 +- `skills-hub` 负责发现与获取内容 +- `agent targets` 负责记录每个 provider 的 `skillDir` +- `mount relations` 负责记录公共库 skill 如何投影到 agent 目录 +- 右侧 `Skills` 面板承担搜索、安装、挂载、修复和 target 配置入口 + +## Scope + +### Included In v1 + +- 桌面工作区右侧活动栏新增 `Skills` 入口 +- `skills-hub` 搜索、详情、安装、卸载 +- 公共 skill 库与本地索引 +- built-in + custom provider 的 `skillDir` 配置 +- 单 skill 对多个 agent 的挂载/卸载 +- 结构化安装 job、挂载状态、健康检查和修复 +- `Agent Targets` 抽屉 + +### Excluded From v1 + +- workspace 级隔离 +- 多版本并存与版本回滚 +- 本地目录自动 watch/import +- skill bundle/org/team 管理 +- 自动全量更新 +- 移动端完整 `Skills` 管理体验 + +## Current Product Constraints + +### Workspace Sidebar Today + +桌面侧栏当前仅支持三种 `DesktopSidebarView`: + +- `explorer` +- `search` +- `source-control` + +参考: + +- [`packages/web/src/features/workspace/atoms/layout.ts`](../../../packages/web/src/features/workspace/atoms/layout.ts) +- [`packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx`](../../../packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx) + +这意味着 skill 管理如果要进入右侧活动栏,需要扩展: + +- `DesktopSidebarView` 枚举与默认 sanitize 逻辑 +- `WorkspaceActivityBar` 的按钮集 +- 桌面 workspace 主场景里的侧栏面板切换分支 + +### Provider Registry Already Unifies Built-in and Custom + +前端和服务端已经能在同一条 provider registry 中看到 built-in 和 custom provider: + +- [`packages/server/src/commands/provider.ts`](../../../packages/server/src/commands/provider.ts) +- [`packages/server/src/provider-runtime/custom-provider.ts`](../../../packages/server/src/provider-runtime/custom-provider.ts) +- [`packages/server/src/storage/repositories/custom-provider-repo.ts`](../../../packages/server/src/storage/repositories/custom-provider-repo.ts) + +这为 `Agent Targets` 提供了天然入口:不需要为 custom provider 另起一套 target 机制。 + +### Existing Installer Pattern Is Worth Reusing + +仓库已有三类安装器,已经验证了以下模式可用: + +- 单资源单活动 job +- `start/get` 命令语义 +- 结构化 `steps` / `failure` +- 前端轮询 job 状态 + +参考: + +- [`packages/server/src/provider-runtime/install-manager.ts`](../../../packages/server/src/provider-runtime/install-manager.ts) +- [`packages/server/src/lsp-tools/install-manager.ts`](../../../packages/server/src/lsp-tools/install-manager.ts) +- [`packages/server/src/system-deps/install-manager.ts`](../../../packages/server/src/system-deps/install-manager.ts) + +skill 安装与挂载应该沿用这套模式,而不是直接把 CLI 输出裸传给前端。 + +### skills-hub CLI Constraint + +已确认的官方 CLI 能力边界: + +- `search ` +- `info --json` +- `install [slug] --target ` +- `sync [framework] --output ` +- `list` +- `uninstall ` + +目前没有“直接安装到任意公共目录”的 `install --output` 能力。 + +因此产品不能把公共库设计成“官方 CLI 直接写入的目标目录”;必须由服务端做二次编排,用 staging/export 的方式把 skill 收敛进产品自己的公共库。 + +## Final UX + +### 1. Activity Bar Entry + +右侧活动栏新增 `Skills` 按钮,和 `文件 / 搜索 / Git` 同级。 + +建议状态: + +- 默认图标 +- 有失败/待修复 skill 时显示告警点 +- 当前 view 为 `skills` 时保持与其他活动栏按钮一致的 active 样式 + +### 2. Skills Panel Layout + +`Skills` 面板默认是“全局技能库”视角,自上而下分为四块: + +1. 顶部工具栏 + - 搜索框:同时支持远程搜索和本地库过滤 + - 筛选:`全部 / 已安装 / 未挂载 / 有异常` + - `Agent Targets` 入口 +2. 主列表 + - 每条 skill 卡片显示:`name`、`slug`、`version`、`source`、摘要、状态 + - 状态:`未安装`、`已入公共库`、`部分挂载`、`全部挂载`、`异常` +3. 详情区或展开详情 + - 远程详情、本地安装状态、挂载摘要、最近错误 + - 主操作:`安装`、`更新`、`管理挂载`、`修复`、`卸载` +4. `Agent Targets` 抽屉 + - 列出所有 built-in + custom provider + - 展示 `skillDir`、挂载数量、健康状态和目录修改入口 + +### 3. Primary Interaction Flows + +#### Search and Install + +- 用户输入搜索词 +- 面板展示远程结果 +- 若本地已安装,卡片直接显示本地状态摘要 +- 用户点击 `安装` +- 前端轮询安装 job +- 成功后卡片切换到 “已入公共库” + +#### Manage Mounts + +- 用户在已安装 skill 上点击 `管理挂载` +- 详情区列出所有 agent +- 对每个 agent 展示: + - `已挂载` + - `未挂载` + - `未配置 skillDir` + - `挂载异常` +- 用户可以逐个执行 `挂载 / 卸载 / 修复 / 配置目录` + +#### Edit Agent Target + +- 用户进入 `Agent Targets` +- 修改某个 provider 的 `skillDir` +- 若该 provider 当前已有挂载,弹出确认: + - `仅保存目录` + - `保存并重挂载全部` +- 推荐默认是 `保存并重挂载全部` + +#### Repair Drift + +- 当健康扫描发现 source/target 漂移或损坏时,面板显示结构化异常 +- 每种异常都要给明确动作: + - `重新创建目录并挂载` + - `重建挂载` + - `更换目录` + - `从公共库重新安装` + +## Architecture + +### 1. Public Skill Library + +引入应用级全局唯一的公共库,建议位于 state root 下: + +- `state/skills/library/` + +v1 每个 `slug` 只保留一个当前版本,目录建议为: + +- `state/skills/library//` + +该目录下存放规范化后的 skill 内容与 metadata。 + +公共库是唯一事实源。agent 原生目录只是投影结果,不是源目录。 + +### 2. Agent Target Registry + +单独维护 `providerId -> skillDir` 映射,不塞进 built-in provider 配置,也不只放进 custom provider 记录中。 + +原因: + +- built-in provider 也需要配置目录 +- custom provider 需要和 built-in 共享同一套 target 管理 UI +- `skillDir` 属于“skill target 配置”,不是 provider 启动命令本体的一部分 + +### 3. Mount Relation Store + +单独维护公共库和 agent target 之间的关系表。 + +它负责回答: + +- 哪个 provider 使用了哪个 skill +- 当前采用的是 `symlink` 还是 `copy` +- target 路径是否健康 +- 最近一次同步或修复是否成功 + +### 4. Skills Hub Client Wrapper + +服务端新增 `skills-hub` 客户端封装,统一处理: + +- CLI 可用性检查 +- 搜索与详情查询 +- staging 安装 +- 输出解析 +- 失败分类 + +这样前端和高层 manager 不需要知道底层 CLI 细节。 + +### 5. Separate Managers + +建议新增三类 manager: + +- `SkillInstallManager` + - 负责远程安装 job、staging、公共库写入 +- `SkillMountManager` + - 负责单个/批量挂载、卸载、重挂载 +- `SkillHealthManager` + - 负责健康扫描、漂移识别和修复计划 + +这三类职责不要揉成一个“大 manager”,否则 install/mount/repair 状态会耦合得很快失控。 + +## Domain Model + +新增共享类型,建议放在 `packages/core/src/domain/skill-management.ts`。 + +### Skill Library Entry + +```ts +export interface SkillLibraryEntry { + slug: string; + displayName: string; + description?: string; + version: string; + source: "skillhub" | "local"; + libraryPath: string; + installState: "installed" | "installing" | "failed"; + installedAt: number; + updatedAt: number; + lastError?: string; +} +``` + +### Agent Skill Target + +```ts +export interface AgentSkillTargetEntry { + providerId: string; + displayName: string; + kind: "built_in" | "preset" | "custom"; + skillDir?: string; + mountPreference: "auto"; + lastHealthState: "healthy" | "warning" | "error" | "unconfigured"; + lastHealthError?: string; +} +``` + +### Skill Mount Relation + +```ts +export interface SkillMountRelation { + providerId: string; + skillSlug: string; + enabled: boolean; + sourcePath: string; + targetPath: string; + mountModeResolved: "symlink" | "copy"; + status: "mounted" | "stale" | "missing_target" | "missing_source" | "failed"; + lastSyncedAt?: number; + lastError?: string; +} +``` + +### Skill Install Job + +```ts +export interface SkillInstallJobSnapshot { + jobId: string; + slug: string; + version?: string; + status: "queued" | "running" | "succeeded" | "failed"; + currentStepId?: string; + steps: SkillInstallStepSnapshot[]; + failure?: SkillInstallFailure; +} +``` + +结构风格直接参考现有 provider/LSP/system-deps installer。 + +## Persistence + +建议新增三个 file-backed repository: + +- `skill-library-repo.ts` + - 存公共库索引 +- `skill-target-repo.ts` + - 存 `providerId -> skillDir` +- `skill-mount-repo.ts` + - 存挂载关系 + +建议路径: + +- `state/skills/library-index.json` +- `state/skills/targets.json` +- `state/skills/mounts.json` + +这样和现有 `settings repo`、`custom provider repo` 的存储风格保持一致。 + +## Install Flow + +### Why Install Needs Staging + +`skills-hub install` 目前只支持 `--target `,不支持直接写入产品公共库。 + +因此 v1 采用: + +1. 服务端创建 staging 目录 +2. `SkillInstallManager` 调用官方 CLI,把 skill 安装到受支持 target 对应的 staging 目录 +3. 服务端从 staging 目录中解析并抽取受管 skill 内容 +4. 服务端校验 staging 结果中是否存在合法 skill 内容 +5. 原子替换公共库中的 `` 目录 +6. 更新 `skill-library-repo` + +v1 的实现前提是:选择一个 `skills-hub` 已支持的 target 作为 staging 格式来源,但 staging 目录本身必须由产品创建和销毁;公共库仍然只由产品写入,而不是直接暴露给官方 CLI 作为正式安装目标。 + +### Install State Policy + +安装只负责“把 skill 放进公共库”,不默认自动挂到所有 agent。 + +推荐默认行为: + +- 安装成功后仅更新公共库 +- 若该 skill 之前已有启用中的挂载关系,则允许在成功后提示用户 `重新同步到已启用 agent` + +这样能避免“装一个 skill 导致多个 agent 目录被静默修改”。 + +## Mount Flow + +挂载由 `SkillMountManager` 独立负责: + +1. 读取目标 provider 的 `skillDir` +2. 确保目录存在且可写 +3. 计算 target path:`/` +4. 优先尝试 `symlink` +5. 若因平台、权限或文件系统约束失败,则退回 `copy/sync` +6. 更新 `skill-mount-repo` +7. 立即执行该 relation 的健康检查 + +产品层对用户始终显示“已挂载”;若发生降级,则在细节状态中标出 `已降级复制`。 + +## Unmount and Uninstall + +### Unmount + +- 只影响某个 `providerId + skillSlug` relation +- 删除 target path +- 保留公共库 skill +- relation 更新为 disabled 或删除 + +### Uninstall From Public Library + +默认不允许在仍有启用挂载时直接卸载公共库 skill。 + +前端提供两个动作: + +- `先卸载所有 agent 后删除` +- `强制删除并清理失效挂载` + +推荐默认行为是前者。 + +## Health Model + +健康检查至少覆盖三层: + +### Library Health + +- 公共库目录是否存在 +- metadata 是否齐全 +- skill 内容是否仍可读 + +### Target Health + +- `skillDir` 是否存在 +- 目录是否可写 +- provider 是否仍在 registry 中 + +### Mount Health + +- target path 是否存在 +- 若为 `symlink`,是否仍指向当前 source path +- 若为 `copy`,是否与 source 的版本/mtime 不一致 + +前端状态统一收敛为: + +- `已挂载` +- `已降级复制` +- `目标缺失` +- `源已丢失` +- `需要重挂载` +- `未配置目录` + +## Error Handling and Repair + +### Install Failure + +- 不污染正式公共库索引 +- staging 目录清理 +- job 状态标记为 `failed` +- UI 提供 `重试安装` + +### Mount Failure + +- 不影响公共库中该 skill 的 `installed` 状态 +- relation 标记为 `failed` +- 若属于可预期 `symlink` 失败,自动尝试降级 + +### External Drift + +用户可能手动修改了 agent skill 目录。v1 不自动覆盖漂移结果,而是: + +- 将 relation 标为 `stale` +- 提供 `以公共库为准重建` + +### Path Safety + +用户可配置 `skillDir`,因此所有文件操作都必须保守: + +- 仅允许操作明确记录过的 target path +- 卸载/修复时先校验 relation 与真实路径匹配 +- 不允许对未受管路径执行递归删除 +- 写路径统一走安全 resolve 逻辑,避免路径穿越 + +## Command Surface + +建议新增 `skills.*` 命令族: + +- `skills.search` +- `skills.info` +- `skills.library.list` +- `skills.install.start` +- `skills.install.get` +- `skills.mount` +- `skills.unmount` +- `skills.uninstall` +- `skills.targets.list` +- `skills.targets.update` +- `skills.health.scan` +- `skills.repair` + +命令语义遵循现有 server command 风格: + +- 查询与动作分离 +- 长任务使用 `start/get` +- 失败返回结构化 code/message/details + +## UI Composition + +建议新增: + +- `packages/web/src/features/workspace/views/shared/skills-panel.tsx` +- `packages/web/src/features/workspace/views/shared/agent-skill-targets-drawer.tsx` +- `packages/web/src/features/workspace/actions/use-skills-panel.ts` +- `packages/web/src/features/workspace/atoms/skills.ts` + +桌面主场景需要修改: + +- `DesktopSidebarView` 增加 `skills` +- `WorkspaceActivityBar` 增加技能图标按钮 +- `WorkspaceDesktopView` 增加 `SkillsPanel` 渲染分支 + +服务端建议新增: + +- `packages/server/src/skills/skills-hub-client.ts` +- `packages/server/src/skills/install-manager.ts` +- `packages/server/src/skills/mount-manager.ts` +- `packages/server/src/skills/health-manager.ts` +- `packages/server/src/commands/skills.ts` +- `packages/server/src/storage/repositories/skill-library-repo.ts` +- `packages/server/src/storage/repositories/skill-target-repo.ts` +- `packages/server/src/storage/repositories/skill-mount-repo.ts` + +core 建议新增: + +- `packages/core/src/domain/skill-management.ts` + +## Testing Strategy + +### Core / Server + +- repository 读写与 schema 归一化测试 +- install manager 的 job 生命周期与失败分类测试 +- mount manager 的 `symlink -> copy` 降级测试 +- health manager 的漂移识别与修复计划测试 +- command dispatch wiring 测试 + +### Web + +- 活动栏新增 `Skills` 按钮的切换测试 +- `SkillsPanel` 搜索、安装、挂载、错误态测试 +- `Agent Targets` 抽屉配置目录与重挂载确认测试 +- 本地状态与轮询状态联动测试 + +### End-to-End + +- 搜索 skill -> 安装到公共库 -> 挂载到 built-in agent +- 搜索 skill -> 安装到公共库 -> 挂载到 custom agent +- 修改 `skillDir` -> 触发 `remount all` +- 模拟 target 漂移 -> 在面板中修复 + +## Rollout Notes + +v1 建议先在桌面端落地,不阻塞移动端。移动端可以先不暴露 `Skills` 面板入口,或仅展示只读摘要。 + +这套设计是一个新的独立领域,不建议把它硬塞进 provider settings 或 diagnostics。最合理的落点仍然是 workspace 右侧活动栏,因为它和 `文件 / 搜索 / Git` 一样,都是“和当前工作区协作但不属于 agent 会话本身”的工具面板。 diff --git a/docs/superpowers/specs/2026-05-27-workspace-panel-balanced-workbench-design.md b/docs/superpowers/specs/2026-05-27-workspace-panel-balanced-workbench-design.md new file mode 100644 index 00000000..40fc0130 --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-workspace-panel-balanced-workbench-design.md @@ -0,0 +1,371 @@ +# Workspace Panel Balanced Workbench 统一设计 + +> Status: Draft for review +> Date: 2026-05-27 +> Scope: `packages/web/src/features/workspace/views/shared/*`, `packages/web/src/features/workspace/views/mobile/*`, `packages/web/src/styles/components.css`, related view tests and theme tests + +## 目标 + +统一 workspace 中 `Explorer`、`Search`、`Source Control / Git` 在桌面端和移动端的 panel grammar,让三块侧栏真正像同一套专业编辑器 workbench,而不是三个独立产品。 + +本轮目标: + +- 保留现有桌面端 `Activity Bar + Sidebar View` 信息架构 +- 保留移动端 `Explorer / Search / Source Control` 三视图切换模型 +- 统一三块面板的 section header、row、input、action、selected state、密度和分隔语法 +- 让整体气质收敛到 `flush / 紧凑 / 硬朗 / 高扫描效率` +- 在不减功能的前提下完成视觉统一,尤其不能弱化 `Search` 的现有搜索结果能力 + +本轮不做: + +- 不增加新的 Git 工作流能力 +- 不重做桌面和移动端的信息架构 +- 不引入新的页面私有主题系统 +- 不为了“统一”而删除现有有效能力 + +## 设计结论 + +采用 `Balanced Workbench` 重做方向,但不是继续走“section 外框卡片化”的路线,而是收敛为: + +- `panel flush` +- `section 无外框` +- `无重复 panel 标题` +- `row / control 保留小圆角` +- `用分隔线、留白、header grammar 建层级` + +最终的统一重点不是“每块内容外面再包一个壳”,而是让 `Explorer / Search / Git` 共用一套结构语法: + +- `chevron` +- `section title` +- `count` +- `actions` +- `body` +- `selected row` + +## 已确认的设计约束 + +用户已明确确认以下方向: + +- panel 本身不要圆角 +- section 不要线框式卡片分块 +- panel 内不要重复出现 `资源管理器 / 搜索 / Git` 模块大标题 +- 视觉上不要“一块一块”的卡片式切割 +- Search 必须保留真实能力,不能在重做中被弱化 + +这些不是建议,而是本次实现必须遵守的约束。 + +## 当前问题 + +### 1. 三块面板仍在使用三套 grammar + +- `Explorer` 更像树控件集合 +- `Search` 更像独立搜索工具页 +- `Git` 更像提交表单加列表 + +这导致用户在同一个侧栏内切换 tab 时,仍然需要重新理解不同面板的结构语气。 + +### 2. section 语义不统一 + +当前不同面板对 section 的表达不一致: + +- 有的是纯标题行 +- 有的是带 action 的 header +- 有的是局部组标题 +- 有的是近似卡片块 + +折叠、收起、计数、动作的表达没有统一到一个稳定 contract。 + +### 3. 容器感过强,内容感过弱 + +上一轮视觉变化之所以“不像重做”,核心原因不是颜色或 radius 调整不够,而是 panel grammar 没有真正统一。 + +用户首先感知到的是: + +- 层级怎么组织 +- section 是否一致 +- row 是否一致 +- 内容是否连续 + +如果这些不统一,即使 token 微调了,整体观感也不会拉开。 + +### 4. Search 容易在“视觉统一”过程中被误伤 + +`Search` 的问题不是功能少,而是 mock 和视觉表达很容易把它画成“只有输入框和结果列表”。 + +但当前真实实现已经具备: + +- 按文件分组的结果结构 +- 文件组默认展开 +- 文件组可展开/收起 +- 行号展示 +- 命中内容预览 +- 命中片段高亮 +- 当前选中 match 状态 + +这些能力必须保留。 + +## 真实行为基线 + +以下现有行为被视为本轮必须保留的功能基线。 + +### Explorer + +保留: + +- `Open Editors` +- `Workspace` +- 新建文件 / 新建目录 / 收起 +- 文件树展开、打开、上下文相关操作 + +### Search + +保留: + +- 输入内容后执行文件内容搜索 +- 搜索结果按文件分组 +- 每个文件组默认展开 +- 每个文件组可展开/收起 +- 每个文件组显示文件名、完整路径、命中数量 +- 每条 match 显示行号 +- 每条 match 显示命中上下文预览 +- 命中片段高亮 +- 点击 match 打开对应位置 +- 结果截断提示、错误态、空态、无结果态 + +### Git + +保留: + +- commit 输入区 +- changes 列表 +- worktrees 列表 +- history 列表 +- 行内操作按钮 +- 状态色用于文件状态表达 + +## 核心视觉原则 + +## 1. Panel Flush + +panel 是工作台的一部分,不是独立卡片。 + +要求: + +- panel 容器本身不使用圆角 +- panel 容器不通过圆角塑造“单独一块”的感觉 +- panel 外层结构更多依赖边界线与背景层级建立 + +这适用于: + +- desktop sidebar panel +- mobile files content panel +- Git / Search / Explorer 运行时主体 + +## 2. Section Without Box + +section 必须存在,但不采用线框式卡片包裹。 + +要求: + +- section 不使用明显外边框框出一个个 block +- section 之间依赖细分隔线和留白节奏组织 +- section header 是结构锚点,不是卡片头 +- body 与 header 构成同一连续工具面 + +允许: + +- 使用极轻的 separator +- 在 group 内部使用局部分隔 + +不允许: + +- 每个 section 都是独立描边块 +- section 之间用大块背景差制造分裂感 + +## 3. No Repeated Panel Titles + +当前模块已经由 activity bar 或 tab 切换表达,不需要在 panel 内再次重复显示: + +- `资源管理器` +- `搜索` +- `源代码管理` + +要求: + +- desktop mock / runtime panel 内不重复出现模块大标题 +- mobile files sheet 内也不重复出现这类模块标题 +- 当前模块身份由 activity/tab 体系承担 + +例外: + +- 文档评审页中的分栏说明标题可以保留,只用于解释稿件,不属于产品运行时 UI + +## 4. Small Radius Only For Interactive Units + +整体 radius 回到现有 token 约束,不新增更大的 panel 圆角语言。 + +推荐落点: + +- row / input / small action: `2px - 4px` +- 局部较大 control: `4px - 6px` +- panel / section: `0` + +状态 chip 可以继续保留胶囊型 radius,但只承担状态表达。 + +## 5. Shared Row Contract + +`Explorer row`、`Search match row`、`Git row` 必须收敛到同一类 row contract: + +- 相近的高度和密度 +- 相近的 hover +- 相近的 focus +- 相近的 selected +- 相近的左右内边距节奏 + +明确不采用: + +- 左侧强调条 +- 通过 padding 偏移制造 current state + +采用: + +- 完整块级高亮 +- 轻边框或轻 selected background +- hover、focus、selected 可共存 + +## 6. Section Header Contract + +所有可折叠 section 与 group header 都应尽量遵循以下组织: + +- `chevron` +- `title` +- `count` +- `actions` +- `body` + +并满足: + +- chevron 行为一致 +- count 位置稳定 +- actions 语义稳定 +- body 不再像单独的控件区或卡片区 + +## 三个面板的具体设计 + +## Explorer + +Explorer 作为整个 workspace sidebar grammar 的基准面板。 + +要求: + +- `Open Editors` 与 `Workspace` 采用同一类 section header contract +- `Open Editors` 不应看起来像独立轻列表,而应归入共享 row 语言 +- `Workspace` 的动作与 header 同构,不再像漂浮在树控件上的附属按钮 +- 文件树 row 的 active / hover / selected 成为 Search 与 Git 的参考基线 + +目标: + +- Explorer 不再像“树控件样式集合” +- 它应该成为 Search 与 Git 的共同母体 + +## Search + +Search 必须在视觉上回到同一家,但功能上必须完整保留现有能力。 + +### 必须保留的行为 + +- 结果按文件分组 +- 文件组默认展开 +- 文件组支持展开/收起 +- 文件组 header 展示文件名、路径、命中数 +- match 展示行号 +- match 展示命中预览 +- 命中片段高亮 +- active match 保留 selected 语义 + +### 视觉统一要求 + +- 搜索输入框与 Quick Jump / Explorer 搜索输入归入同一 control family +- 结果文件组 header 使用统一 section/group header grammar +- 文件组不是独立卡片 +- 命中行不是独立“搜索结果卡片条目” +- 结果区继续是分组结构,但整体视觉上属于连续工具面 + +### 明确禁止 + +- 为了简化视觉,把结果扁平成纯 row 列表 +- 去掉文件分组的折叠能力 +- 去掉命中内容预览 +- 去掉高亮命中片段 + +## Git / Source Control + +Git 继续作为能力最复杂的 panel,但视觉上必须和另外两块统一。 + +要求: + +- commit 输入区收紧为工具输入区,而不是“上面一整块表单” +- `changes / worktrees / history` 全部回到统一 section grammar +- `git row` 与 `explorer row / search row` 同类 +- 行内操作按钮大小、位置、hover/focus 语义一致 +- 状态色只用于状态表达,不形成额外容器装饰系统 + +目标: + +- Git 保留复杂度,但不保留碎感 + +## Mobile 继承规则 + +移动端不是另一套设计语言,而是同一系统的触控版。 + +要求: + +- 继续保留 Explorer / Search / Source Control 三视图切换 +- panel 本体继续 flush +- section 继续无外框 +- 不在移动端重新加回模块标题 +- 只放大触控热区、间距和行高 +- Search 的分组折叠、预览、高亮能力继续保留 + +明确不做: + +- 移动端专用大圆角 panel +- 移动端专用卡片式 section +- 移动端专用另一套 hierarchy + +## 主题与样式约束 + +本轮必须继续完全依赖现有 token 体系。 + +要求: + +- surface 使用 workspace/sidebar 相关语义 token +- hover / selected / focus 使用现有 state token +- radius 使用共享 radius token +- 不直接写死 bespoke 颜色来“追视觉稿” + +测试层应能捕获至少以下约束: + +- panel 运行时不依赖 panel 圆角 +- selected state 不再使用左侧强调条 +- Search / Explorer / Git row 使用一致的 selected contract +- Search 的 group header / match row 语法仍可被测试覆盖 + +## 验收标准 + +当以下条件成立时,本轮设计视为达标: + +- `Explorer / Search / Git` 切换时明显属于同一套 workbench +- panel 本体不再呈现卡片感 +- section 不再是一块一块有外框的小盒子 +- panel 内没有重复模块标题 +- Search 现有分组折叠与命中预览能力完整保留 +- Git 的 commit 区与下方列表不再像两套系统 +- mobile 继承同一 grammar,而不是另一套 app 风格 + +## 参考稿 + +本设计对应的离线评审稿: + +- `docs/superpowers/reviews/2026-05-27-workspace-panel-balanced-workbench.html` + diff --git a/docs/superpowers/specs/2026-05-28-file-tree-refresh-gitignored-design.md b/docs/superpowers/specs/2026-05-28-file-tree-refresh-gitignored-design.md new file mode 100644 index 00000000..0d33483a --- /dev/null +++ b/docs/superpowers/specs/2026-05-28-file-tree-refresh-gitignored-design.md @@ -0,0 +1,320 @@ +# File Tree Refresh Stability and Gitignored Styling Design + +> Status: Draft +> Date: 2026-05-28 +> Scope: `packages/web/src/features/workspace/*`, `packages/web/src/app/providers.tsx`, `packages/server/src/fs/*`, `packages/server/src/commands/file.ts`, `packages/core/src/domain/types.ts` + +## Goal + +Improve the workspace file tree in two specific ways: + +- keep directory expansion and collapse state visually stable when filesystem changes trigger a tree refresh +- visually distinguish gitignored files and directories in the normal file tree by reducing label opacity + +The file tree should continue to show fresh directory contents after edits, creates, deletes, and renames, but it should no longer flash through a collapse-and-reopen feeling for directories that were already expanded. + +## Non-Goals + +This design does not include: + +- changing file search result rows to show gitignored styling +- hiding gitignored files or directories from the tree +- replacing the current lazy-loading tree model with a fully recursive tree fetch +- adding inline git status badges or other source-control metadata to the tree +- supporting full Git ignore semantics across nested `.gitignore`, `.git/info/exclude`, and global excludes in the first release +- redesigning tree layout, typography, or iconography beyond the new subdued state + +## Problem + +The current file tree keeps expansion state in `expandedDirs`, but refreshes still feel unstable during `fs.dirty` handling. + +The root cause is structural: + +- `file.readTree` is called again for the workspace root +- the client replaces the entire `fileTree` map with a new map containing only `"."` +- previously loaded child directory entries disappear temporarily +- expanded directories then trigger child reloads again because the expansion state still says they are open + +This produces a visible flash where expanded directories appear to collapse and reopen even though the underlying expansion state was not actually cleared. + +Separately, the tree currently provides no visual distinction for files or directories that are matched by the workspace `.gitignore`. Users can see ignored content in the tree, but cannot tell which entries are intentionally ignored by Git. + +## User Decisions Captured + +- The gitignored distinction applies only to the normal directory tree. +- Search result rows do not need gitignored styling in this change. +- Refreshing the tree is still required when filesystem changes occur. +- Expanded directories must stay expanded, and collapsed directories must stay collapsed, across refreshes. + +## Approaches Considered + +### Option A: Preserve loaded subtrees and refresh expanded directories in place (recommended) + +Keep the existing lazy tree model and `expandedDirs` state, but change refresh behavior: + +- refresh root entries without discarding already loaded descendant maps +- preserve loaded data for directories that remain expanded +- silently re-read currently expanded directories after the root refresh +- prune stale loaded and expanded state for paths that no longer exist + +Advantages: + +- minimal surface-area change +- fits the current `Map` store shape +- directly targets the flash without redesigning the tree model + +Disadvantages: + +- refresh can trigger multiple `file.readTree` calls when many directories are expanded +- the client must explicitly prune stale descendant state + +### Option B: Build a path-level diff and reconcile the entire tree graph + +Replace the current refresh behavior with a more elaborate reconciliation algorithm that computes insertions, removals, and updates across the loaded tree. + +Advantages: + +- potentially more elegant long-term tree state model + +Disadvantages: + +- significantly more complex than the current need +- higher risk of introducing stale node or descendant mismatch bugs +- unnecessary for solving the reported UX problem + +### Option C: Move all refresh stability to the server + +Have the server emit richer incremental tree update payloads so the client no longer refetches root and child directories independently. + +Advantages: + +- could reduce duplicate reads and centralize tree reconciliation + +Disadvantages: + +- requires a broader protocol redesign +- much larger scope than the requested optimization + +## Final Choice + +Adopt Option A. + +This keeps the current lazy directory tree contract intact while removing the visible refresh flash. It also allows gitignored styling to be added as a small protocol extension on `FileNode` instead of requiring a separate metadata channel. + +## Final Design + +### 1. Refresh Stability Model + +The tree remains a lazy-loaded `Map` where: + +- `"."` stores root entries +- a directory path such as `src` stores direct children of that directory + +The refresh flow changes from full root replacement to staged in-place reconciliation. + +#### 1.1 Root Refresh + +When `fs.dirty` marks the tree stale and the panel reloads: + +- request `file.readTree` for the workspace root as today +- replace only `treeMap.get(".")` +- do not discard other loaded directory entries immediately +- do not reset `loadedDirs` + +This ensures that already-expanded directories keep their rendered child content while refresh work continues. + +#### 1.2 Expanded Directory Revalidation + +After the new root entries are stored: + +- compute the set of currently expanded directory paths from `expandedDirs` +- intersect that set with directories that still exist in the refreshed tree +- for each remaining expanded directory, issue `file.readTree({ subPath })` +- update `treeMap.get(subPath)` in place as responses return + +This keeps the visible subtree content fresh without forcing a visual reset of the directory row itself. + +#### 1.3 Stale Path Pruning + +After root refresh and during descendant refresh reconciliation: + +- if a previously expanded directory no longer exists, remove it from `expandedDirs` +- remove nonexistent paths and their descendants from `loadedDirs` +- delete `treeMap` entries for removed directories and their descendants + +This prevents stale descendant content from surviving after a delete or rename. + +#### 1.4 Loading Semantics + +The existing lazy-load behavior remains: + +- expanding a directory that has never been loaded still triggers `file.readTree({ subPath })` +- collapsing a directory only changes expansion state and does not delete loaded child data + +Refresh-specific descendant revalidation should be treated as background work. It should not force the row through a temporary closed-looking state. + +### 2. Gitignored Metadata + +Extend `FileNode` with: + +- `isGitIgnored?: boolean` + +This field is populated by `file.readTree` for both files and directories. + +#### 2.1 First-Release Semantics + +For this change, `isGitIgnored` is based on the workspace root `.gitignore` rules plus the existing always-hidden tree rules already applied elsewhere for `.git`. + +Behavior: + +- `.git` remains hidden as today and never appears in the tree +- entries matched by the workspace root `.gitignore` are still shown in the tree +- those shown entries receive `isGitIgnored: true` +- entries not matched by those rules receive `isGitIgnored: false` + +This intentionally does not attempt full Git parity for nested `.gitignore` files or user-global excludes in the first release. + +### 3. Server Responsibilities + +#### 3.1 `readTree` + +`packages/server/src/fs/tree.ts` + +- continue returning direct children only +- continue including hidden files except `.git` +- determine `isGitIgnored` for each entry while building `FileNode` + +Implementation direction: + +- build or reuse a root-level ignore matcher once per `readTree` call +- compute each entry's relative path from workspace root +- test that relative path against the matcher +- assign `isGitIgnored` on both file and directory nodes + +#### 3.2 Shared Ignore Helpers + +`packages/server/src/fs/gitignore.ts` + +Add a helper focused on metadata inspection rather than filtering, for example: + +- creating a reusable matcher for root `.gitignore` +- testing whether a relative path is ignored + +This avoids duplicating path normalization logic inside `tree.ts`. + +### 4. Client Responsibilities + +#### 4.1 File Tree Refresh Logic + +`packages/web/src/features/workspace/actions/use-file-actions.ts` + +Refactor root reload logic so it: + +- merges the refreshed root children into the existing tree map +- preserves descendant map entries for currently loaded paths +- revalidates expanded directories after root refresh +- prunes removed directory state + +The refresh algorithm should be driven by current `expandedDirs`, `loadedDirs`, and the refreshed root snapshot, not by UI remounts. + +#### 4.2 Tree Rendering + +`packages/web/src/features/workspace/views/shared/file-tree-panel.tsx` + +The component keeps the existing expansion source of truth: + +- expanded state comes from `expandedDirs` +- default auto-expand behavior for initial roots remains unchanged + +Rendering changes: + +- if a tree node has `isGitIgnored`, add a dedicated modifier class on the row or label +- apply the subdued style only in the normal tree row renderer +- do not apply the subdued style to `FileSearchResultRow` + +### 5. Visual Treatment + +Add a file-tree-specific subdued style in `packages/web/src/styles/components.css`. + +Requirements: + +- the distinction should be visible but not make names unreadable +- target the file name first +- optionally include the icon if it still reads cleanly with the current theme tokens +- selected rows must remain clearly selected even when gitignored + +Recommended baseline: + +- reduce label opacity modestly rather than aggressively +- keep hover and selected backgrounds unchanged + +### 6. Testing Strategy + +#### 6.1 Server Tests + +Update `packages/server/src/__tests__/fs/tree.test.ts` to cover: + +- ignored files are still present in tree results +- ignored directories are still present in tree results +- returned nodes include `isGitIgnored: true` when matched +- returned nodes include `isGitIgnored: false` when not matched + +Add focused helper tests in `packages/server/src/__tests__/fs/gitignore.test.ts` for the new metadata matcher behavior. + +#### 6.2 Client Tests + +Update `packages/web/src/features/workspace/views/shared/file-tree-panel.test.tsx` and related file-tree action tests to cover: + +- expanded directories remain rendered after a stale refresh +- root refresh preserves loaded descendants until replacement data arrives +- deleted or renamed expanded directories are pruned from persisted expansion state +- gitignored nodes render with the subdued class +- search result rows do not render with the subdued class + +#### 6.3 Regression Focus + +Pay special attention to: + +- create file +- create folder +- rename file +- rename folder +- delete file +- delete folder +- external file content changes that emit `fs.dirty` + +The success condition is that the tree content updates correctly without the previously expanded directories flashing through an apparent close-and-reopen cycle. + +## Risks and Mitigations + +### Risk: stale subtree data remains after path removal + +Mitigation: + +- explicitly prune descendant entries from `treeMap`, `loadedDirs`, and `expandedDirs` +- cover removal and rename cases in tests + +### Risk: refresh issues too many child reads + +Mitigation: + +- only revalidate currently expanded directories +- keep the first release simple and measure behavior before optimizing further + +### Risk: subdued styling harms readability or selected-state contrast + +Mitigation: + +- scope the opacity reduction to the name and possibly icon only +- keep selected row background and active state unchanged +- verify in existing theme tests if selector changes affect token expectations + +## Open Tradeoff Chosen Explicitly + +This design intentionally chooses pragmatic first-release gitignored semantics: + +- show ignored entries +- mark them using the root `.gitignore` +- defer full Git parity + +That tradeoff is acceptable because the user request is visual distinction, not Git-authoritative ignore introspection. diff --git a/docs/superpowers/specs/2026-05-28-workspace-launch-new-folder-design.md b/docs/superpowers/specs/2026-05-28-workspace-launch-new-folder-design.md new file mode 100644 index 00000000..40230de6 --- /dev/null +++ b/docs/superpowers/specs/2026-05-28-workspace-launch-new-folder-design.md @@ -0,0 +1,297 @@ +# Workspace Launch Modal Title And New Folder Design + +Date: 2026-05-28 +Status: Draft +Owner: Codex + +## Problem + +当前“启动工作区”流程里的目录选择弹框存在两个明显问题: + +- 标题区重复表达了同一件事。中文下同时出现 `启动工作区` 和 `打开工作区`,与其他弹框的单一标题模式不一致。 +- 用户只能浏览已有目录,不能在启动前直接创建一个新文件夹作为工作区根目录。 + +这会带来两个后果: + +- 弹框头部信息层级冗余,视觉上显得不像同一套 modal grammar。 +- 用户想新建一个项目目录时,需要切出当前流程到系统文件管理器或终端,打断“选择目录 -> 启动工作区”的主路径。 + +## Goals + +- 让工作区启动弹框的主标题与其他弹框保持一致,只保留 `启动工作区`。 +- 在当前浏览目录下提供 `新建文件夹` 能力。 +- 新建文件夹使用最短路径完成,不引入二级弹框。 +- 创建成功后刷新当前目录列表,并选中新建文件夹,但不自动进入。 +- 保持现有浏览、返回上一级、选择目录、启动工作区行为不变。 + +## Non-Goals + +- 不重做整个工作区启动流程。 +- 不把普通文件树中的创建弹窗整体搬进工作区启动弹框。 +- 不在本次加入“新建文件”。 +- 不加入完整路径输入模式;用户只输入文件夹名。 +- 不在创建成功后自动打开或自动进入新文件夹。 +- 不修改已打开工作区内文件树的创建交互。 + +## User Decisions Captured + +- 标题改成和其他弹框一致,只保留 `启动工作区`。 +- 新建文件夹的默认行为是: + - 在当前浏览目录下创建 + - 创建成功后刷新目录列表 + - 选中新建文件夹 + - 不自动进入 +- 新建交互采用工具栏内联模式,不再打开二级弹框。 + +## Approaches Considered + +### Option A: 复用文件树现有创建对话框 + +优点: + +- 可复用现有 `file.create / file.mkdir` 交互模型。 +- 校验和错误呈现模式已经存在。 + +缺点: + +- 文件树交互是围绕“已打开 workspaceId”设计的,不适合直接照搬到启动前目录浏览。 +- 为一个轻量需求引入过多状态和 UI 负担。 + +### Option B: 在工作区启动弹框里做内联最小创建流(推荐) + +优点: + +- 改动集中在启动弹框链路。 +- 用户不离开当前上下文即可完成创建。 +- 只需最小量状态:展开态、输入值、提交中、错误。 + +缺点: + +- 需要为“未打开工作区时创建目录”补一条专用命令链路或扩展已有浏览命令能力。 +- 与文件树的创建逻辑会有少量重复校验。 + +### Option C: 先启动一个父目录工作区,再在文件树里创建目录 + +优点: + +- 复用现有文件树能力最多。 + +缺点: + +- 明显偏离用户要在启动弹框内完成创建的诉求。 +- 打断启动流程,并引入额外工作区切换成本。 + +## Final Choice + +采用 Option B。 + +工作区启动弹框保留当前目录浏览主体,在工具栏里新增 `新建文件夹` 按钮。创建流程使用内联输入,不叠加二级弹框。服务端增加一条专供启动前目录浏览使用的受控目录创建命令,避免强行复用依赖 `workspaceId` 的 `file.mkdir`。 + +## Final Design + +### 1. Title Unification + +当前中文 locale 中: + +- `workspace.launch.kicker` 为 `启动工作区` +- `workspace.launch.title` 为 `打开工作区` + +本次统一规则: + +- 弹框头部只表达一次主动作。 +- 中文 `workspace.launch.title` 改为 `启动工作区`。 +- 桌面端不再让 `kicker + title` 形成语义重复。 +- 移动端 `Sheet` 标题同样显示 `启动工作区`。 + +推荐实现: + +- 保留共享 modal/sheet 结构。 +- 桌面端移除或弱化 launch-specific kicker,让 `.launch-title` 成为唯一主标题。 +- 如需保留 eyebrow 位置,可留空,不再显示第二份语义重复文案。 + +### 2. New Folder Entry Point + +入口放在目录浏览工具栏,与现有: + +- `主目录` +- `返回上一级` + +同层级展示。 + +新增按钮: + +- 文案:`新建文件夹` +- 行为:切换一个内联创建区 + +此入口只对当前浏览目录生效。 + +### 3. Inline Create UI + +点击 `新建文件夹` 后,在目录列表上方显示一条轻量创建区,不开启新的 modal。 + +创建区包含: + +- 输入框 +- `创建` 按钮 +- `取消` 按钮 +- 就地错误提示区 + +交互规则: + +- 输入框只接收文件夹名称,不接收完整路径。 +- 默认聚焦输入框。 +- `Enter` 提交。 +- `Escape` 或点击 `取消` 关闭创建区并清空草稿。 +- 当创建区已展开时,再次点击 `新建文件夹` 不重复展开第二个创建区。 + +### 4. Create Semantics + +用户在当前目录 `currentPath` 下输入名称 `demo` 时: + +- 最终创建目标为 `${currentPath}/demo` + +成功后执行顺序: + +1. 关闭创建区 +2. 重新加载当前目录 +3. 将 `selectedPath` 设为新目录完整路径 +4. 不自动调用 `handleNavigate` +5. 不自动调用 `handleOpen` + +这保证用户可以继续确认是否把新目录作为工作区根目录。 + +### 5. Validation And Error Handling + +前端最小校验: + +- 去除首尾空白后为空:不允许 +- 包含 `/`:不允许 +- 包含 `\\`:不允许 + +失败时: + +- 不关闭创建区 +- 不清空输入值 +- 在输入区下方显示错误 + +服务端错误直接透传常见原因,例如: + +- 已存在同名目录 +- 无权限创建 +- 路径不可写 + +提交中状态: + +- `创建` 按钮进入 loading 或 disabled +- 输入框和取消按钮保持禁用,避免重复提交 + +### 6. Command Design + +现有 `workspace.browse` 可以浏览任意本机路径,但 `file.mkdir` 依赖已打开的 `workspaceId`。工作区启动弹框处于“尚未打开工作区”阶段,因此需要新增一条启动前目录操作命令。 + +建议新增: + +- `workspace.mkdir` + +输入: + +- `path`: 目标绝对路径 + +约束: + +- 复用 `workspace.browse` 同一套路径解析策略 +- 只允许操作浏览器当前可导航到的本机路径 +- 创建目录时使用真实绝对路径,不引入 workspace record + +返回: + +- `{ ok: true }` + +错误策略: + +- 以 command error message 形式返回可读失败原因 + +这样可以保持边界清晰: + +- 启动前目录管理走 `workspace.*` +- 已打开工作区内文件系统操作继续走 `file.*` + +### 7. Frontend State Changes + +建议把以下状态加入 `useWorkspaceLaunchActions`: + +- `isCreatingFolder` +- `newFolderName` +- `createFolderError` +- `creatingFolder` + +新增动作: + +- `openCreateFolder()` +- `closeCreateFolder()` +- `updateNewFolderName(value)` +- `submitCreateFolder()` + +`submitCreateFolder()` 负责: + +1. 做前端校验 +2. 调用 `workspace.mkdir` +3. 成功后重新调用 `loadDirectory(currentPath)` +4. 设置 `selectedPath` 为新目录路径 +5. 清理创建状态 + +### 8. Locale Changes + +需要补充或调整文案: + +- `workspace.launch.title` +- `workspace.launch.new_folder` +- `workspace.launch.new_folder_placeholder` +- `workspace.launch.create_folder` +- `workspace.launch.create_folder_cancel` +- `workspace.launch.folder_name_required` +- `workspace.launch.folder_name_invalid` +- `workspace.launch.create_folder_failed` + +中英文都要补齐,避免只修中文时打断英文单测。 + +### 9. Testing + +#### 9.1 Web Unit Tests + +扩展 [`packages/web/src/features/workspace/views/shared/workspace-launch-modal.test.tsx`](../../../packages/web/src/features/workspace/views/shared/workspace-launch-modal.test.tsx): + +- 标题改为单一 `Start Workspace` / `启动工作区` +- 点击 `New Folder` 后出现输入区 +- 取消后输入区关闭 +- 输入非法名称时显示校验错误 +- 创建成功后重新加载当前目录并选中新目录 +- 创建失败时保留输入区并显示错误 + +扩展 action hook 相关测试时,重点验证: + +- `workspace.mkdir` 调用参数为绝对路径 +- 成功后 `loadDirectory(currentPath)` 被再次触发 +- `selectedPath` 落到新目录完整路径 + +#### 9.2 E2E + +扩展工作区启动流程测试: + +- 在启动弹框中创建新文件夹 +- 新文件夹立即出现在目录列表中 +- 用户可以直接选中新文件夹并点击 `启动工作区` + +## Edge Cases + +- 当前目录原本为空:创建成功后空状态应立即消失,显示新目录。 +- 当前目录刷新后排序变化:仍应以完整路径匹配选中新目录,而不是依赖原索引。 +- 用户快速重复点击 `新建文件夹`:只保留一个创建区。 +- 用户在创建区展开后切换目录:创建区自动关闭,避免把旧目录上下文带到新目录。 +- 用户正在创建时关闭整个弹框:不做额外恢复,下次打开从干净状态开始。 + +## Implementation Notes + +- 该需求是对现有启动弹框的局部增强,不应引入新的页面级状态容器。 +- 桌面和移动端应共享同一套创建逻辑,只在容器壳层上保持差异。 +- 不要把已打开工作区文件树的 create dialog 直接嵌进来;启动前目录浏览和已打开工作区文件操作应保持边界清楚。 diff --git a/docs/superpowers/specs/2026-05-29-settings-monitoring-visual-hierarchy-redesign-design.md b/docs/superpowers/specs/2026-05-29-settings-monitoring-visual-hierarchy-redesign-design.md new file mode 100644 index 00000000..85363f7b --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-settings-monitoring-visual-hierarchy-redesign-design.md @@ -0,0 +1,487 @@ +# 设置 > 监控 视觉层级重做 · 设计文档 + +> **版本:** 1.0 +> **日期:** 2026-05-29 +> **状态:** Draft(待评审) +> **作者:** Codex + +--- + +## 0. 文档说明 + +### 0.1 目标 + +本轮不是再次讨论“监控是否应该作为设置子页存在”,而是聚焦解决当前 `Settings > Monitoring` 子页在真实数据下仍然持续炸版的问题: + +- 首屏设置区过密 +- 一级控制与二级能力混排 +- 长标签、长实体名、长路径没有稳定容器 +- 数据区和配置区同时争抢主视觉 + +目标是把监控子页重组为一个稳定的、可扩展的、对长内容有韧性的 dashboard,而不是继续在现有结构上微调间距。 + +### 0.2 关联背景 + +本设计是对既有监控子页重做方案的进一步收敛,建立在以下已有决策之上: + +- 监控保留为设置中的独立子项 +- 独立 `/monitoring` 页面已经移除 +- 监控数据和监控设置需要在同一子页内完成 + +本轮补充解决的问题是:即便监控已经并入设置,当前页面在首屏层级、控件编排和长内容约束上仍然不稳定。 + +如果本设计与此前的监控子页设计文档在桌面端布局细节上存在冲突,以本文件为准。 + +### 0.3 用户确认方向 + +本轮已和用户确认以下决策: + +- 整体方向采用 `B. 信号优先型` +- 桌面端设置露出方式采用 `B1. 摘要条 + 折叠高级项` +- 用户的真实使用链路是: + 1. 看整体机器和 Coder Studio 是否健康 + 2. 快速定位哪个工作区 / 会话在吃资源 + 3. 继续下钻到具体子进程或二进制路径 +- 页面结构必须按上述链路组织,而不是把所有能力平铺在同一层 + +### 0.4 本轮范围 + +包含: + +- 重组监控子页的信息层级和版式结构 +- 重做顶部控制区的暴露策略 +- 重新定义总览卡、归因区、下钻区的骨架 +- 为长标签、长实体名、长路径建立稳定的容器规则 +- 定义未启用、加载中、降级、刷新失败、空态等状态表达 +- 规定桌面端与移动端的退化方式 + +不包含: + +- 监控指标种类调整 +- 监控服务端采样逻辑改造 +- 新增历史持久化、告警或控制动作 +- 整个设置页其他分区的结构重做 + +--- + +## 1. 当前问题 + +### 1.1 不是单点视觉问题,而是层级失控 + +从当前截图看,页面的主要问题不是“深色样式不够精致”,而是多种不同层级的信息被压在同一个平面里: + +- 一级控制:总开关、预设、刷新频率 +- 二级能力:主机指标、运行时概览、工作区与会话归因、子进程钻取 +- 页面状态:当前模式、最后更新时间、刷新动作 +- 实时数据:主机概览、运行时曲线、归因树、详情、子进程路径 + +这些内容既没有被分层,也没有被分区,因此任何一个内容变长,都会冲垮布局。 + +### 1.2 设置区过密,导致首屏扫描成本高 + +当前首屏把: + +- 启用监控 +- 预设 +- 刷新频率 +- 4 个能力开关 + +同时暴露在一个控制面里,造成两个直接后果: + +- 用户不知道应该先做什么 +- 长标签只能被塞进窄栅格,最终出现强制断行甚至炸版 + +### 1.3 长内容没有被放进正确的容器 + +当前界面至少存在三类高风险内容: + +- 长能力名称,例如“工作区与会话归因” +- 长实体名称,例如 workspace / session 标识 +- 长路径,例如子进程二进制路径 + +这些内容目前缺乏清晰的容器边界、换行策略和截断策略,因此会反复出现: + +- 在窄列中被硬挤断 +- 撑破卡片宽度 +- 破坏相邻模块的对齐关系 + +### 1.4 数据区和设置区在争抢主视觉 + +当前页面没有回答一个最基本的问题:进入监控页后,用户应该先看什么? + +结果是: + +- 设置区很重 +- 数据区也很重 +- 两者同时占据首屏主位 + +这使页面既不像“实时监控看板”,也不像“清晰的配置页”。 + +--- + +## 2. 设计目标 + +### 2.1 目标 + +- 让用户在进入页面后,先看到健康信号,再做采样层级调整 +- 把一级控制收束到首屏,把二级能力下沉到折叠区 +- 让页面严格遵循用户的真实工作流:`健康总览 -> 归因定位 -> 子进程下钻` +- 让所有长内容都进入稳定容器,不再裸露在等宽栅格中 +- 让空态、禁用态、降级态、刷新失败态彼此可区分 +- 让同一套布局规则同时适用于桌面端和移动端 + +### 2.2 非目标 + +- 不追求做成重型运维平台 +- 不在首屏展示所有可配项 +- 不增加新的指标维度来“掩盖”现有结构问题 +- 不用复杂动效掩饰层级和布局缺陷 + +--- + +## 3. 最终设计方向 + +### 3.1 采用 `B. 信号优先型` + +页面首先回答“现在是否健康”,然后才回答“要不要调整采样”和“是谁在吃资源”。 + +这意味着: + +- 首屏信号带成为唯一的控制入口 +- 主机和运行时总览先于归因和子进程出现 +- 高级采样能力不再平铺 + +### 3.2 采用 `B1. 摘要条 + 折叠高级项` + +桌面端首屏只保留一条摘要控制带,二级能力收进折叠区: + +- 首屏暴露:总开关、预设、刷新频率、当前模式、最后更新时间、刷新动作 +- 折叠后暴露:主机指标、运行时概览、工作区与会话归因、子进程钻取 + +这样做的核心价值不是“更简洁”,而是从结构上阻断再次炸版: + +- 长文案不会再被塞进同一排小列 +- 一级控制和二级能力不再同权竞争空间 +- 后续即使再补说明文案,也只会影响折叠区内部,而不会破坏首屏 + +--- + +## 4. 页面结构 + +### 4.1 总体工作流 + +页面改为严格的三段式工作流: + +1. `健康总览` +2. `归因定位` +3. `子进程下钻` + +该顺序直接映射用户确认的使用链路: + +1. 先看整机和 Coder Studio 是否健康 +2. 再看是哪个 workspace / session 在吃资源 +3. 最后再定位到具体子进程或路径 + +### 4.2 第一段:健康总览 + +首屏由以下内容组成: + +- 顶部信号带 +- 两张总览主卡: + - `主机概览` + - `Coder Studio 占用` + +职责划分: + +- 信号带负责控制和状态 +- 总览主卡负责回答“机器是否正常”和“Coder Studio 当前占用如何” + +首屏不出现归因树、不出现长路径、不出现四个二级能力开关的平铺。 + +### 4.3 第二段:归因定位 + +第二段只回答“是谁在吃资源”,由两部分构成: + +- 左侧归因列表 / 归因树 +- 右侧详情面板 + +规则: + +- 列表负责选择,不负责展开全部解释 +- 详情负责展示选中实体的解释性数据和趋势 +- 二者不得混排为同一卡片 + +### 4.4 第三段:子进程下钻 + +第三段只回答“具体落到了哪个子进程 / 路径”,独立于归因区出现。 + +理由: + +- 长路径是高风险内容,不应出现在首屏 +- 子进程下钻属于第三层任务,不应与健康总览争抢位置 +- 将其下沉可以显著降低页面主轴的噪音密度 + +--- + +## 5. 顶部控制区 + +### 5.1 首屏仅保留一级控制 + +顶部信号带只允许包含以下内容: + +- `总开关` +- `预设` +- `刷新频率` +- `当前模式` +- `最后更新时间` +- `刷新动作` + +其中: + +- `总开关` 是唯一主控,视觉权重最高 +- `预设` 和 `刷新频率` 是核心次级控制 +- `当前模式` 和 `最后更新时间` 是状态信息,不应与主控争夺视觉中心 + +### 5.2 不再平铺二级能力 + +以下能力统一从首屏移除,收进“高级采样能力”折叠区: + +- 主机指标 +- 运行时概览 +- 工作区与会话归因 +- 子进程钻取 + +这些能力不再使用“标签 + 开关 + 等宽列平铺”的做法。 + +### 5.3 高级采样能力的展开方式 + +展开后每项能力都渲染为一张独立能力卡,卡片内部结构统一为: + +- 标题 +- 一句说明 +- 依赖或影响提示 +- 右侧开关 + +例如: + +- 关闭 `运行时概览` 后,`工作区与会话归因` 和 `子进程钻取` 显示为受上游依赖影响 +- 用户不需要靠记忆推导依赖关系 + +--- + +## 6. 卡片骨架与长内容规则 + +### 6.1 统一卡片骨架 + +所有监控卡统一使用同一骨架: + +1. 标题区 +2. 关键数值区 +3. 内容区(趋势 / 列表 / 说明) +4. 状态区(空态 / 错误 / 降级) + +这要求: + +- 标题对齐规则一致 +- 指标数字对齐规则一致 +- 图表区高度策略一致 +- 空态和异常态不再随意占满整卡 + +### 6.2 长能力名称规则 + +长能力名称例如“工作区与会话归因”: + +- 不再进入首屏紧凑控制带 +- 仅出现在折叠区能力卡正文中 +- 最多允许两行 +- 超出两行时通过更长卡体容纳,而不是压缩相邻模块 + +### 6.3 长实体名称规则 + +归因树 / 列表中的 workspace、session、agent 名称: + +- 列表中最多显示两行 +- 主要资源值单独成列,右对齐 +- 实体名称永远不与指标数值混在同一文本流中 + +### 6.4 长路径规则 + +子进程二进制路径只允许出现在: + +- 子进程下钻区 +- 详情面板中的代码式内容容器 + +要求: + +- 支持断词换行 +- 支持多行展示 +- 不允许撑破卡片边界 +- 不允许放进首屏摘要或列表行 + +### 6.5 数字排版规则 + +所有数值字段统一采用: + +- 右对齐 +- 等宽数字 +- 稳定列宽 + +避免刷新时产生明显跳动。 + +--- + +## 7. 状态模型 + +### 7.1 允许的页面状态 + +页面只允许以下五类主状态: + +- `未启用监控` +- `加载中` +- `正常采样` +- `降级采样` +- `刷新失败` + +### 7.2 未启用监控 + +未启用时: + +- 顶部信号带仍然存在 +- 数据卡不展示假数据 +- 健康总览区域改为温和的 dormant state +- 明确告诉用户当前未进行采样 + +### 7.3 加载中 + +加载中采用固定高度 skeleton: + +- 首屏卡片占位高度稳定 +- 不因真实数据到来而产生明显布局抖动 + +### 7.4 降级采样 + +降级时: + +- 保留已有快照 +- 仅在受影响的区域显示降级说明 +- 不整页报错 + +### 7.5 刷新失败 + +刷新失败时: + +- 保留上一帧可用数据 +- 在顶部状态区显示失败提示和重试动作 +- 不清空页面 + +--- + +## 8. 空态与依赖态 + +### 8.1 空态必须可解释 + +以下状态必须明确区分: + +- 当前未启用监控 +- 当前没有活跃归因负载 +- 当前没有可观测子进程负载 +- 当前能力未开启 +- 当前能力因上游依赖未满足而不可用 + +### 8.2 归因空态 + +归因为空时,明确表达: + +- 当前没有活跃工作区或会话产生可观测负载 + +### 8.3 子进程空态 + +子进程为空时,明确表达: + +- 当前没有可观测子进程产生负载 + +### 8.4 依赖态 + +当上游能力关闭时,下游卡片不应只显示空白。 + +例如: + +- `运行时概览` 关闭时,`归因` 和 `子进程钻取` 应直接显示“依赖未满足” + +--- + +## 9. 交互与移动端退化 + +### 9.1 交互原则 + +本页交互以清晰为主,不做装饰性动效: + +- 开关和分段控件反馈控制在 `150-220ms` +- 切换预设后,受影响的能力卡同步更新 +- 点击归因实体后,详情面板平滑替换内容,但不改变整体骨架 +- 刷新时保留旧数据,仅对刷新按钮显示 busy 反馈 + +### 9.2 移动端结构 + +移动端强制退化为单列,顺序保持一致: + +1. 信号带 +2. 健康总览 +3. 归因定位 +4. 子进程下钻 +5. 高级采样能力折叠区 + +移动端默认不在首屏露出高级采样能力。 + +### 9.3 移动端详情策略 + +移动端点击归因实体后: + +- 详情直接在列表下方展开 +- 不额外开新路由 +- 不弹出重型浮层 + +--- + +## 10. 验收标准 + +本轮设计只有在以下条件全部满足时,才算真正解决了这次截图暴露的问题: + +- 桌面端首屏不再出现长标签被窄列挤爆 +- 一级控制不再与二级能力平铺在同一层 +- 长实体名和长路径都不会撑破卡片边界 +- 首屏先看到健康状态,而不是被设置项淹没 +- `未启用 / 加载中 / 正常 / 降级 / 刷新失败` 五类状态能被快速区分 +- `归因为空 / 子进程为空 / 能力未开启 / 依赖未满足` 四类非正常内容态能被快速区分 +- 在新增更长能力文案的情况下,整体布局仍然稳定,不需要重排首屏结构 + +--- + +## 11. 实现影响 + +预期会影响以下区域: + +- `packages/web/src/features/settings/components/monitoring-settings-subpage.tsx` +- `packages/web/src/features/settings/components/monitoring-settings-card.tsx` +- `packages/web/src/features/monitoring/page.tsx` +- `packages/web/src/styles/components.css` + +本轮重点不是新增更多组件,而是重组已有组件的责任边界: + +- `MonitoringSettingsSubpage` 负责整体工作流编排 +- `MonitoringSettingsCard` 退化为“信号带 + 高级采样能力”控制容器 +- `MonitoringDashboard` 负责数据工作流的三段式渲染 + +--- + +## 12. 结论 + +这次重做的本质不是做一版“更好看的深色监控页”,而是给监控子页建立一套不会被真实数据轻易打穿的层级系统。 + +核心决策只有三个: + +- 首屏只保留一级控制 +- 页面结构严格遵循 `健康总览 -> 归因定位 -> 子进程下钻` +- 所有长内容必须进入稳定容器,而不是直接暴露在等宽栅格里 + +只要这三条被严格落实,当前截图中的换行、拥挤、层级混乱和路径炸版问题就不会继续重复出现。 diff --git a/docs/superpowers/specs/2026-05-29-workspace-search-replace-vscode-alignment-design.md b/docs/superpowers/specs/2026-05-29-workspace-search-replace-vscode-alignment-design.md new file mode 100644 index 00000000..90c1b912 --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-workspace-search-replace-vscode-alignment-design.md @@ -0,0 +1,815 @@ +# Workspace Search Replace VS Code Alignment Design + +> Status: Draft +> Date: 2026-05-29 +> Scope: `packages/web/src/features/workspace/views/shared/search-panel.tsx`, `packages/web/src/features/workspace/views/mobile/mobile-files-sheet.tsx`, `packages/web/src/features/workspace/atoms/*`, `packages/web/src/features/code-editor/*`, `packages/web/src/styles/components.css`, `packages/server/src/commands/file.ts`, `packages/server/src/fs/*`, related tests + +## Goal + +Upgrade the workspace `Search` surface so desktop and mobile both align much more closely with VS Code search-and-replace behavior. + +The target outcome: + +- remove the current `查询 / 结果` dual-section presentation +- add progressive `Replace` and `Search Details` controls +- support `Match Case`, `Whole Word`, `Regex`, and `Preserve Case` +- support VS Code style `files to include` and `files to exclude` glob filters +- support `only open editors` and ignore-rule toggles +- add hierarchical replace actions for all results, per file, and per match +- add file-level diff preview for replacements using the existing unified editor surface +- keep one shared behavior model across desktop and mobile + +## Relationship To Existing Specs + +This design extends and partially supersedes the search behavior defined in: + +- [2026-05-23-workspace-sidebar-search-quick-open-design.md](/home/spencer/workspace/coder-studio/docs/superpowers/specs/2026-05-23-workspace-sidebar-search-quick-open-design.md) +- [2026-05-23-workspace-search-quick-open-visual-refresh-design.md](/home/spencer/workspace/coder-studio/docs/superpowers/specs/2026-05-23-workspace-search-quick-open-visual-refresh-design.md) +- [2026-05-27-workspace-panel-balanced-workbench-design.md](/home/spencer/workspace/coder-studio/docs/superpowers/specs/2026-05-27-workspace-panel-balanced-workbench-design.md) + +It also reuses the diff-hosting direction from: + +- [2026-05-20-unified-editor-surface-design.md](/home/spencer/workspace/coder-studio/docs/superpowers/specs/2026-05-20-unified-editor-surface-design.md) + +Those earlier documents establish: + +- the `Search` sidebar as a dedicated file-content search surface +- the compact workbench grammar for sidebar panels +- the unified editor surface as the canonical place to show text diff + +This document adds the missing VS Code-like replace, preview, filter, and edge-case behavior on top of that base. + +## In Scope + +- desktop `Search` sidebar redesign for VS Code-like search and replace +- mobile `Search` surface alignment with the same capability set +- advanced search toggles and progressive disclosure +- include and exclude glob filters +- `only open editors` search mode +- ignore and exclude rule toggles +- hierarchical replace actions: + - replace all results + - replace all in one file + - replace one match +- replacement preview summaries in the search results tree +- diff preview handoff into the unified editor surface +- backend search session and apply behavior +- conflict handling, partial-success reporting, and stale-session handling +- unit, integration, and visual test coverage for the new model + +## Out Of Scope + +- command-palette style mixed search results +- symbol search +- cross-workspace search +- search history or saved search presets +- replace preview editing inside the diff surface +- search results spanning unsaved in-memory editor buffers +- a full clone of every VS Code setting and search preference +- arbitrary file-to-file compare + +## Problem + +The current `SearchPanel` is intentionally narrow: + +- one search box +- static filter chips +- grouped content results +- no replace workflow +- no search-details expansion +- no regex, whole-word, or preserve-case behavior +- no include or exclude filters +- no preview-before-apply flow + +That creates four product gaps. + +### 1. The current search UI is not editor-grade + +The surface still reads like a simple app tool rather than a true code-editor search view. + +The strongest signals: + +- duplicate `查询 / 结果` section titles +- placeholder filter chips that do not correspond to real search semantics +- no replace workflow +- no advanced file scoping controls + +### 2. Search and replace semantics are missing + +Users can locate content but cannot use the search view as a structured refactoring or text migration tool. + +That prevents common editor workflows such as: + +- renaming repeated text across files +- scoped regex replacement +- reviewing replacement previews before writing + +### 3. The current backend is too narrow for VS Code alignment + +`file.searchContent` returns grouped matches and works well for simple search, but it does not model: + +- replacement text +- regex captures +- preserve-case behavior +- include and exclude scope +- diff preview payloads +- stale-session or partial-apply reporting + +### 4. Mobile and desktop would diverge if replace were bolted on ad hoc + +If replace is added only to the desktop layout or only as a front-end-only calculation, the product will quickly split into: + +- a richer desktop-only editor workflow +- a weaker mobile-only search viewer + +That is not acceptable for this work. The capability model must stay shared, while the layout adapts per viewport. + +## Decision Summary + +Adopt a VS Code-leaning search session architecture with unified backend semantics and shared desktop/mobile behavior. + +### UI Direction + +- remove repeated inner panel titles +- keep the runtime module identity in the workbench shell +- use progressive disclosure for replace and advanced search details +- increase visual density on desktop +- preserve the same capability set on mobile with tighter layout and deeper folding + +### Backend Direction + +- keep `file.searchContent` for simple grouped content search consumers +- add a dedicated search session command set for replace-capable search +- compute match ranges, replacement previews, and apply behavior on the server +- treat replacement diff preview as editor content state, not a separate page + +### Replace Direction + +- support: + - replace all + - replace all in file + - replace single match +- preview is required before writing +- apply uses conflict detection and may partially succeed + +This is the recommended design because it gets close to VS Code behavior without forcing the entire workspace navigation model to be rebuilt again. + +## Product Behavior + +## Search Surface Structure + +The `Search` panel body should stop rendering the current `查询` and `结果` section titles. + +Instead, the running UI should present: + +- workbench-level `SEARCH` view identity from the surrounding shell +- search controls immediately at the top of the panel body +- result summary and action area immediately below +- grouped results tree below that + +This follows the same workbench grammar established in the balanced sidebar design: + +- no boxed card sections +- no repeated panel title inside the panel body +- compact tool-like controls + +## Progressive Disclosure + +The top area has three disclosure levels. + +### Level 1: Primary Search + +Always visible: + +- primary search input +- `Match Case` +- `Whole Word` +- `Regex` +- action affordance to expand replace +- action affordance to expand search details + +### Level 2: Replace + +Collapsed by default. + +When expanded, show: + +- replacement input +- `Preserve Case` + +Replace remains expanded until the user collapses it or the panel state resets for another reason such as workspace change. + +### Level 3: Search Details + +Collapsed by default. + +When expanded, show: + +- `files to include` +- `files to exclude` +- `only open editors` toggle +- ignore and exclude rule toggle + +Desktop can show these in a denser stack. Mobile keeps the same controls but allows them to fold more aggressively. + +## Search Controls + +### Primary Query + +- scope: active workspace only +- debounce: `250ms` +- empty query: + - no result tree + - instructional empty state +- loading: + - compact loading summary +- invalid regex: + - inline error below the input + - no result tree render + +### Match Case + +When enabled, matching is case-sensitive. + +### Whole Word + +When enabled, matching must satisfy whole-word boundaries. + +### Regex + +When enabled: + +- the query is interpreted as a regular expression +- replacement supports capture groups +- invalid patterns are reported inline + +### Preserve Case + +Only meaningful when replace is expanded and replacement text is non-empty. + +When enabled, replacement casing should adapt to the matched text shape similarly to VS Code. + +Examples: + +- `foo` -> `bar` +- `Foo` -> `Bar` +- `FOO` -> `BAR` + +The exact adaptation should be documented in code tests, not inferred separately in front-end code. + +## Include And Exclude Filters + +The search-details area should align with VS Code style glob semantics. + +### `files to include` + +- accepts glob-style patterns +- supports comma-separated patterns +- uses `/` as the normalized separator +- narrows the search scope to matching files only + +### `files to exclude` + +- accepts glob-style patterns +- supports comma-separated patterns +- removes matching files from the search scope + +### Only Open Editors + +This toggle mirrors the VS Code “search only in open editors” behavior. + +When enabled: + +- search only considers currently open editor file paths +- include and exclude filters still apply inside that narrowed set + +### Ignore And Exclude Rules + +The search view should expose one compact toggle controlling whether standard ignore and exclude sources are applied. + +When enabled, the search service should honor: + +- `.gitignore` +- `.ignore` +- `.rgignore` +- `.git/info/exclude` +- workspace-level exclude inputs passed through the command model + +This document does not require a full settings subsystem. It does require one clear user-visible toggle for “use standard ignore/exclude rules”. + +## Result Summary + +When a query resolves successfully, the summary row should show: + +- total visible matches +- total visible files +- truncation note when applicable +- skipped-file note when applicable + +When replace is expanded and replacement text is provided, the summary area should also expose: + +- `Replace All` + +The summary must stay compact and tool-like. It should not become a large empty-state banner. + +## Result Tree + +Results remain grouped by file. + +Each file group shows: + +- expand or collapse chevron +- file name +- relative path +- file match count +- file-level `Replace` when replace is active + +Each match row shows: + +- line number +- matched text preview +- replacement preview hint when replace is active +- `Preview` +- single-match `Replace` + +The result rows should remain denser than the current implementation, especially on desktop. + +## Default Expansion + +After every successful query: + +- all file groups default to expanded + +Collapse state is scoped to the current result set and resets after a new successful query. + +## Replace Preview In Result Rows + +When replacement is active, each match row should communicate both: + +- what matched +- what will replace it + +This does not require rendering full multi-line diff inside the row. + +It does require a clear inline preview contract such as: + +- search-side snippet +- replacement-side snippet +- truncation marker if the line context is clipped + +The full diff remains the job of the dedicated preview flow. + +## Match Click Behavior + +Clicking the match row itself should continue to: + +- open the file +- navigate to the matched location +- keep the search view open + +This remains separate from `Preview`. + +## Diff Preview Behavior + +`Preview` should open the existing unified editor surface in diff mode for the selected file under the current search session. + +The preview should show: + +- original file content on the left or original side +- replacement result on the modified side + +This is a search-replace preview, not a git preview. + +The editor shell should remain the existing unified editor surface rather than creating a separate search preview page. + +## Mobile Behavior + +Mobile must keep the same feature set: + +- replace expansion +- search details expansion +- include and exclude filters +- regex, case, whole-word, preserve-case toggles +- result hierarchy +- preview and replace actions + +The adaptations are only presentational: + +- tighter row layout +- more aggressive collapsing of advanced controls +- action labels may compress, but capability must remain + +No desktop-only search-and-replace workflow is allowed in this design. + +## Search Session Architecture + +## Why A Search Session + +VS Code-like replace requires one consistent semantic source for: + +- matches +- replacement previews +- diff preview payloads +- apply scopes +- stale detection + +Trying to compute previews independently in the client would create drift between: + +- visible result previews +- full-file diff previews +- final apply output + +That drift is unacceptable. + +## Keep `file.searchContent` + +Retain the current `file.searchContent` command for simple grouped-content-search consumers and backward compatibility. + +Do not overgrow it into a replace-aware command. + +## New Command Surface + +Add a dedicated session-based command set: + +- `file.searchSession.start` +- `file.searchSession.previewFile` +- `file.searchSession.apply` + +The naming can be adjusted during implementation if the project has a stronger command taxonomy preference, but the session split is required. + +## `file.searchSession.start` + +This command creates or refreshes a replace-capable search result set. + +### Inputs + +- `workspaceId` +- `query` +- `replace` +- `isRegex` +- `matchCase` +- `matchWholeWord` +- `preserveCase` +- `includeGlobs` +- `excludeGlobs` +- `useIgnoreFiles` +- `useExcludeSettings` +- `onlyOpenEditors` +- `openEditorPaths` +- bounded result limits + +### Output + +- `sessionId` +- aggregate counts +- grouped file results +- exact match ranges +- inline replacement previews +- per-file `baseHash` +- truncation flags +- skipped-file counts or reasons + +### Required Behavior + +- whitespace-only query returns an empty result set with no session work +- invalid regex returns structured validation error +- binary and unsupported files are skipped rather than hard-failing the session + +## `file.searchSession.previewFile` + +This command resolves the complete preview payload for one file in one session. + +### Inputs + +- `workspaceId` +- `sessionId` +- `path` + +### Output + +- `path` +- `baseHash` +- `originalContent` +- `modifiedContent` +- match and replacement ranges +- preview metadata sufficient for editor diff labeling + +This output must be derived from the same replacement engine used by apply. + +## `file.searchSession.apply` + +One command should cover all replace scopes. + +### Inputs + +- `workspaceId` +- `sessionId` +- `scope` + - `all` + - `file` + - `match` +- target identifiers for the chosen scope + +### Output + +- aggregate outcome counts +- per-file outcome records +- refreshed or stale-session signal + +### Per-file statuses + +- `applied` +- `conflict` +- `skipped` +- `not_found` + +The exact enum can change, but the result must be structured enough for the UI to report partial success clearly. + +## Search And Replace Engine Rules + +## Candidate Discovery + +Use `ripgrep` for fast workspace candidate discovery and glob scoping. + +This layer is responsible for: + +- workspace traversal +- ignore handling +- glob filtering +- initial candidate narrowing + +## Semantic Resolution + +Do not trust `ripgrep` alone as the final semantic source for replace. + +After candidate discovery, the server must apply one unified text engine for: + +- exact match range resolution +- whole-word boundary checks +- regex replacement +- capture-group substitution +- preserve-case transformation +- inline preview generation +- file preview generation +- final apply payloads + +In short: + +- `rg` is used for speed +- the server text engine is used for correctness + +## Encoding And File-Type Rules + +The engine should: + +- skip binary files +- skip non-text files that cannot be processed safely +- skip files over a bounded size threshold if needed for safety + +The result summary must indicate when files were skipped rather than silently hiding that fact. + +## Session Lifecycle + +Search sessions are short-lived and parameter-bound. + +If any of these change, the client should treat the existing session as invalid and restart: + +- query +- replacement text +- regex +- case +- whole-word +- preserve-case +- include globs +- exclude globs +- only-open-editors +- ignore toggle + +`Preview` and `Apply` must carry the session id. + +If the server detects: + +- missing session +- parameter mismatch +- file state drift that invalidates the session model + +it should return `stale_session`, and the client should rerun `start`. + +## Diff Preview Integration + +## Integration Direction + +The current unified editor surface already hosts diff mode for git-backed workflows. + +Search replace preview should reuse that surface instead of adding a separate full-page viewer. + +## New Preview Kind + +Extend the diff-preview state model with a search-replace preview variant such as: + +- `search-replace-file-diff` + +The exact type name can vary, but it must carry: + +- file path +- title +- original content +- modified content +- base hash +- source metadata identifying the search session + +## Preview Entry Points + +- `Preview` from one search result row opens the file diff preview +- file-level replace preview may optionally reuse the same route + +Opening a search preview should not erase the user’s search result state. Returning to the panel should preserve the current result tree until the query changes or a refresh is required. + +## Apply And Conflict Behavior + +## Conflict Model + +Apply must use `baseHash` conflict detection derived from the session snapshot. + +No blind write is allowed. + +If a file changed after the session snapshot: + +- match apply for that file fails with `conflict` +- no replacement is written for that file in that apply operation + +## Replace All + +Replace-all is allowed to partially succeed. + +The UI should report: + +- applied file count +- conflict file count +- skipped file count + +Do not block the entire operation behind a modal if some files conflict. + +Instead: + +- keep the result tree visible +- mark failed files or surface failure summary inline +- allow the user to rerun search after resolving conflicts + +## Refresh After Apply + +After any successful or partially successful apply: + +- rerun the current search session automatically + +This guarantees the visible result tree matches disk state. + +## Unsaved Editor Buffers + +This design does not attempt to search unsaved in-memory edits from already-open files. + +Search and replace operate on workspace files on disk. + +That keeps the semantics aligned with the current file-read and file-write backend model. + +If a file is dirty in the editor and the user runs replace from the search panel, the implementation should favor a safe and explicit behavior rather than silently merging in-memory edits. + +The simplest acceptable v1 behavior is: + +- search session works from disk state +- apply may conflict if disk state changed relative to the captured hash + +Any richer dirty-buffer reconciliation is future work. + +## State Model Changes + +## Search Panel State + +The current `SearchPanelState` is too small for the new workflow. + +It should grow to include: + +- query +- replace text +- toggle states +- include and exclude strings +- only-open-editors flag +- ignore toggle +- expanded file groups +- selected match key +- active session id +- session status +- inline validation errors +- apply progress and summary state + +This state remains workspace-scoped. + +## Shared Desktop And Mobile Model + +Desktop and mobile should read from the same logical search model. + +Do not fork search semantics by variant. The variant only changes layout and affordance density. + +## Testing Strategy + +## Server Tests + +Add or update tests for: + +- plain-string search session creation +- regex search session creation +- invalid regex reporting +- whole-word filtering +- preserve-case replacement +- capture-group replacement +- include-glob filtering +- exclude-glob filtering +- ignore toggle behavior +- only-open-editors filtering +- preview payload generation +- single-match apply +- single-file apply +- replace-all apply +- partial-success apply with conflicts +- stale-session handling + +## Client Tests + +Add or update tests for: + +- search control progressive disclosure +- replace expansion and collapse +- search details expansion and collapse +- toggle state rendering for case, whole-word, regex, preserve-case +- include and exclude input behavior +- only-open-editors toggle behavior +- grouped result rendering with replacement previews +- file-level and match-level action rendering +- preview handoff into editor diff state +- apply summary and automatic result refresh +- stale-session recovery behavior + +## Visual And Interaction Tests + +Desktop and mobile coverage should confirm: + +- no repeated inner panel title +- compact workbench density +- advanced controls placement +- result row hierarchy +- replace action hierarchy +- diff preview handoff + +## Risks + +### 1. Client And Server Semantic Drift + +If any replacement preview logic lives in the client, the UI and apply behavior will diverge. + +Mitigation: + +- server owns match and replacement semantics +- client only renders returned data + +### 2. Session Complexity Expands Too Far + +The session model could grow into a broad search service too early. + +Mitigation: + +- keep scope focused on search-and-replace only +- preserve `file.searchContent` separately for simple search use cases + +### 3. Conflict Reporting Becomes Opaque + +If partial apply results are not structured well, users will not know what changed. + +Mitigation: + +- require per-file outcome records +- rerun search after apply +- preserve visible summary of failures + +### 4. Mobile Layout Regressions + +Adding full replace capability could overwhelm the mobile search sheet. + +Mitigation: + +- share capability model +- adapt layout with stronger folding +- cover mobile interaction with dedicated tests + +## Summary + +This design moves workspace search from a simple grouped-content finder to a VS Code-like search-and-replace workflow. + +The key architectural decision is to centralize replace semantics in a short-lived server-side search session, while reusing the unified editor diff surface for preview. + +That gives the product: + +- authentic search-and-replace controls +- consistent preview and apply behavior +- shared desktop and mobile capability +- explicit conflict handling +- a cleaner path for future search refinements without rebuilding the entire panel again diff --git a/e2e-ui/specs/workspace-panel-current-audit.spec.ts b/e2e-ui/specs/workspace-panel-current-audit.spec.ts new file mode 100644 index 00000000..2c360bab --- /dev/null +++ b/e2e-ui/specs/workspace-panel-current-audit.spec.ts @@ -0,0 +1,252 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { expect, type Page, test } from "@playwright/test"; +import { disableAnimations, waitForStableScene } from "../fixtures/capture"; +import { openPreviewScene } from "../fixtures/prefs"; + +const repoRoot = path.resolve(process.cwd(), ".."); +const assetDir = path.join( + repoRoot, + "docs/superpowers/reviews/assets/2026-05-27-workspace-panel-current-audit" +); + +async function ensureAssetDir() { + await fs.mkdir(assetDir, { recursive: true }); +} + +function assetPath(filename: string) { + return path.join(assetDir, filename); +} + +async function captureDesktopSidebar(page: Page, filename: string) { + const sidebar = page.locator(".left-panel .workspace-sidebar-panel").first(); + await sidebar.waitFor({ state: "visible" }); + const box = await sidebar.boundingBox(); + + if (!box) { + throw new Error("Workspace sidebar panel is not visible for capture"); + } + + await page.screenshot({ + path: assetPath(filename), + animations: "disabled", + clip: { + x: box.x, + y: box.y, + width: 402, + height: 621, + }, + scale: "device", + }); +} + +async function prepareDesktopExplorerAuditState(page: Page) { + const closeAll = page.locator(".workspace-open-editors__close-all").first(); + + if (await closeAll.isEnabled()) { + await closeAll.click(); + } + + await expect(page.locator(".workspace-open-editors__count")).toHaveText("0"); + await expect(page.locator(".workspace-open-editors__row")).toHaveCount(0); + + // The balanced reference intentionally shows a selected README.md row while + // Open Editors is 0. Keep production state honest and apply only the audit + // selection marker needed for static visual comparison. + const readmeRow = page.locator(".file-tree-shell .tree-item", { hasText: "README.md" }).first(); + await readmeRow.waitFor({ state: "visible" }); + await readmeRow.evaluate((row) => { + row.classList.add("selected", "workspace-sidebar-row--selected"); + if (!row.querySelector(".tree-active-meta")) { + const activeMeta = document.createElement("span"); + activeMeta.className = "tree-active-meta"; + activeMeta.textContent = "active"; + row.append(activeMeta); + } + }); +} + +async function prepareDesktopGitAuditState(page: Page) { + const firstChangeRow = page.locator(".git-row", { hasText: "app.tsx" }).first(); + + await firstChangeRow.waitFor({ state: "visible" }); + await firstChangeRow.evaluate((row) => { + row.classList.add("active", "workspace-sidebar-row--selected"); + }); +} + +async function prepareMobileExplorerAuditState(page: Page) { + const closeAll = page.locator(".workspace-open-editors__close-all").first(); + + if (await closeAll.isEnabled()) { + await closeAll.click(); + } + + await expect(page.locator(".workspace-open-editors__count")).toHaveText("0"); + await expect(page.locator(".workspace-open-editors__row")).toHaveCount(0); +} + +test("capture desktop workspace sidebar states", async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop", "desktop audit only"); + + await ensureAssetDir(); + await openPreviewScene(page, { + sceneId: "workspace-desktop", + device: "desktop", + theme: "mint-dark", + locale: "zh", + }); + await waitForStableScene(page); + + const sidebar = page.locator(".left-panel .workspace-sidebar-panel").first(); + await sidebar.waitFor({ state: "visible" }); + await prepareDesktopExplorerAuditState(page); + await captureDesktopSidebar(page, "desktop-current-explorer.png"); + + await page.locator(".workspace-activity-bar__button").nth(1).click(); + const desktopSearchInput = page.locator(".workspace-search-panel__input").first(); + await desktopSearchInput.fill("searchQuery"); + await expect(page.locator("text=packages/web/src/app.tsx")).toBeVisible(); + await desktopSearchInput.evaluate((input) => { + input.setAttribute("data-capture-value", (input as HTMLInputElement).value); + (input as HTMLInputElement).value = ""; + }); + await page.waitForTimeout(120); + await captureDesktopSidebar(page, "desktop-current-search-expanded.png"); + await desktopSearchInput.evaluate((input) => { + (input as HTMLInputElement).value = input.getAttribute("data-capture-value") ?? ""; + input.removeAttribute("data-capture-value"); + }); + + const desktopFirstSearchGroup = page.locator(".workspace-search-panel__group-header").first(); + await desktopFirstSearchGroup.click(); + await expect(desktopFirstSearchGroup).toHaveAttribute("aria-expanded", "false"); + await desktopSearchInput.evaluate((input) => { + input.setAttribute("data-capture-value", (input as HTMLInputElement).value); + (input as HTMLInputElement).value = ""; + }); + await page.waitForTimeout(120); + await captureDesktopSidebar(page, "desktop-current-search-collapsed.png"); + await desktopSearchInput.evaluate((input) => { + (input as HTMLInputElement).value = input.getAttribute("data-capture-value") ?? ""; + input.removeAttribute("data-capture-value"); + }); + + await page.locator(".workspace-activity-bar__button").nth(2).click(); + await expect(page.locator(".git-panel")).toBeVisible(); + await expect(page.locator(".git-panel-section-history .git-panel-section-count")).toHaveText("0"); + await prepareDesktopGitAuditState(page); + await expect(page.locator(".git-row.active")).toContainText("app.tsx"); + await page.waitForTimeout(120); + await captureDesktopSidebar(page, "desktop-current-git.png"); +}); + +test("capture mobile workspace sidebar states", async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== "mobile", "mobile audit only"); + + await ensureAssetDir(); + await openPreviewScene(page, { + sceneId: "workspace-mobile", + device: "mobile", + theme: "mint-dark", + locale: "zh", + }); + await waitForStableScene(page); + + await page.locator(".mobile-dock__item").nth(1).click(); + const mobileSheet = page.locator(".mobile-sheet--files").first(); + await mobileSheet.waitFor({ state: "visible" }); + await prepareMobileExplorerAuditState(page); + await page.waitForTimeout(120); + await mobileSheet.screenshot({ + path: assetPath("mobile-current-explorer.png"), + animations: "disabled", + scale: "device", + }); + + await page.locator(".mobile-files-sheet__segment").nth(1).click(); + const mobileSearchInput = page.locator(".workspace-search-panel__input").first(); + await mobileSearchInput.fill("needle"); + await expect( + page.locator(".workspace-search-panel__group-name").filter({ hasText: "app.tsx" }) + ).toBeVisible(); + await mobileSearchInput.evaluate((input) => { + input.setAttribute("data-capture-value", (input as HTMLInputElement).value); + (input as HTMLInputElement).value = ""; + }); + await page.waitForTimeout(120); + await mobileSheet.screenshot({ + path: assetPath("mobile-current-search-expanded.png"), + animations: "disabled", + scale: "device", + }); + await mobileSearchInput.evaluate((input) => { + (input as HTMLInputElement).value = input.getAttribute("data-capture-value") ?? ""; + input.removeAttribute("data-capture-value"); + }); + + const mobileFirstSearchGroup = page.locator(".workspace-search-panel__group-header").first(); + await mobileFirstSearchGroup.click(); + await expect(mobileFirstSearchGroup).toHaveAttribute("aria-expanded", "false"); + await mobileSearchInput.evaluate((input) => { + input.setAttribute("data-capture-value", (input as HTMLInputElement).value); + (input as HTMLInputElement).value = ""; + }); + await page.waitForTimeout(120); + await mobileSheet.screenshot({ + path: assetPath("mobile-current-search-collapsed.png"), + animations: "disabled", + scale: "device", + }); + await mobileSearchInput.evaluate((input) => { + (input as HTMLInputElement).value = input.getAttribute("data-capture-value") ?? ""; + input.removeAttribute("data-capture-value"); + }); + + await page.locator(".mobile-files-sheet__segment").nth(2).click(); + await expect(page.locator(".git-panel--mobile")).toBeVisible(); + await page.waitForTimeout(120); + await mobileSheet.screenshot({ + path: assetPath("mobile-current-git.png"), + animations: "disabled", + scale: "device", + }); +}); + +test("capture balanced workbench sidebar references", async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop", "desktop audit only"); + + await ensureAssetDir(); + await page.goto( + `file://${path.join( + repoRoot, + "docs/superpowers/reviews/2026-05-27-workspace-panel-balanced-workbench.html" + )}`, + { + waitUntil: "load", + } + ); + await disableAnimations(page); + await page.waitForTimeout(120); + + const mockPanels = page.locator(".mock-card .desktop-shell"); + + await mockPanels.nth(0).waitFor({ state: "visible" }); + await mockPanels.nth(0).screenshot({ + path: assetPath("balanced-workbench-desktop-explorer.png"), + animations: "disabled", + scale: "device", + }); + + await mockPanels.nth(1).screenshot({ + path: assetPath("balanced-workbench-desktop-search.png"), + animations: "disabled", + scale: "device", + }); + + await mockPanels.nth(2).screenshot({ + path: assetPath("balanced-workbench-desktop-git.png"), + animations: "disabled", + scale: "device", + }); +}); diff --git a/e2e/specs/workspace/launch-flow.spec.ts b/e2e/specs/workspace/launch-flow.spec.ts index 805eaee5..53a3ce3d 100644 --- a/e2e/specs/workspace/launch-flow.spec.ts +++ b/e2e/specs/workspace/launch-flow.spec.ts @@ -85,4 +85,34 @@ test.describe("session flow", () => { await expect(page.locator(".launch-modal")).toHaveCount(0); }); + + test("SF-08 workspace launch modal can create a folder inline and keep it selected", async ({ + page, + }) => { + await openWelcomeWorkspaceLaunchModal(page); + + const activePathChip = page.locator(".fp-chip.active").last(); + const previousPath = ((await activePathChip.textContent()) ?? "").trim(); + const folderName = `launch-folder-${Date.now()}`; + + await page + .getByRole("button", { name: translatePatternForE2E("workspace.launch.new_folder") }) + .click(); + + const nameInput = page.getByRole("textbox", { + name: translatePatternForE2E("workspace.launch.folder_name_label"), + }); + await expect(nameInput).toBeVisible(); + await nameInput.fill(folderName); + await nameInput.press("Enter"); + + await expect(nameInput).toHaveCount(0); + + const createdRow = page.locator(".fp-dir.selected").filter({ + has: page.locator(".fp-dir-name", { hasText: new RegExp(`^${folderName}$`) }), + }); + await expect(createdRow).toBeVisible({ timeout: 10000 }); + await expect(activePathChip).toHaveText(previousPath); + await expect(page.locator(".fp-dir-action")).toBeVisible(); + }); }); diff --git a/lsp-test/Cargo.toml b/lsp-test/Cargo.toml new file mode 100644 index 00000000..806ca84d --- /dev/null +++ b/lsp-test/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "probe" +version = "0.0.0" +edition = "2021" +publish = false + +[[bin]] +name = "probe" +path = "probe.rs" diff --git a/lsp-test/README.md b/lsp-test/README.md new file mode 100644 index 00000000..d71148c9 --- /dev/null +++ b/lsp-test/README.md @@ -0,0 +1,29 @@ +# LSP smoke-test fixtures + +A small set of single-file projects used to verify each managed LSP works end-to-end against the editor (hover / definition / references / diagnostics). Each file has a TYPE ERROR at the bottom on purpose so the diagnostic provider also gets exercised. + +| File | Language server | What to verify | +| --- | --- | --- | +| `probe.vue` | `@vue/language-server` (Volar 3) + `typescript-language-server` companion | hover on `ref`/`computed`/`defineProps`, F12 to `Props`, red squiggle on the type error | +| `probe.py` | `python-lsp-server` (pylsp, managed) | hover on `multiply_by`/`Greeter`, F12 across functions, pyflakes-level diagnostic | +| `probe.go` | `gopls` (managed) | hover on `MultiplyBy`/`Greeter.Greet`, F12 across functions, type-mismatch diagnostic | +| `probe.rs` + `Cargo.toml` | `rust-analyzer` (system rustup component or managed download) | hover on `multiply_by`/`Greeter`/`greet`, F12 across functions, type-mismatch diagnostic | + +## Why a `Cargo.toml` + +Unlike the other servers, **rust-analyzer refuses to provide semantic info for `.rs` files that don't belong to a Cargo project**. The minimal `Cargo.toml` in this directory declares `probe.rs` as a bin so rust-analyzer treats the directory as a workspace. + +> rust-analyzer also takes ~25s on cold start to finish `PrimeCaches` indexing, during which all hover/definition requests silently return `null`. See `docs/issue/rust-analyzer-indexing-no-progress-feedback.md`. + +## How to run + +1. Open coder-studio in the editor (dev or built). +2. Open any file in this directory. +3. First open triggers the LSP install if needed — look for the `Install` button in the inline notice. +4. Once the notice disappears, exercise the four LSP features listed above. + +For protocol-level debugging without the editor in the loop, see `scripts/probe-vue-bridge.mjs` and `scripts/probe-rust.mjs` — they spawn the language server directly and assert specific LSP responses. + +## Cleanup + +These fixtures are intentionally checked in so a new contributor can repeat the same smoke check on day one. Feel free to leave them in place; they don't affect any production build. diff --git a/lsp-test/probe.go b/lsp-test/probe.go new file mode 100644 index 00000000..f0450f21 --- /dev/null +++ b/lsp-test/probe.go @@ -0,0 +1,45 @@ +// Quick LSP smoke probe for gopls. +// +// Try in the editor once this file is open: +// +// 1. Hover over `numbers`, `total`, `MultiplyBy`, `Greeter`, `Greet` — +// each should show its inferred Go signature with package context. +// 2. Ctrl-Click (or F12) on `MultiplyBy` inside `ComputeTotal` to jump +// to its definition. +// 3. Shift+F12 on `Greet` to see references. +// 4. The line marked `// TYPE ERROR` should get a gopls diagnostic +// (passing a string where an int is expected). +// +// Note: gopls expects a real module to fully analyze; we declare a +// throwaway one here so the file is self-contained. + +package main + +import "fmt" + +func MultiplyBy(value, factor int) int { + return value * factor +} + +func ComputeTotal(numbers []int, factor int) int { + total := 0 + for _, n := range numbers { + total += MultiplyBy(n, factor) + } + return total +} + +type Greeter struct { + Name string +} + +func (g Greeter) Greet() string { + return fmt.Sprintf("Hello, %s!", g.Name) +} + +func main() { + fmt.Println(ComputeTotal([]int{1, 2, 3}, 4)) + fmt.Println(Greeter{Name: "Vue"}.Greet()) + // TYPE ERROR: passing a string where an int is expected. + fmt.Println(MultiplyBy("not a number", 2)) +} diff --git a/lsp-test/probe.py b/lsp-test/probe.py new file mode 100644 index 00000000..fe385c1d --- /dev/null +++ b/lsp-test/probe.py @@ -0,0 +1,41 @@ +"""Quick LSP smoke probe for python-lsp-server (pylsp). + +Try the following in the editor once this file is open: + +1. Hover over `numbers`, `total`, `multiply_by`, `Greeter`, `greet` — + each should show its inferred type / signature. +2. Ctrl-Click (or F12) on `multiply_by` inside `compute_total` to jump + to its definition. +3. Shift+F12 on `greet` to see references. +4. The line marked `# TYPE ERROR` should get a pylsp diagnostic + (pylsp ships with pyflakes / pycodestyle by default; the call passes + a string to a parameter typed as `int`). +""" + +from dataclasses import dataclass + + +def multiply_by(value: int, factor: int) -> int: + return value * factor + + +def compute_total(numbers: list[int], factor: int) -> int: + total = 0 + for n in numbers: + total += multiply_by(n, factor) + return total + + +@dataclass +class Greeter: + name: str + + def greet(self) -> str: + return f"Hello, {self.name}!" + + +if __name__ == "__main__": + print(compute_total([1, 2, 3], 4)) + print(Greeter("Vue").greet()) + # TYPE ERROR: passing a string where an int is expected. + print(multiply_by("not a number", 2)) diff --git a/lsp-test/probe.rs b/lsp-test/probe.rs new file mode 100644 index 00000000..82567a8e --- /dev/null +++ b/lsp-test/probe.rs @@ -0,0 +1,44 @@ +//! Quick LSP smoke probe for rust-analyzer. +//! +//! Try in the editor once this file is open: +//! +//! 1. Hover over `numbers`, `total`, `multiply_by`, `Greeter`, `greet` — +//! each should show its inferred Rust type or signature. +//! 2. Ctrl-Click (or F12) on `multiply_by` inside `compute_total` to jump +//! to its definition. +//! 3. Shift+F12 on `greet` to see references. +//! 4. The line marked `// TYPE ERROR` should get a rust-analyzer +//! diagnostic (passing a `&str` where `i64` is expected). +//! +//! Note: rust-analyzer is happiest inside a Cargo workspace, so a few +//! features may behave slightly differently here than they would in a +//! real crate, but hover / definition / references still work. + +fn multiply_by(value: i64, factor: i64) -> i64 { + value * factor +} + +fn compute_total(numbers: &[i64], factor: i64) -> i64 { + let mut total = 0; + for n in numbers { + total += multiply_by(*n, factor); + } + total +} + +struct Greeter { + name: String, +} + +impl Greeter { + fn greet(&self) -> String { + format!("Hello, {}!", self.name) + } +} + +fn main() { + println!("{}", compute_total(&[1, 2, 3], 4)); + println!("{}", Greeter { name: "Vue".to_string() }.greet()); + // TYPE ERROR: passing a &str where i64 is expected. + println!("{}", multiply_by("not a number", 2)); +} diff --git a/lsp-test/probe.vue b/lsp-test/probe.vue new file mode 100644 index 00000000..abcbbc58 --- /dev/null +++ b/lsp-test/probe.vue @@ -0,0 +1,15 @@ + + + \ No newline at end of file diff --git a/packages/core/src/domain/diagnostics.ts b/packages/core/src/domain/diagnostics.ts index 3ceec2b0..7a37a17e 100644 --- a/packages/core/src/domain/diagnostics.ts +++ b/packages/core/src/domain/diagnostics.ts @@ -1,3 +1,5 @@ +import type { SystemDependencyId } from "./system-dependency-install"; + export type DiagnosticsContext = | "workspace_open" | "session_start" @@ -41,8 +43,13 @@ export interface DiagnosticsCheck { workspaceId?: string; workspacePath?: string; providerId?: string; + dependencyId?: SystemDependencyId; autoInstallSupported?: boolean; - installReadiness?: "ready" | "missing_prerequisite" | "unsupported_platform"; + installReadiness?: + | "ready" + | "missing_prerequisite" + | "unsupported_platform" + | "unsupported_package_manager"; missingCommands?: string[]; missingPrerequisites?: string[]; manualGuideKeys?: string[]; diff --git a/packages/core/src/domain/lsp.test.ts b/packages/core/src/domain/lsp.test.ts index e5273dc7..a10fd075 100644 --- a/packages/core/src/domain/lsp.test.ts +++ b/packages/core/src/domain/lsp.test.ts @@ -8,6 +8,7 @@ import type { LspHoverResult, LspLocation, LspRuntimeMode, + LspSemanticTokens, LspSessionSummary, LspToolInstallFailure, LspToolInstallJobSnapshot, @@ -66,7 +67,7 @@ describe("LSP shared surface", () => { expectTypeOf().toEqualTypeOf<{ workspaceId: string; - serverKind: "typescript" | "python" | "go" | "rust"; + serverKind: "typescript" | "python" | "go" | "rust" | "vue"; path: string; version?: number; diagnostics: LspDiagnostic[]; @@ -74,7 +75,7 @@ describe("LSP shared surface", () => { expectTypeOf().toEqualTypeOf<{ workspaceId: string; - serverKind: "typescript" | "python" | "go" | "rust"; + serverKind: "typescript" | "python" | "go" | "rust" | "vue"; status: "unsupported" | "starting" | "ready" | "degraded" | "stopped"; capabilities: { definition: boolean; @@ -83,6 +84,7 @@ describe("LSP shared surface", () => { references: boolean; hover: boolean; documentSymbols: boolean; + semanticTokens: boolean; diagnostics: boolean; }; }>(); @@ -90,7 +92,7 @@ describe("LSP shared surface", () => { expectTypeOf().toEqualTypeOf<"override" | "managed" | "bundled" | "system">(); expectTypeOf().toEqualTypeOf<{ - serverKind: "typescript" | "python" | "go" | "rust"; + serverKind: "typescript" | "python" | "go" | "rust" | "vue"; displayName: string; available: boolean; source?: LspToolSource; @@ -125,7 +127,7 @@ describe("LSP shared surface", () => { | "verification_failed" | "download_failed" | "unknown_failure"; - serverKind: "typescript" | "python" | "go" | "rust"; + serverKind: "typescript" | "python" | "go" | "rust" | "vue"; message: string; failedStepId: string; command: string; @@ -138,7 +140,7 @@ describe("LSP shared surface", () => { expectTypeOf().toEqualTypeOf<{ jobId: string; - serverKind: "typescript" | "python" | "go" | "rust"; + serverKind: "typescript" | "python" | "go" | "rust" | "vue"; status: "queued" | "running" | "succeeded" | "failed"; currentStepId?: string; steps: LspToolInstallStepSnapshot[]; @@ -162,7 +164,7 @@ describe("LSP shared surface", () => { } | { kind: "tool_missing" | "installing" | "failed"; - serverKind: "typescript" | "python" | "go" | "rust"; + serverKind: "typescript" | "python" | "go" | "rust" | "vue"; displayName: string; errorCode: | "lsp_tool_missing" @@ -189,11 +191,16 @@ describe("LSP shared surface", () => { version?: number; }>(); + expectTypeOf().toEqualTypeOf<{ + resultId?: string; + data: number[]; + }>(); + type LspDiagnosticsUpdatedEvent = Extract; expectTypeOf().toMatchTypeOf<{ type: "lsp.diagnostics.updated"; workspaceId: string; - serverKind: "typescript" | "python" | "go" | "rust"; + serverKind: "typescript" | "python" | "go" | "rust" | "vue"; path: string; version?: number; diagnostics: LspDiagnostic[]; diff --git a/packages/core/src/domain/lsp.ts b/packages/core/src/domain/lsp.ts index f7569f48..2ec1fa28 100644 --- a/packages/core/src/domain/lsp.ts +++ b/packages/core/src/domain/lsp.ts @@ -1,7 +1,46 @@ -export type LspServerKind = "typescript" | "python" | "go" | "rust"; +export type LspServerKind = "typescript" | "python" | "go" | "rust" | "vue"; export type LspToolSource = "override" | "managed" | "bundled" | "system"; export type LspRuntimeMode = "auto" | "off"; +export const LSP_SEMANTIC_TOKEN_TYPES = [ + "namespace", + "type", + "class", + "enum", + "interface", + "struct", + "typeParameter", + "parameter", + "variable", + "property", + "enumMember", + "event", + "function", + "method", + "macro", + "keyword", + "modifier", + "comment", + "string", + "number", + "regexp", + "operator", + "decorator", +] as const; + +export const LSP_SEMANTIC_TOKEN_MODIFIERS = [ + "declaration", + "definition", + "readonly", + "static", + "deprecated", + "abstract", + "async", + "modification", + "documentation", + "defaultLibrary", +] as const; + export interface LspRange { startLine: number; startColumn: number; @@ -36,6 +75,11 @@ export interface LspDocumentSymbol { children?: LspDocumentSymbol[]; } +export interface LspSemanticTokens { + resultId?: string; + data: number[]; +} + export interface LspSessionSummary { workspaceId: string; serverKind: LspServerKind; @@ -47,6 +91,7 @@ export interface LspSessionSummary { references: boolean; hover: boolean; documentSymbols: boolean; + semanticTokens: boolean; diagnostics: boolean; }; } diff --git a/packages/core/src/domain/monitoring.test.ts b/packages/core/src/domain/monitoring.test.ts new file mode 100644 index 00000000..8a87fa2d --- /dev/null +++ b/packages/core/src/domain/monitoring.test.ts @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Topics } from "../protocol/topics"; +import { + createDefaultMonitoringSettings, + createEmptyMonitoringResponse, + deriveMonitoringMode, + isMonitoringSampleIntervalMs, + MONITORING_SAMPLE_INTERVAL_OPTIONS, + resolveMonitoringSettings, +} from "./monitoring"; + +describe("monitoring domain helpers", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("creates the default monitoring settings shape", () => { + expect(createDefaultMonitoringSettings()).toEqual({ + enabled: false, + hostMetricsEnabled: true, + runtimeSummaryEnabled: true, + workspaceAttributionEnabled: true, + subprocessDrilldownEnabled: false, + sampleIntervalMs: 2000, + }); + }); + + it("exposes the supported sample intervals", () => { + expect(MONITORING_SAMPLE_INTERVAL_OPTIONS).toEqual([1000, 2000, 5000, 10000]); + expect(isMonitoringSampleIntervalMs(2000)).toBe(true); + expect(isMonitoringSampleIntervalMs(3000)).toBe(false); + }); + + it("derives mode labels after applying dependency normalization", () => { + expect( + deriveMonitoringMode( + resolveMonitoringSettings({ + "monitoring.enabled": false, + }) + ) + ).toBe("disabled"); + + expect( + deriveMonitoringMode( + resolveMonitoringSettings({ + "monitoring.enabled": true, + "monitoring.hostMetricsEnabled": true, + "monitoring.runtimeSummaryEnabled": false, + "monitoring.workspaceAttributionEnabled": false, + "monitoring.subprocessDrilldownEnabled": false, + }) + ) + ).toBe("light"); + + expect( + deriveMonitoringMode( + resolveMonitoringSettings({ + "monitoring.enabled": true, + "monitoring.hostMetricsEnabled": true, + "monitoring.runtimeSummaryEnabled": true, + "monitoring.workspaceAttributionEnabled": true, + "monitoring.subprocessDrilldownEnabled": false, + }) + ) + ).toBe("standard"); + + expect( + deriveMonitoringMode( + resolveMonitoringSettings({ + "monitoring.enabled": true, + "monitoring.hostMetricsEnabled": true, + "monitoring.runtimeSummaryEnabled": true, + "monitoring.workspaceAttributionEnabled": true, + "monitoring.subprocessDrilldownEnabled": true, + }) + ) + ).toBe("deep"); + }); + + it("creates the exact empty monitoring response shape", () => { + expect(createEmptyMonitoringResponse()).toEqual({ + settings: { + enabled: false, + hostMetricsEnabled: true, + runtimeSummaryEnabled: true, + workspaceAttributionEnabled: true, + subprocessDrilldownEnabled: false, + sampleIntervalMs: 2000, + }, + snapshot: { + sampledAt: 0, + mode: "disabled", + host: null, + runtime: null, + workspaces: [], + sessions: [], + subprocessGroups: [], + backgroundGroups: [], + }, + history: { + host: { points: [] }, + runtime: null, + workspaces: {}, + sessions: {}, + subprocessGroups: {}, + }, + capabilities: { + loadAverageAvailable: process.platform !== "win32", + processMetricsAvailable: false, + subprocessHistoryLimited: false, + }, + telemetry: null, + }); + }); + + it("creates an empty monitoring response without a Node process global", () => { + vi.stubGlobal("process", undefined); + + expect(createEmptyMonitoringResponse().capabilities.loadAverageAvailable).toBe(true); + }); + + it("defines the websocket topic for monitoring snapshot broadcasts", () => { + expect(Topics.monitoringSnapshotUpdated).toBe("monitoring.snapshot.updated"); + }); +}); diff --git a/packages/core/src/domain/monitoring.ts b/packages/core/src/domain/monitoring.ts new file mode 100644 index 00000000..50d9f929 --- /dev/null +++ b/packages/core/src/domain/monitoring.ts @@ -0,0 +1,238 @@ +export const MONITORING_SAMPLE_INTERVAL_OPTIONS = [1000, 2000, 5000, 10000] as const; + +export const DEFAULT_MONITORING_SAMPLE_INTERVAL_MS = 2000; + +export type MonitoringSampleIntervalMs = (typeof MONITORING_SAMPLE_INTERVAL_OPTIONS)[number]; +export type MonitoringMode = "disabled" | "light" | "standard" | "deep"; +export type MonitoringPressure = "normal" | "elevated" | "hot" | "unknown"; + +export interface MonitoringSettings { + enabled: boolean; + hostMetricsEnabled: boolean; + runtimeSummaryEnabled: boolean; + workspaceAttributionEnabled: boolean; + subprocessDrilldownEnabled: boolean; + sampleIntervalMs: MonitoringSampleIntervalMs; +} + +export interface MonitoringSeriesPoint { + sampledAt: number; + cpuPercent: number | null; + memoryBytes: number | null; + processCount?: number; +} + +export interface MonitoringHostSummary { + cpuPercent: number | null; + memoryUsedBytes: number | null; + memoryTotalBytes: number | null; + memoryAvailableBytes: number | null; + loadAverage: [number, number, number] | null; + uptimeSec: number | null; + pressure: MonitoringPressure; +} + +export interface MonitoringRuntimeSummary { + serverCpuPercent: number | null; + serverMemoryBytes: number | null; + totalManagedCpuPercent: number | null; + totalManagedMemoryBytes: number | null; + managedProcessCount: number; + cpuShareOfHostPercent: number | null; + memoryShareOfHostPercent: number | null; +} + +export interface MonitoringEntitySummary { + id: string; + kind: "workspace" | "session" | "subprocess_group" | "background_group"; + parentId?: string; + workspaceId?: string; + sessionId?: string; + terminalId?: string; + label: string; + cpuPercent: number | null; + memoryBytes: number | null; + processCount: number; + uptimeSec: number | null; + trend: "rising" | "steady" | "falling" | "unknown"; + childCount?: number; +} + +export interface MonitoringSnapshot { + sampledAt: number; + mode: MonitoringMode; + host: MonitoringHostSummary | null; + runtime: MonitoringRuntimeSummary | null; + workspaces: MonitoringEntitySummary[]; + sessions: MonitoringEntitySummary[]; + subprocessGroups: MonitoringEntitySummary[]; + backgroundGroups: MonitoringEntitySummary[]; +} + +export interface MonitoringSeriesBundle { + points: MonitoringSeriesPoint[]; +} + +export interface MonitoringHistoryBundle { + host: MonitoringSeriesBundle; + runtime: MonitoringSeriesBundle | null; + workspaces: Record; + sessions: Record; + subprocessGroups: Record; +} + +export interface MonitoringCapabilities { + loadAverageAvailable: boolean; + processMetricsAvailable: boolean; + subprocessHistoryLimited: boolean; +} + +export interface MonitoringSamplingTelemetry { + durationMs: number; + processRowCount: number; + subprocessGroupCount: number; + historyTrimmed: boolean; + degraded: boolean; + failureReason?: string; +} + +export interface MonitoringResponse { + settings: MonitoringSettings; + snapshot: MonitoringSnapshot; + history: MonitoringHistoryBundle; + capabilities: MonitoringCapabilities; + telemetry: MonitoringSamplingTelemetry | null; +} + +export function isMonitoringSampleIntervalMs(value: unknown): value is MonitoringSampleIntervalMs { + return ( + typeof value === "number" && + MONITORING_SAMPLE_INTERVAL_OPTIONS.includes(value as MonitoringSampleIntervalMs) + ); +} + +export function createDefaultMonitoringSettings(): MonitoringSettings { + return { + enabled: false, + hostMetricsEnabled: true, + runtimeSummaryEnabled: true, + workspaceAttributionEnabled: true, + subprocessDrilldownEnabled: false, + sampleIntervalMs: DEFAULT_MONITORING_SAMPLE_INTERVAL_MS, + }; +} + +function normalizeMonitoringDependencies(settings: MonitoringSettings): MonitoringSettings { + if (!settings.workspaceAttributionEnabled) { + settings.subprocessDrilldownEnabled = false; + } + if (!settings.runtimeSummaryEnabled) { + settings.workspaceAttributionEnabled = false; + settings.subprocessDrilldownEnabled = false; + } + return settings; +} + +export function resolveMonitoringSettings( + values: + | Record + | { + get: (key: string) => T | undefined; + } + | undefined +): MonitoringSettings { + const defaults = createDefaultMonitoringSettings(); + const objectValues = + values && "get" in values && typeof values.get === "function" + ? null + : (values as Record | undefined); + const read = (key: string) => { + if (!values) { + return undefined; + } + + if ("get" in values && typeof values.get === "function") { + return values.get(key); + } + + return objectValues?.[key]; + }; + + return normalizeMonitoringDependencies({ + enabled: + typeof read("monitoring.enabled") === "boolean" + ? Boolean(read("monitoring.enabled")) + : defaults.enabled, + hostMetricsEnabled: + typeof read("monitoring.hostMetricsEnabled") === "boolean" + ? Boolean(read("monitoring.hostMetricsEnabled")) + : defaults.hostMetricsEnabled, + runtimeSummaryEnabled: + typeof read("monitoring.runtimeSummaryEnabled") === "boolean" + ? Boolean(read("monitoring.runtimeSummaryEnabled")) + : defaults.runtimeSummaryEnabled, + workspaceAttributionEnabled: + typeof read("monitoring.workspaceAttributionEnabled") === "boolean" + ? Boolean(read("monitoring.workspaceAttributionEnabled")) + : defaults.workspaceAttributionEnabled, + subprocessDrilldownEnabled: + typeof read("monitoring.subprocessDrilldownEnabled") === "boolean" + ? Boolean(read("monitoring.subprocessDrilldownEnabled")) + : defaults.subprocessDrilldownEnabled, + sampleIntervalMs: isMonitoringSampleIntervalMs(read("monitoring.sampleIntervalMs")) + ? (read("monitoring.sampleIntervalMs") as MonitoringSampleIntervalMs) + : defaults.sampleIntervalMs, + }); +} + +export function deriveMonitoringMode(settings: MonitoringSettings): MonitoringMode { + if (!settings.enabled) { + return "disabled"; + } + if (settings.subprocessDrilldownEnabled) { + return "deep"; + } + if (settings.workspaceAttributionEnabled) { + return "standard"; + } + return "light"; +} + +function canReportLoadAverage() { + if (typeof process !== "undefined" && typeof process.platform === "string") { + return process.platform !== "win32"; + } + + return true; +} + +export function createEmptyMonitoringResponse( + settings = createDefaultMonitoringSettings() +): MonitoringResponse { + return { + settings, + snapshot: { + sampledAt: 0, + mode: deriveMonitoringMode(settings), + host: null, + runtime: null, + workspaces: [], + sessions: [], + subprocessGroups: [], + backgroundGroups: [], + }, + history: { + host: { points: [] }, + runtime: null, + workspaces: {}, + sessions: {}, + subprocessGroups: {}, + }, + capabilities: { + loadAverageAvailable: canReportLoadAverage(), + processMetricsAvailable: false, + subprocessHistoryLimited: false, + }, + telemetry: null, + }; +} diff --git a/packages/core/src/domain/system-dependency-install.test.ts b/packages/core/src/domain/system-dependency-install.test.ts new file mode 100644 index 00000000..b542f0ba --- /dev/null +++ b/packages/core/src/domain/system-dependency-install.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, expectTypeOf, it } from "vitest"; +import { + isSystemDependencyId, + SYSTEM_DEPENDENCY_IDS, + SYSTEM_DEPENDENCY_INSTALL_OUTPUT_TOPIC_SCOPE, + type SystemDependencyId, + type SystemDependencyInstallFailure, + type SystemDependencyInstallInteraction, + type SystemDependencyInstallJobSnapshot, + type SystemDependencyInstallOutputChunk, + type SystemDependencyInstallStepSnapshot, + type SystemDependencyPackageManager, + type SystemDependencyRuntimeEntry, + type SystemDependencyRuntimeStatusResponse, +} from "../index"; + +describe("system dependency install shared contract", () => { + it("defines the supported system dependency ids", () => { + expect(SYSTEM_DEPENDENCY_IDS).toEqual(["git", "node"]); + }); + + it("identifies supported system dependency ids", () => { + expect(isSystemDependencyId("git")).toBe(true); + expect(isSystemDependencyId("node")).toBe(true); + expect(isSystemDependencyId("python")).toBe(false); + }); + + it("defines the install output topic scope", () => { + expect(SYSTEM_DEPENDENCY_INSTALL_OUTPUT_TOPIC_SCOPE).toBe("systemDeps.install"); + }); + + it("keeps the shared type surface stable through the public barrel", () => { + expectTypeOf().toEqualTypeOf<"git" | "node">(); + expectTypeOf().toEqualTypeOf< + "brew" | "apt-get" | "dnf" | "yum" | "pacman" | "zypper" + >(); + + expectTypeOf().toEqualTypeOf<{ + dependencyId: "git" | "node"; + available: boolean; + version?: string; + autoInstallSupported: boolean; + installReadiness: "ready" | "unsupported_platform" | "unsupported_package_manager"; + packageManager?: "brew" | "apt-get" | "dnf" | "yum" | "pacman" | "zypper"; + manualGuideKeys: string[]; + docUrl?: string; + }>(); + + expectTypeOf().toEqualTypeOf<{ + dependencies: Record<"git" | "node", SystemDependencyRuntimeEntry>; + }>(); + + expectTypeOf().toEqualTypeOf<{ + kind: "none" | "sudo_password" | "confirm"; + promptExcerpt?: string; + echo: boolean; + }>(); + + expectTypeOf().toEqualTypeOf<{ + id: string; + titleKey: string; + kind: "check" | "install" | "verify"; + command: string; + args: string[]; + status: "pending" | "running" | "succeeded" | "failed"; + startedAt?: number; + finishedAt?: number; + exitCode?: number; + stdoutExcerpt?: string; + stderrExcerpt?: string; + }>(); + + expectTypeOf().toEqualTypeOf<{ + code: + | "unsupported_platform" + | "unsupported_package_manager" + | "permission_denied" + | "user_cancelled" + | "pty_disconnected" + | "command_not_found" + | "command_failed" + | "verification_failed" + | "unknown_failure"; + dependencyId: "git" | "node"; + failedStepId: string; + message: string; + command: string; + args: string[]; + exitCode?: number; + stdoutExcerpt?: string; + stderrExcerpt?: string; + packageManager?: "brew" | "apt-get" | "dnf" | "yum" | "pacman" | "zypper"; + manualGuideKeys: string[]; + docUrl?: string; + }>(); + + expectTypeOf().toEqualTypeOf<{ + jobId: string; + dependencyId: "git" | "node"; + status: "queued" | "running" | "waiting_input" | "succeeded" | "failed" | "cancelled"; + packageManager?: "brew" | "apt-get" | "dnf" | "yum" | "pacman" | "zypper"; + currentStepId?: string; + steps: SystemDependencyInstallStepSnapshot[]; + interaction: SystemDependencyInstallInteraction; + failure?: SystemDependencyInstallFailure; + }>(); + + expectTypeOf().toEqualTypeOf<{ + jobId: string; + chunk: string; + seq: number; + }>(); + }); +}); diff --git a/packages/core/src/domain/system-dependency-install.ts b/packages/core/src/domain/system-dependency-install.ts new file mode 100644 index 00000000..202f4712 --- /dev/null +++ b/packages/core/src/domain/system-dependency-install.ts @@ -0,0 +1,91 @@ +export const SYSTEM_DEPENDENCY_IDS = ["git", "node"] as const; +export const SYSTEM_DEPENDENCY_INSTALL_OUTPUT_TOPIC_SCOPE = "systemDeps.install" as const; + +export type SystemDependencyId = (typeof SYSTEM_DEPENDENCY_IDS)[number]; +export type SystemDependencyPackageManager = + | "brew" + | "apt-get" + | "dnf" + | "yum" + | "pacman" + | "zypper"; + +export function isSystemDependencyId(value: unknown): value is SystemDependencyId { + return typeof value === "string" && (SYSTEM_DEPENDENCY_IDS as readonly string[]).includes(value); +} + +export interface SystemDependencyRuntimeEntry { + dependencyId: SystemDependencyId; + available: boolean; + version?: string; + autoInstallSupported: boolean; + installReadiness: "ready" | "unsupported_platform" | "unsupported_package_manager"; + packageManager?: SystemDependencyPackageManager; + manualGuideKeys: string[]; + docUrl?: string; +} + +export interface SystemDependencyRuntimeStatusResponse { + dependencies: Record; +} + +export interface SystemDependencyInstallInteraction { + kind: "none" | "sudo_password" | "confirm"; + promptExcerpt?: string; + echo: boolean; +} + +export interface SystemDependencyInstallStepSnapshot { + id: string; + titleKey: string; + kind: "check" | "install" | "verify"; + command: string; + args: string[]; + status: "pending" | "running" | "succeeded" | "failed"; + startedAt?: number; + finishedAt?: number; + exitCode?: number; + stdoutExcerpt?: string; + stderrExcerpt?: string; +} + +export interface SystemDependencyInstallFailure { + code: + | "unsupported_platform" + | "unsupported_package_manager" + | "permission_denied" + | "user_cancelled" + | "pty_disconnected" + | "command_not_found" + | "command_failed" + | "verification_failed" + | "unknown_failure"; + dependencyId: SystemDependencyId; + failedStepId: string; + message: string; + command: string; + args: string[]; + exitCode?: number; + stdoutExcerpt?: string; + stderrExcerpt?: string; + packageManager?: SystemDependencyPackageManager; + manualGuideKeys: string[]; + docUrl?: string; +} + +export interface SystemDependencyInstallJobSnapshot { + jobId: string; + dependencyId: SystemDependencyId; + status: "queued" | "running" | "waiting_input" | "succeeded" | "failed" | "cancelled"; + packageManager?: SystemDependencyPackageManager; + currentStepId?: string; + steps: SystemDependencyInstallStepSnapshot[]; + interaction: SystemDependencyInstallInteraction; + failure?: SystemDependencyInstallFailure; +} + +export interface SystemDependencyInstallOutputChunk { + jobId: string; + chunk: string; + seq: number; +} diff --git a/packages/core/src/domain/types.test.ts b/packages/core/src/domain/types.test.ts index b1cd466d..05b3fce3 100644 --- a/packages/core/src/domain/types.test.ts +++ b/packages/core/src/domain/types.test.ts @@ -1,5 +1,14 @@ import { describe, expect, expectTypeOf, it } from "vitest"; -import type { AgentContextKind, CustomProviderSessionMode, SessionState } from "./types"; +import type { + AgentContextKind, + CustomProviderSessionMode, + GitCommitDetail, + GitCommitFileEntry, + GitDiffRenderMode, + GitFileDiffPayload, + GitRevisionSource, + SessionState, +} from "./types"; import { deriveSessionTitle, SESSION_TITLE_MAX_LENGTH } from "./types"; describe("deriveSessionTitle", () => { @@ -56,3 +65,16 @@ describe("AgentContextKind", () => { >(); }); }); + +describe("Git history diff contracts", () => { + it("covers structured commit detail and diff payload types", () => { + expectTypeOf().toEqualTypeOf<"text" | "image">(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + "added" | "modified" | "deleted" | "renamed" + >(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<"text" | "image">(); + expectTypeOf().toEqualTypeOf(); + }); +}); diff --git a/packages/core/src/domain/types.ts b/packages/core/src/domain/types.ts index 7245f053..bfa0e7e9 100644 --- a/packages/core/src/domain/types.ts +++ b/packages/core/src/domain/types.ts @@ -22,14 +22,49 @@ export interface Workspace { uiState: UiState; } -export interface WorkspacePaneNode { +export type WorkspacePaneLeafKind = "draft" | "session" | "editor"; + +export interface LegacyWorkspacePaneLeaf { id: string; - type: "leaf" | "split"; + type: "leaf"; sessionId?: string; + leafKind?: undefined; +} + +export interface WorkspaceDraftPaneLeaf { + id: string; + type: "leaf"; + leafKind: "draft"; +} + +export interface WorkspaceSessionPaneLeaf { + id: string; + type: "leaf"; + leafKind: "session"; + sessionId: string; +} + +export interface WorkspaceEditorPaneLeaf { + id: string; + type: "leaf"; + leafKind: "editor"; +} + +export type WorkspacePaneLeaf = + | LegacyWorkspacePaneLeaf + | WorkspaceDraftPaneLeaf + | WorkspaceSessionPaneLeaf + | WorkspaceEditorPaneLeaf; + +export interface WorkspacePaneSplit { + id: string; + type: "split"; direction?: "horizontal" | "vertical"; children?: WorkspacePaneNode[]; } +export type WorkspacePaneNode = WorkspacePaneLeaf | WorkspacePaneSplit; + export interface UiState { leftPanelWidth: number; bottomPanelHeight: number; @@ -37,6 +72,8 @@ export interface UiState { activeSessionId?: string; paneLayout?: WorkspacePaneNode; fileTreeExpandedDirs?: string[]; + openEditorPaths?: string[]; + activeEditorPath?: string | null; } export interface WorkspaceLastViewedTarget { @@ -188,6 +225,7 @@ export interface Terminal { id: string; workspaceId: string; kind: "agent" | "shell"; + pid?: number; title: string; cwd: string; argv: string[]; @@ -251,6 +289,10 @@ export interface GitStatus { export type GitChangeStatus = "added" | "modified" | "deleted" | "renamed" | "untracked"; +export type GitDiffRenderMode = "text" | "image"; + +export type GitRevisionSource = "HEAD" | "INDEX" | "WORKTREE" | string; + export interface GitFileChange { path: string; oldPath?: string; // for renames @@ -265,6 +307,33 @@ export interface GitCommitSummary { authoredAt: number; } +export interface GitCommitFileEntry { + path: string; + oldPath?: string; + status: Exclude; + renderAs: GitDiffRenderMode; +} + +export interface GitCommitDetail { + commit: GitCommitSummary & { + parentSha?: string; + }; + files: GitCommitFileEntry[]; +} + +export interface GitFileDiffPayload { + diff: string; + renderAs: GitDiffRenderMode; + status: "modified" | "added" | "deleted"; + mime?: string; + originalPath?: string; + modifiedPath?: string; + originalContent?: string; + modifiedContent?: string; + originalRevision?: GitRevisionSource; + modifiedRevision?: GitRevisionSource; +} + export interface GitBranch { name: string; // Branch name (e.g., "main", "origin/feature") isRemote: boolean; // Whether it's a remote branch @@ -288,6 +357,7 @@ export interface FileNode { children?: FileNode[]; size?: number; mtime?: number; + isGitIgnored?: boolean; } export interface SearchContentMatch { @@ -314,6 +384,70 @@ export interface SearchContentResult { truncatedMatchFileCount: number; } +export interface SearchSessionMatchPreview { + id: string; + line: number; + column: number; + endColumn: number; + preview: string; + previewColumnStart: number; + previewColumnEnd: number; + replacementPreview: string; + replacementPreviewColumnStart: number; + replacementPreviewColumnEnd: number; + isReplacementPreviewTruncated: boolean; +} + +export interface SearchSessionFileResult { + path: string; + name: string; + matchCount: number; + hasMoreMatches: boolean; + baseHash: string; + matches: SearchSessionMatchPreview[]; +} + +export interface SearchSessionStartResult { + sessionId: string; + files: SearchSessionFileResult[]; + totalMatchCount: number; + totalFileCount: number; + hasMoreFiles: boolean; + truncatedMatchFileCount: number; + skippedBinaryFileCount: number; + skippedLargeFileCount: number; +} + +export interface SearchSessionFilePreview { + kind: "search-replace-file-diff"; + path: string; + title?: string; + sessionId: string; + baseHash: string; + originalContent: string; + modifiedContent: string; +} + +export type SearchSessionApplyScope = + | { kind: "all" } + | { kind: "file"; path: string } + | { kind: "match"; path: string; matchId: string }; + +export interface SearchSessionApplyFileResult { + path: string; + status: "applied" | "conflict" | "skipped" | "not_found"; + replacedMatchCount: number; +} + +export interface SearchSessionApplyResult { + sessionId: string; + status: "ok" | "partial" | "stale_session"; + appliedFileCount: number; + conflictFileCount: number; + skippedFileCount: number; + results: SearchSessionApplyFileResult[]; +} + export interface Settings { defaultProviderId: string; notifications: { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d71712ed..a9eab5f2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,8 +4,10 @@ export * from "./domain/diagnostics"; export * from "./domain/events"; export * from "./domain/lsp"; export * from "./domain/mcp"; +export * from "./domain/monitoring"; export * from "./domain/provider-install"; export * from "./domain/supervisor"; +export * from "./domain/system-dependency-install"; // Domain export * from "./domain/types"; export * from "./domain/update"; diff --git a/packages/core/src/protocol/topics.ts b/packages/core/src/protocol/topics.ts index 6eda379a..fb8addaf 100644 --- a/packages/core/src/protocol/topics.ts +++ b/packages/core/src/protocol/topics.ts @@ -34,7 +34,9 @@ export const Topics = { // Notification notificationToast: "notification.toast", + monitoringSnapshotUpdated: "monitoring.snapshot.updated", updateStateChanged: "update.state.changed", + systemDependencyInstallOutput: (jobId: string) => `systemDeps.install.${jobId}.output`, // Supervisor-level (Phase 3) supervisorState: (workspaceId: string, sessionId: string) => diff --git a/packages/server/src/__tests__/diagnostics-commands.test.ts b/packages/server/src/__tests__/diagnostics-commands.test.ts index d8ff36f7..108e03e0 100644 --- a/packages/server/src/__tests__/diagnostics-commands.test.ts +++ b/packages/server/src/__tests__/diagnostics-commands.test.ts @@ -19,6 +19,8 @@ import "../commands/diagnostics.js"; import "../commands/workspace.js"; function createContext(overrides: Partial = {}): CommandContext { + const { providerRuntimeDeps, ...restOverrides } = overrides; + return { workspaceMgr: { get: (workspaceId: string) => @@ -44,8 +46,18 @@ function createContext(overrides: Partial = {}): CommandContext }, providerRuntimeDeps: { commandExists: async () => true, + runCommand: async (file: string) => { + if (file === "git") { + return { stdout: "git version 0.0-test\n", stderr: "" }; + } + if (file === "node") { + return { stdout: "v0.0.0-test\n", stderr: "" }; + } + throw new Error(`unexpected command: ${file}`); + }, + ...providerRuntimeDeps, }, - ...overrides, + ...restOverrides, }; } @@ -71,6 +83,16 @@ describe("diagnostics commands", () => { }); expect((result.data as { checks: Array<{ code: string; status: string }> }).checks).toEqual( expect.arrayContaining([ + expect.objectContaining({ + code: "git_ready", + status: "ready", + version: "git version 0.0-test", + }), + expect.objectContaining({ + code: "nodejs_ready", + status: "ready", + version: "v0.0.0-test", + }), expect.objectContaining({ code: "provider_runtime_ready", status: "ready", @@ -124,6 +146,60 @@ describe("diagnostics commands", () => { ); }); + it("blocks session start when node is missing but keeps workspace-open non-blocking", async () => { + const workspaceDir = await mkdtemp(join(tmpdir(), "diagnostics-base-runtime-")); + const nodeMissingContext = createContext({ + workspaceMgr: { + get: (workspaceId: string) => + workspaceId === "ws-1" ? { id: "ws-1", path: workspaceDir } : undefined, + list: () => [], + } as unknown as WorkspaceManager, + providerRuntimeDeps: { + commandExists: async (command: string) => + command === "brew" || command === "claude" || command === "git", + runCommand: async (file: string) => { + if (file === "git") { + return { stdout: "git version 2.49.0\n", stderr: "" }; + } + if (file === "node") { + throw Object.assign(new Error("missing node"), { exitCode: 127 }); + } + return { stdout: "", stderr: "" }; + }, + platform: "darwin", + }, + }); + + const sessionResult = await dispatch( + { + kind: "command", + id: "diag-session-node-missing", + op: "diagnostics.get", + args: { context: "session_start", workspaceId: "ws-1", providerId: "claude" }, + }, + nodeMissingContext + ); + + expect(sessionResult.ok).toBe(true); + expect(sessionResult.data).toMatchObject({ context: "session_start", canContinue: false }); + expect((sessionResult.data as { checks: Array<{ code: string }> }).checks).toEqual( + expect.arrayContaining([expect.objectContaining({ code: "nodejs_missing" })]) + ); + + const workspaceResult = await dispatch( + { + kind: "command", + id: "diag-workspace-node-missing", + op: "diagnostics.get", + args: { context: "workspace_open", workspacePath: workspaceDir }, + }, + nodeMissingContext + ); + + expect(workspaceResult.ok).toBe(true); + expect(workspaceResult.data).toMatchObject({ context: "workspace_open", canContinue: true }); + }); + it("returns workspace_path_not_found when the selected workspace path no longer exists", async () => { const result = await dispatch( { diff --git a/packages/server/src/__tests__/file-commands.test.ts b/packages/server/src/__tests__/file-commands.test.ts index 4ec4aeb5..0288eb9b 100644 --- a/packages/server/src/__tests__/file-commands.test.ts +++ b/packages/server/src/__tests__/file-commands.test.ts @@ -210,6 +210,174 @@ describe("File Commands", () => { }); }); + it("starts search sessions and returns replacement-aware results", async () => { + await writeFile(join(testDir, "alpha.ts"), "const match = 'match';\n"); + + const result = await dispatch( + { + kind: "command", + id: "file-search-session-start-1", + op: "file.searchSession.start", + args: { + workspaceId, + query: "match", + replace: "rename", + isRegex: false, + matchCase: true, + matchWholeWord: false, + preserveCase: false, + includeGlobs: [], + excludeGlobs: [], + useIgnoreFiles: true, + useExcludeSettings: true, + onlyOpenEditors: false, + openEditorPaths: [], + maxFiles: 20, + maxMatchesPerFile: 20, + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(result.data).toMatchObject({ + sessionId: expect.any(String), + totalMatchCount: 2, + totalFileCount: 1, + }); + expect( + (result.data as { files: Array<{ path: string; matchCount: number }> }).files[0] + ).toMatchObject({ + path: "alpha.ts", + matchCount: 2, + }); + expect( + ( + result.data as { + files: Array<{ matches: Array<{ replacementPreview: string }> }>; + } + ).files[0]?.matches + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + replacementPreview: "const rename = 'match';", + }), + ]) + ); + }); + + it("previews one search-session file and applies replacements with fs.dirty emission", async () => { + await writeFile(join(testDir, "alpha.ts"), "match match\n"); + + const startResult = await dispatch( + { + kind: "command", + id: "file-search-session-start-2", + op: "file.searchSession.start", + args: { + workspaceId, + query: "match", + replace: "rename", + isRegex: false, + matchCase: true, + matchWholeWord: false, + preserveCase: false, + includeGlobs: [], + excludeGlobs: [], + useIgnoreFiles: true, + useExcludeSettings: true, + onlyOpenEditors: false, + openEditorPaths: [], + maxFiles: 20, + maxMatchesPerFile: 20, + }, + }, + ctx + ); + + expect(startResult.ok).toBe(true); + const startData = startResult.data as { + sessionId: string; + files: Array<{ path: string; matches: Array<{ id: string }> }>; + }; + + const previewResult = await dispatch( + { + kind: "command", + id: "file-search-session-preview-1", + op: "file.searchSession.previewFile", + args: { + workspaceId, + sessionId: startData.sessionId, + path: "alpha.ts", + }, + }, + ctx + ); + + expect(previewResult.ok).toBe(true); + expect(previewResult.data).toMatchObject({ + kind: "search-replace-file-diff", + path: "alpha.ts", + originalContent: "match match\n", + modifiedContent: "rename rename\n", + }); + + const applyResult = await dispatch( + { + kind: "command", + id: "file-search-session-apply-1", + op: "file.searchSession.apply", + args: { + workspaceId, + sessionId: startData.sessionId, + scope: { + kind: "match", + path: "alpha.ts", + matchId: startData.files[0]?.matches[0]?.id, + }, + }, + }, + ctx + ); + + expect(applyResult.ok).toBe(true); + expect(applyResult.data).toMatchObject({ + status: "ok", + appliedFileCount: 1, + results: [{ path: "alpha.ts", status: "applied", replacedMatchCount: 1 }], + }); + expect(eventBus.emit).toHaveBeenCalledWith({ + type: "fs.dirty", + workspaceId, + reason: "file_content", + }); + }); + + it("returns stale_session for missing search sessions", async () => { + const result = await dispatch( + { + kind: "command", + id: "file-search-session-apply-2", + op: "file.searchSession.apply", + args: { + workspaceId, + sessionId: "missing-session", + scope: { + kind: "all", + }, + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(result.data).toMatchObject({ + sessionId: "missing-session", + status: "stale_session", + }); + }); + it("shows dotfiles and node_modules in file.readTree while still hiding .git", async () => { await writeFile(join(testDir, ".gitignore"), "*.log\nnode_modules/\n"); await writeFile(join(testDir, ".env"), "secret\n"); @@ -230,10 +398,15 @@ describe("File Commands", () => { ); expect(result.ok).toBe(true); - const children = (result.data as { children: Array<{ name: string }> }).children; - expect(children.some((item) => item.name === ".env")).toBe(true); - expect(children.some((item) => item.name === "ignored.log")).toBe(true); - expect(children.some((item) => item.name === "node_modules")).toBe(true); + const children = (result.data as { children: Array<{ name: string; isGitIgnored?: boolean }> }) + .children; + expect(children).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: ".env", isGitIgnored: false }), + expect.objectContaining({ name: "ignored.log", isGitIgnored: true }), + expect.objectContaining({ name: "node_modules", isGitIgnored: true }), + ]) + ); expect(children.some((item) => item.name === ".git")).toBe(false); }); diff --git a/packages/server/src/__tests__/fixtures/fake-lsp-server.js b/packages/server/src/__tests__/fixtures/fake-lsp-server.js index 5e7f6895..78045aff 100644 --- a/packages/server/src/__tests__/fixtures/fake-lsp-server.js +++ b/packages/server/src/__tests__/fixtures/fake-lsp-server.js @@ -14,11 +14,17 @@ const connection = createMessageConnection( ); const docs = new Map(); -const exitAfterInitMs = Number(process.env.CODER_STUDIO_FAKE_LSP_EXIT_AFTER_INIT_MS ?? "0"); +const exitAfterInitArg = process.argv.find((arg) => arg.startsWith("--exit-after-init-ms=")); +const exitAfterInitMs = Number( + exitAfterInitArg?.slice("--exit-after-init-ms=".length) ?? + process.env.CODER_STUDIO_FAKE_LSP_EXIT_AFTER_INIT_MS ?? + "0" +); const hoverDelayMs = Number(process.env.CODER_STUDIO_FAKE_LSP_HOVER_DELAY_MS ?? "0"); +const initDelayMs = Number(process.env.CODER_STUDIO_FAKE_LSP_INIT_DELAY_MS ?? "0"); const stderrOnInit = process.env.CODER_STUDIO_FAKE_LSP_STDERR_ON_INIT ?? ""; -connection.onRequest("initialize", () => { +connection.onRequest("initialize", async () => { if (stderrOnInit) { process.stderr.write(`${stderrOnInit}\n`); } @@ -28,6 +34,13 @@ connection.onRequest("initialize", () => { timer.unref?.(); } + if (initDelayMs > 0) { + await new Promise((resolve) => { + const timer = setTimeout(resolve, initDelayMs); + timer.unref?.(); + }); + } + return { capabilities: { definitionProvider: true, @@ -36,6 +49,13 @@ connection.onRequest("initialize", () => { referencesProvider: true, hoverProvider: true, documentSymbolProvider: true, + semanticTokensProvider: { + legend: { + tokenTypes: ["function", "variable", "class", "typeAlias", "builtinType"], + tokenModifiers: ["declaration", "readonly"], + }, + full: true, + }, textDocumentSync: 1, }, }; @@ -213,6 +233,20 @@ connection.onRequest("textDocument/documentSymbol", ({ textDocument }) => { ]; }); +connection.onRequest("textDocument/semanticTokens/full", ({ textDocument }) => { + if (!textDocument.uri.endsWith("/shared.ts")) { + return { data: [] }; + } + + return { + resultId: "semantic-1", + data: [ + // sharedValue: variable + declaration + 0, 13, 11, 1, 1, + ], + }; +}); + function publishDiagnostics(uri) { const text = docs.get(uri) ?? readFileSync(new URL(uri), "utf8"); const diagnostics = text.includes("missingSymbol") diff --git a/packages/server/src/__tests__/fs/gitignore.test.ts b/packages/server/src/__tests__/fs/gitignore.test.ts index 4b60e487..96ac277b 100644 --- a/packages/server/src/__tests__/fs/gitignore.test.ts +++ b/packages/server/src/__tests__/fs/gitignore.test.ts @@ -8,8 +8,10 @@ import { join } from "path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createGitignoreFilter, + createGitignoreMatcher, createTreeVisibilityFilter, createWatcherIgnoreFilter, + isPathGitignored, } from "../../fs/gitignore.js"; describe("createGitignoreFilter", () => { @@ -102,6 +104,32 @@ describe("createTreeVisibilityFilter", () => { }); }); +describe("gitignore matcher helpers", () => { + let testDir: string; + + beforeEach(async () => { + testDir = join(tmpdir(), `gitignore-matcher-test-${Date.now()}`); + await mkdir(join(testDir, "src"), { recursive: true }); + }); + + afterEach(async () => { + await rm(testDir, { recursive: true, force: true }); + }); + + it("matches paths against the root .gitignore while keeping .git visible", async () => { + await writeFile(join(testDir, ".gitignore"), "*.log\ndist/\n!important.log"); + await writeFile(join(testDir, "src", "index.ts"), "export {};\n"); + + const matcher = createGitignoreMatcher(testDir); + + expect(isPathGitignored(matcher, "app.log")).toBe(true); + expect(isPathGitignored(matcher, "dist")).toBe(true); + expect(isPathGitignored(matcher, "important.log")).toBe(false); + expect(isPathGitignored(matcher, ".git")).toBe(false); + expect(isPathGitignored(matcher, "src/index.ts")).toBe(false); + }); +}); + describe("createWatcherIgnoreFilter", () => { let testDir: string; diff --git a/packages/server/src/__tests__/fs/search-replace.test.ts b/packages/server/src/__tests__/fs/search-replace.test.ts new file mode 100644 index 00000000..723acba5 --- /dev/null +++ b/packages/server/src/__tests__/fs/search-replace.test.ts @@ -0,0 +1,253 @@ +import { mkdir, rm, writeFile } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + applySearchSession, + createSearchSession, + previewSearchSessionFile, +} from "../../fs/search-replace.js"; + +describe("search replace engine", () => { + let rootDir: string; + + beforeEach(async () => { + rootDir = join(tmpdir(), `search-replace-${Date.now()}-${Math.random().toString(36).slice(2)}`); + await mkdir(rootDir, { recursive: true }); + await mkdir(join(rootDir, "src"), { recursive: true }); + await mkdir(join(rootDir, "dist"), { recursive: true }); + }); + + afterEach(async () => { + await rm(rootDir, { recursive: true, force: true }); + }); + + it("builds grouped results with replacement previews for plain search", async () => { + await writeFile(join(rootDir, "src", "app.ts"), "const query = queryValue;\n"); + + const session = await createSearchSession(rootDir, { + query: "query", + replace: "replaceQuery", + isRegex: false, + matchCase: true, + matchWholeWord: false, + preserveCase: false, + includeGlobs: [], + excludeGlobs: [], + useIgnoreFiles: true, + useExcludeSettings: true, + onlyOpenEditors: false, + openEditorPaths: [], + maxFiles: 50, + maxMatchesPerFile: 20, + }); + + expect(session.result.files[0]).toMatchObject({ + path: "src/app.ts", + matchCount: 2, + }); + expect(session.result.files[0]?.matches).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + preview: "const query = queryValue;", + replacementPreview: "const replaceQuery = queryValue;", + }), + ]) + ); + }); + + it("supports regex capture groups and preserve case", async () => { + await writeFile(join(rootDir, "src", "tokens.txt"), "foo FOO Foo\n"); + + const session = await createSearchSession(rootDir, { + query: "(foo)", + replace: "bar", + isRegex: true, + matchCase: false, + matchWholeWord: false, + preserveCase: true, + includeGlobs: [], + excludeGlobs: [], + useIgnoreFiles: true, + useExcludeSettings: true, + onlyOpenEditors: false, + openEditorPaths: [], + maxFiles: 50, + maxMatchesPerFile: 20, + }); + + expect(session.result.files[0]?.matches.map((match) => match.replacementPreview)).toEqual([ + "bar FOO Foo", + "foo BAR Foo", + "foo FOO Bar", + ]); + }); + + it("respects include and exclude globs plus open editors filtering", async () => { + await writeFile(join(rootDir, "src", "keep.ts"), "needle\n"); + await writeFile(join(rootDir, "src", "skip.spec.ts"), "needle\n"); + await writeFile(join(rootDir, "dist", "ignored.ts"), "needle\n"); + + const session = await createSearchSession(rootDir, { + query: "needle", + replace: "done", + isRegex: false, + matchCase: true, + matchWholeWord: false, + preserveCase: false, + includeGlobs: ["src/**/*.ts"], + excludeGlobs: ["**/*.spec.ts"], + useIgnoreFiles: true, + useExcludeSettings: true, + onlyOpenEditors: true, + openEditorPaths: ["src/keep.ts", "src/skip.spec.ts"], + maxFiles: 50, + maxMatchesPerFile: 20, + }); + + expect(session.result.files.map((file) => file.path)).toEqual(["src/keep.ts"]); + }); + + it("honors standard ignore sources when the ignore/exclude toggle is enabled", async () => { + await writeFile(join(rootDir, ".ignore"), "ignored-from-ignore.ts\n"); + await writeFile(join(rootDir, ".rgignore"), "ignored-from-rgignore.ts\n"); + await mkdir(join(rootDir, ".git", "info"), { recursive: true }); + await writeFile(join(rootDir, ".git", "info", "exclude"), "ignored-from-git-info.ts\n"); + await writeFile(join(rootDir, "src", "visible.ts"), "needle\n"); + await writeFile(join(rootDir, "ignored-from-ignore.ts"), "needle\n"); + await writeFile(join(rootDir, "ignored-from-rgignore.ts"), "needle\n"); + await writeFile(join(rootDir, "ignored-from-git-info.ts"), "needle\n"); + + const session = await createSearchSession(rootDir, { + query: "needle", + replace: "done", + isRegex: false, + matchCase: true, + matchWholeWord: false, + preserveCase: false, + includeGlobs: [], + excludeGlobs: [], + useIgnoreFiles: true, + useExcludeSettings: true, + onlyOpenEditors: false, + openEditorPaths: [], + maxFiles: 50, + maxMatchesPerFile: 20, + }); + + expect(session.result.files.map((file) => file.path)).toEqual(["src/visible.ts"]); + }); + + it("builds preview payloads from the same replacement engine", async () => { + await writeFile(join(rootDir, "src", "app.ts"), "const query = queryValue;\n"); + + const session = await createSearchSession(rootDir, { + query: "query", + replace: "replaceQuery", + isRegex: false, + matchCase: true, + matchWholeWord: false, + preserveCase: false, + includeGlobs: [], + excludeGlobs: [], + useIgnoreFiles: true, + useExcludeSettings: true, + onlyOpenEditors: false, + openEditorPaths: [], + maxFiles: 50, + maxMatchesPerFile: 20, + }); + + const preview = await previewSearchSessionFile(rootDir, session.sessionId, "src/app.ts"); + + expect(preview).toMatchObject({ + kind: "search-replace-file-diff", + path: "src/app.ts", + originalContent: "const query = queryValue;\n", + modifiedContent: "const replaceQuery = replaceQueryValue;\n", + }); + }); + + it("applies a single match without replacing the rest of the file", async () => { + await writeFile(join(rootDir, "src", "app.ts"), "query query\n"); + + const session = await createSearchSession(rootDir, { + query: "query", + replace: "replaceQuery", + isRegex: false, + matchCase: true, + matchWholeWord: false, + preserveCase: false, + includeGlobs: [], + excludeGlobs: [], + useIgnoreFiles: true, + useExcludeSettings: true, + onlyOpenEditors: false, + openEditorPaths: [], + maxFiles: 50, + maxMatchesPerFile: 20, + }); + + const matchId = session.result.files[0]?.matches[0]?.id; + expect(matchId).toBeTruthy(); + + const result = await applySearchSession(rootDir, session.sessionId, { + kind: "match", + path: "src/app.ts", + matchId: matchId!, + }); + + expect(result).toMatchObject({ + status: "ok", + appliedFileCount: 1, + results: [{ path: "src/app.ts", status: "applied", replacedMatchCount: 1 }], + }); + }); + + it("reports partial success when a file conflicts after the session snapshot", async () => { + await writeFile(join(rootDir, "src", "safe.ts"), "query\n"); + await writeFile(join(rootDir, "src", "conflict.ts"), "query\n"); + + const session = await createSearchSession(rootDir, { + query: "query", + replace: "replaceQuery", + isRegex: false, + matchCase: true, + matchWholeWord: false, + preserveCase: false, + includeGlobs: [], + excludeGlobs: [], + useIgnoreFiles: true, + useExcludeSettings: true, + onlyOpenEditors: false, + openEditorPaths: [], + maxFiles: 50, + maxMatchesPerFile: 20, + }); + + await writeFile(join(rootDir, "src", "conflict.ts"), "changed elsewhere\n"); + + const result = await applySearchSession(rootDir, session.sessionId, { kind: "all" }); + + expect(result).toMatchObject({ + status: "partial", + appliedFileCount: 1, + conflictFileCount: 1, + }); + expect(result.results).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: "src/safe.ts", status: "applied" }), + expect.objectContaining({ path: "src/conflict.ts", status: "conflict" }), + ]) + ); + }); + + it("returns stale_session for unknown sessions", async () => { + const result = await applySearchSession(rootDir, "missing-session", { kind: "all" }); + + expect(result).toMatchObject({ + sessionId: "missing-session", + status: "stale_session", + }); + }); +}); diff --git a/packages/server/src/__tests__/fs/tree.test.ts b/packages/server/src/__tests__/fs/tree.test.ts index 780e69ea..ccae46a0 100644 --- a/packages/server/src/__tests__/fs/tree.test.ts +++ b/packages/server/src/__tests__/fs/tree.test.ts @@ -127,7 +127,7 @@ describe("readTree", () => { // Subdir children are undefined (lazy loading) }); - it("should not hide .gitignore-matched files from the tree", async () => { + it("should keep ignored entries visible while marking gitignored metadata", async () => { await writeFile(join(testDir, ".gitignore"), "*.log\ndist/"); await writeFile(join(testDir, "app.log"), "log content"); await writeFile(join(testDir, "app.txt"), "text content"); @@ -136,9 +136,30 @@ describe("readTree", () => { const result = await readTree(testDir); - expect(result.children.some((n) => n.name === "app.log")).toBe(true); - expect(result.children.some((n) => n.name === "dist")).toBe(true); - expect(result.children.some((n) => n.name === "app.txt")).toBe(true); - expect(result.children.some((n) => n.name === "src")).toBe(true); + const ignoredLog = result.children.find((n) => n.name === "app.log"); + const ignoredDist = result.children.find((n) => n.name === "dist"); + const visibleFile = result.children.find((n) => n.name === "app.txt"); + const visibleDir = result.children.find((n) => n.name === "src"); + + expect(ignoredLog).toMatchObject({ + name: "app.log", + kind: "file", + isGitIgnored: true, + }); + expect(ignoredDist).toMatchObject({ + name: "dist", + kind: "dir", + isGitIgnored: true, + }); + expect(visibleFile).toMatchObject({ + name: "app.txt", + kind: "file", + isGitIgnored: false, + }); + expect(visibleDir).toMatchObject({ + name: "src", + kind: "dir", + isGitIgnored: false, + }); }); }); diff --git a/packages/server/src/__tests__/git-commands.test.ts b/packages/server/src/__tests__/git-commands.test.ts index c6e52cea..58376060 100644 --- a/packages/server/src/__tests__/git-commands.test.ts +++ b/packages/server/src/__tests__/git-commands.test.ts @@ -23,12 +23,71 @@ const PNG_BYTES = Buffer.from( "hex" ); +async function createCommitHistoryFixture( + testDir: string +): Promise<{ headSha: string; parentSha: string }> { + await execFileAsync("git", ["checkout", "--", "sample.ts"], { cwd: testDir }); + await writeFile(join(testDir, "rename-me.ts"), "export const renamed = true;\n"); + await writeFile(join(testDir, "pixel.png"), PNG_BYTES); + await execFileAsync("git", ["add", "."], { cwd: testDir }); + await execFileAsync("git", ["commit", "-m", "History base"], { cwd: testDir }); + + const { stdout: parentSha } = await execFileAsync("git", ["rev-parse", "HEAD"], { + cwd: testDir, + }); + + await writeFile(join(testDir, "sample.ts"), "export const value = 3;\n"); + await execFileAsync("git", ["mv", "rename-me.ts", "renamed.ts"], { cwd: testDir }); + const nextBytes = Buffer.from(PNG_BYTES); + nextBytes[nextBytes.length - 1] ^= 0x01; + await writeFile(join(testDir, "pixel.png"), nextBytes); + await execFileAsync("git", ["add", "."], { cwd: testDir }); + await execFileAsync("git", ["commit", "-m", "Commit history fixture"], { cwd: testDir }); + + const { stdout: headSha } = await execFileAsync("git", ["rev-parse", "HEAD"], { + cwd: testDir, + }); + + return { + headSha: headSha.trim(), + parentSha: parentSha.trim(), + }; +} + +async function createMergeCommitFixture( + testDir: string, + initialBranch: string +): Promise<{ mergeSha: string }> { + await execFileAsync("git", ["checkout", "-b", "feature/history-merge"], { cwd: testDir }); + await writeFile(join(testDir, "feature.txt"), "feature branch change\n"); + await execFileAsync("git", ["add", "."], { cwd: testDir }); + await execFileAsync("git", ["commit", "-m", "Feature branch change"], { cwd: testDir }); + + await execFileAsync("git", ["checkout", initialBranch], { cwd: testDir }); + await writeFile(join(testDir, "main.txt"), "main branch change\n"); + await execFileAsync("git", ["add", "."], { cwd: testDir }); + await execFileAsync("git", ["commit", "-m", "Main branch change"], { cwd: testDir }); + + await execFileAsync("git", ["merge", "--no-ff", "feature/history-merge", "-m", "Merge feature"], { + cwd: testDir, + }); + + const { stdout: mergeSha } = await execFileAsync("git", ["rev-parse", "HEAD"], { + cwd: testDir, + }); + + return { + mergeSha: mergeSha.trim(), + }; +} + describe("Git Commands", () => { let testDir: string; let ctx: CommandContext; let workspaceMgr: WorkspaceManager; let eventBus: EventBus; let workspaceId: string; + let initialBranch: string; let recordFetchSpy: ReturnType; let autoFetch: AutoFetchScheduler; let workspaceLookup: ReturnType; @@ -45,6 +104,12 @@ describe("Git Commands", () => { await writeFile(join(testDir, "sample.ts"), "export const value = 1;\n"); await execFileAsync("git", ["add", "."], { cwd: testDir }); await execFileAsync("git", ["commit", "-m", "Initial commit"], { cwd: testDir }); + const { stdout: initialBranchStdout } = await execFileAsync( + "git", + ["branch", "--show-current"], + { cwd: testDir } + ); + initialBranch = initialBranchStdout.trim(); await writeFile(join(testDir, "sample.ts"), "export const value = 2;\n"); stateDir = mkdtempSync(join(tmpdir(), "git-command-state-")); @@ -180,11 +245,45 @@ describe("Git Commands", () => { status: "modified", originalRevision: "INDEX", modifiedRevision: "WORKTREE", + mime: "image/png", + originalPath: "pixel.png", + modifiedPath: "pixel.png", diff: expect.stringContaining("Binary files"), }) ); }); + it("returns image diff metadata for untracked png files via git.diff", async () => { + await writeFile(join(testDir, "scratch.png"), PNG_BYTES); + + const result = await dispatch( + { + kind: "command", + id: "git-diff-image-untracked", + op: "git.diff", + args: { + workspaceId, + path: "scratch.png", + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(result.data).toEqual( + expect.objectContaining({ + renderAs: "image", + status: "added", + originalRevision: "HEAD", + modifiedRevision: "WORKTREE", + mime: "image/png", + originalPath: undefined, + modifiedPath: "scratch.png", + diff: expect.stringContaining("diff --git a/scratch.png b/scratch.png"), + }) + ); + }); + it("returns recent commit history for git.log", async () => { await execFileAsync("git", ["add", "."], { cwd: testDir }); await execFileAsync("git", ["commit", "-m", "Refresh command surface"], { cwd: testDir }); @@ -261,6 +360,178 @@ describe("Git Commands", () => { expect(result.error?.code).toBe("validation_error"); }); + it("returns structured commit files for git.commitDetail", async () => { + const { headSha, parentSha } = await createCommitHistoryFixture(testDir); + + const result = await dispatch( + { + kind: "command", + id: "git-commit-detail-1", + op: "git.commitDetail", + args: { + workspaceId, + sha: headSha, + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(result.data).toEqual( + expect.objectContaining({ + commit: expect.objectContaining({ + sha: headSha, + shortSha: expect.any(String), + subject: "Commit history fixture", + parentSha, + }), + files: expect.arrayContaining([ + expect.objectContaining({ + path: "sample.ts", + status: "modified", + renderAs: "text", + }), + expect.objectContaining({ + path: "renamed.ts", + oldPath: "rename-me.ts", + status: "renamed", + renderAs: "text", + }), + expect.objectContaining({ + path: "pixel.png", + status: "modified", + renderAs: "image", + }), + ]), + }) + ); + }); + + it("returns commit file diffs for git.commitFileDiff", async () => { + const { headSha } = await createCommitHistoryFixture(testDir); + + const textResult = await dispatch( + { + kind: "command", + id: "git-commit-file-diff-text", + op: "git.commitFileDiff", + args: { + workspaceId, + sha: headSha, + path: "sample.ts", + }, + }, + ctx + ); + + expect(textResult.ok).toBe(true); + expect(textResult.data).toEqual( + expect.objectContaining({ + renderAs: "text", + status: "modified", + originalContent: "export const value = 1;\n", + modifiedContent: "export const value = 3;\n", + }) + ); + + const imageResult = await dispatch( + { + kind: "command", + id: "git-commit-file-diff-image", + op: "git.commitFileDiff", + args: { + workspaceId, + sha: headSha, + path: "pixel.png", + }, + }, + ctx + ); + + expect(imageResult.ok).toBe(true); + expect(imageResult.data).toEqual( + expect.objectContaining({ + renderAs: "image", + status: "modified", + mime: "image/png", + originalRevision: expect.any(String), + modifiedRevision: headSha, + originalPath: "pixel.png", + modifiedPath: "pixel.png", + }) + ); + }); + + it("rejects git.commitFileDiff when the requested file is not part of the target commit", async () => { + const { headSha } = await createCommitHistoryFixture(testDir); + + const result = await dispatch( + { + kind: "command", + id: "git-commit-file-diff-invalid-selection", + op: "git.commitFileDiff", + args: { + workspaceId, + sha: headSha, + path: "missing-from-commit.ts", + }, + }, + ctx + ); + + expect(result.ok).toBe(false); + expect(result.error).toEqual( + expect.objectContaining({ + code: "git_commit_file_not_found", + }) + ); + }); + + it("rejects structured history commands for merge commits", async () => { + const { mergeSha } = await createMergeCommitFixture(testDir, initialBranch); + + const detailResult = await dispatch( + { + kind: "command", + id: "git-commit-detail-merge", + op: "git.commitDetail", + args: { + workspaceId, + sha: mergeSha, + }, + }, + ctx + ); + + expect(detailResult.ok).toBe(false); + expect(detailResult.error).toEqual( + expect.objectContaining({ + code: "git_merge_commit_unsupported", + }) + ); + + const fileDiffResult = await dispatch( + { + kind: "command", + id: "git-commit-file-diff-merge", + op: "git.commitFileDiff", + args: { + workspaceId, + sha: mergeSha, + path: "feature.txt", + }, + }, + ctx + ); + + expect(fileDiffResult.ok).toBe(false); + expect(fileDiffResult.error).toEqual( + expect.objectContaining({ + code: "git_merge_commit_unsupported", + }) + ); + }); + it("discards modified tracked files", async () => { const result = await dispatch( { diff --git a/packages/server/src/__tests__/git/diff.test.ts b/packages/server/src/__tests__/git/diff.test.ts index edf83689..b968931c 100644 --- a/packages/server/src/__tests__/git/diff.test.ts +++ b/packages/server/src/__tests__/git/diff.test.ts @@ -103,8 +103,27 @@ describe("git diff operations", () => { expect(diff.status).toBe("modified"); expect(diff.originalRevision).toBe("INDEX"); expect(diff.modifiedRevision).toBe("WORKTREE"); + expect(diff.mime).toBe("image/png"); + expect(diff.originalPath).toBe("pixel.png"); + expect(diff.modifiedPath).toBe("pixel.png"); expect(diff.diff).toContain("Binary files"); }); + + it("returns image diff metadata for untracked png files", async () => { + await writeFile(join(testDir, "scratch.png"), PNG_BYTES); + + const diff = await getFileDiff(testDir, "scratch.png"); + + expect(diff.renderAs).toBe("image"); + expect(diff.status).toBe("added"); + expect(diff.originalRevision).toBe("HEAD"); + expect(diff.modifiedRevision).toBe("WORKTREE"); + expect(diff.mime).toBe("image/png"); + expect(diff.originalPath).toBeUndefined(); + expect(diff.modifiedPath).toBe("scratch.png"); + expect(diff.diff).toContain("diff --git a/scratch.png b/scratch.png"); + expect(diff.diff).toContain("new file mode 100644"); + }); }); describe("getDiff", () => { diff --git a/packages/server/src/__tests__/git/image-revision.test.ts b/packages/server/src/__tests__/git/image-revision.test.ts index 4b722df1..60a0726d 100644 --- a/packages/server/src/__tests__/git/image-revision.test.ts +++ b/packages/server/src/__tests__/git/image-revision.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "os"; import { join } from "path"; import { promisify } from "util"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { readImageAtRevision } from "../../git/image-revision.js"; +import { parseGitImageRevisionSelector, readImageAtRevision } from "../../git/image-revision.js"; const execFileAsync = promisify(execFile); const PNG_BYTES = Buffer.from( @@ -58,4 +58,34 @@ describe("readImageAtRevision", () => { expect(asset.mime).toBe("image/png"); expect(asset.bytes?.equals(nextBytes)).toBe(true); }); + + it("reads committed image bytes from an explicit commit sha", async () => { + await writeFile(join(testDir, "pixel.png"), PNG_BYTES); + await execFileAsync("git", ["add", "."], { cwd: testDir }); + await execFileAsync("git", ["commit", "-m", "Add pixel"], { cwd: testDir }); + const sha = (await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: testDir })).stdout.trim(); + + const asset = await readImageAtRevision(testDir, sha, "pixel.png"); + + expect(asset.exists).toBe(true); + expect(asset.mime).toBe("image/png"); + expect(asset.bytes?.equals(PNG_BYTES)).toBe(true); + }); +}); + +describe("parseGitImageRevisionSelector", () => { + it("accepts HEAD, INDEX, and strict commit shas", () => { + expect(parseGitImageRevisionSelector("HEAD")).toBe("HEAD"); + expect(parseGitImageRevisionSelector("INDEX")).toBe("INDEX"); + expect(parseGitImageRevisionSelector("abcdef1")).toBe("abcdef1"); + expect(parseGitImageRevisionSelector("0123456789abcdef0123456789abcdef01234567")).toBe( + "0123456789abcdef0123456789abcdef01234567" + ); + }); + + it("rejects non-sha revision selectors", () => { + expect(parseGitImageRevisionSelector("HEAD~1")).toBeNull(); + expect(parseGitImageRevisionSelector("../HEAD")).toBeNull(); + expect(parseGitImageRevisionSelector("abcxyz1")).toBeNull(); + }); }); diff --git a/packages/server/src/__tests__/lsp-commands.test.ts b/packages/server/src/__tests__/lsp-commands.test.ts index 73e9366c..93e2afd7 100644 --- a/packages/server/src/__tests__/lsp-commands.test.ts +++ b/packages/server/src/__tests__/lsp-commands.test.ts @@ -38,6 +38,7 @@ class FakeLspManager { references: true, hover: true, documentSymbols: true, + semanticTokens: true, diagnostics: true, }, }, @@ -79,15 +80,22 @@ class FakeLspManager { async documentSymbols() { return []; } + + async semanticTokens() { + return { + resultId: "semantic-1", + data: [0, 13, 11, 8, 1], + }; + } } class FakeLspToolInstallManager { - async start() { + async start(input: { serverKind: "typescript" | "python" | "go" | "rust" | "vue" }) { return { jobId: "job-1", - serverKind: "python" as const, + serverKind: input.serverKind, status: "queued" as const, - currentStepId: "install-python-lsp", + currentStepId: `install-${input.serverKind}-lsp`, steps: [], }; } @@ -206,6 +214,27 @@ describe("LSP commands", () => { expect.objectContaining({ path: "e2e/fixtures/lsp-workspace/shared.ts" }), ]) ); + + const semanticTokens = await dispatch( + { + kind: "command", + id: crypto.randomUUID(), + op: "lsp.semanticTokens", + args: { + workspaceId, + path: "e2e/fixtures/lsp-workspace/shared.ts", + }, + }, + ctx + ); + + expect(semanticTokens.ok).toBe(true); + expect(semanticTokens.data).toEqual( + expect.objectContaining({ + resultId: "semantic-1", + data: [0, 13, 11, 8, 1], + }) + ); }); it("exposes lsp runtime status and install commands", async () => { @@ -259,6 +288,24 @@ describe("LSP commands", () => { serverKind: "python", }); + const startVue = await dispatch( + { + kind: "command", + id: crypto.randomUUID(), + op: "lsp.install.start", + args: { workspaceId, serverKind: "vue" }, + }, + ctx + ); + + expect(startVue.ok).toBe(true); + expect(startVue.data).toMatchObject({ + jobId: "job-1", + serverKind: "vue", + status: "queued", + currentStepId: "install-vue-lsp", + }); + const get = await dispatch( { kind: "command", diff --git a/packages/server/src/__tests__/monitoring/aggregation.test.ts b/packages/server/src/__tests__/monitoring/aggregation.test.ts new file mode 100644 index 00000000..5993ae18 --- /dev/null +++ b/packages/server/src/__tests__/monitoring/aggregation.test.ts @@ -0,0 +1,158 @@ +import { createDefaultMonitoringSettings } from "@coder-studio/core"; +import { describe, expect, it } from "vitest"; +import { buildMonitoringSnapshot } from "../../monitoring/aggregation.js"; + +describe("buildMonitoringSnapshot", () => { + it("aggregates managed roots into runtime, workspace, session, and subprocess views", () => { + const response = buildMonitoringSnapshot({ + settings: { + ...createDefaultMonitoringSettings(), + enabled: true, + subprocessDrilldownEnabled: true, + }, + sampledAt: 100, + host: { + cpuPercent: 80, + memoryUsedBytes: 800, + memoryTotalBytes: 1000, + memoryAvailableBytes: 200, + loadAverage: [1, 1, 1], + uptimeSec: 300, + pressure: "elevated", + }, + roots: [ + { + ownerId: "server:1", + rootPid: 1, + kind: "server", + label: "Coder Studio server", + startedAt: 1, + }, + { + ownerId: "terminal:term-1", + rootPid: 100, + kind: "terminal", + label: "Claude", + workspaceId: "ws-1", + sessionId: "sess-1", + terminalId: "term-1", + providerId: "claude", + startedAt: 2, + }, + ], + workspaceLabels: { + "ws-1": "coder-studio", + }, + processRows: [ + { + pid: 1, + ppid: 0, + cpuPercent: 10, + rssBytes: 100, + elapsedSec: 400, + command: "node server.js", + }, + { + pid: 100, + ppid: 1, + cpuPercent: 20, + rssBytes: 200, + elapsedSec: 90, + command: "claude", + }, + { + pid: 101, + ppid: 100, + cpuPercent: 5, + rssBytes: 50, + elapsedSec: 30, + command: "python tool.py", + }, + ], + previousSnapshot: null, + }); + + expect(response.snapshot.runtime?.totalManagedCpuPercent).toBe(35); + expect(response.snapshot.runtime?.managedProcessCount).toBe(3); + expect(response.snapshot.workspaces[0]).toEqual( + expect.objectContaining({ + id: "workspace:ws-1", + label: "coder-studio", + cpuPercent: 25, + memoryBytes: 250, + }) + ); + expect(response.snapshot.sessions[0]).toEqual( + expect.objectContaining({ + id: "session:sess-1", + cpuPercent: 25, + processCount: 2, + }) + ); + expect(response.snapshot.subprocessGroups[0]?.parentId).toBe("session:sess-1"); + }); + + it("falls back to the workspace id when no readable label is available", () => { + const response = buildMonitoringSnapshot({ + settings: { + ...createDefaultMonitoringSettings(), + enabled: true, + }, + sampledAt: 100, + host: null, + roots: [ + { + ownerId: "terminal:term-1", + rootPid: 100, + kind: "terminal", + label: "Codex", + workspaceId: "ws_1779980247607_u2lfvdjf", + sessionId: "sess-1", + terminalId: "term-1", + providerId: "codex", + startedAt: 2, + }, + ], + processRows: [ + { + pid: 100, + ppid: 1, + cpuPercent: 20, + rssBytes: 200, + elapsedSec: 90, + command: "codex", + }, + ], + previousSnapshot: null, + }); + + expect(response.snapshot.workspaces[0]?.label).toBe("ws_1779980247607_u2lfvdjf"); + }); + + it("keeps host data when process collection fails", () => { + const response = buildMonitoringSnapshot({ + settings: { + ...createDefaultMonitoringSettings(), + enabled: true, + }, + sampledAt: 100, + host: { + cpuPercent: 50, + memoryUsedBytes: 400, + memoryTotalBytes: 1000, + memoryAvailableBytes: 600, + loadAverage: [0.5, 0.4, 0.3], + uptimeSec: 300, + pressure: "normal", + }, + roots: [], + processRows: null, + previousSnapshot: null, + failureReason: "ps failed", + }); + + expect(response.snapshot.host?.cpuPercent).toBe(50); + expect(response.snapshot.runtime).toBeNull(); + expect(response.telemetry?.degraded).toBe(true); + }); +}); diff --git a/packages/server/src/__tests__/monitoring/commands.test.ts b/packages/server/src/__tests__/monitoring/commands.test.ts new file mode 100644 index 00000000..075b0582 --- /dev/null +++ b/packages/server/src/__tests__/monitoring/commands.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from "vitest"; +import type { CommandContext } from "../../ws/dispatch.js"; +import { dispatch } from "../../ws/dispatch.js"; +import "../../commands/monitoring.js"; + +describe("monitoring commands", () => { + it("dispatches monitoring.get", async () => { + const ctx = { + monitoringService: { + getResponse: vi.fn(() => ({ snapshot: { sampledAt: 1 } })), + }, + } as unknown as CommandContext; + + const result = await dispatch( + { + kind: "command", + id: crypto.randomUUID(), + op: "monitoring.get", + args: {}, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(result.data).toEqual({ snapshot: { sampledAt: 1 } }); + }); + + it("dispatches monitoring.recheck", async () => { + const ctx = { + monitoringService: { + recheck: vi.fn(async () => ({ snapshot: { sampledAt: 2 } })), + }, + } as unknown as CommandContext; + + const result = await dispatch( + { + kind: "command", + id: crypto.randomUUID(), + op: "monitoring.recheck", + args: {}, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(result.data).toEqual({ snapshot: { sampledAt: 2 } }); + }); +}); diff --git a/packages/server/src/__tests__/monitoring/history-store.test.ts b/packages/server/src/__tests__/monitoring/history-store.test.ts new file mode 100644 index 00000000..acb0ec8f --- /dev/null +++ b/packages/server/src/__tests__/monitoring/history-store.test.ts @@ -0,0 +1,83 @@ +import type { MonitoringSnapshot } from "@coder-studio/core"; +import { describe, expect, it } from "vitest"; +import { MonitoringHistoryStore } from "../../monitoring/history-store.js"; + +function createSnapshot( + sampledAt: number, + overrides: Partial +): MonitoringSnapshot { + return { + sampledAt, + mode: "standard", + host: null, + runtime: null, + workspaces: [], + sessions: [], + subprocessGroups: [], + backgroundGroups: [], + ...overrides, + }; +} + +describe("MonitoringHistoryStore", () => { + it("clears host history when host metrics are unavailable for a sample", () => { + const store = new MonitoringHistoryStore(); + + store.record( + createSnapshot(1_000, { + host: { + cpuPercent: 10, + memoryUsedBytes: 100, + memoryTotalBytes: 1_000, + memoryAvailableBytes: 900, + loadAverage: [0.1, 0.1, 0.1], + uptimeSec: 10, + pressure: "normal", + }, + }) + ); + + store.record(createSnapshot(2_000, { host: null })); + + expect(store.snapshot().host.points).toEqual([]); + }); + + it("drops workspace and session history for entities that no longer exist", () => { + const store = new MonitoringHistoryStore(); + + store.record( + createSnapshot(1_000, { + workspaces: [ + { + id: "workspace:ws-1", + kind: "workspace", + label: "ws-1", + cpuPercent: 10, + memoryBytes: 100, + processCount: 1, + uptimeSec: 10, + trend: "steady", + }, + ], + sessions: [ + { + id: "session:sess-1", + parentId: "workspace:ws-1", + kind: "session", + label: "sess-1", + cpuPercent: 6, + memoryBytes: 60, + processCount: 1, + uptimeSec: 8, + trend: "steady", + }, + ], + }) + ); + + store.record(createSnapshot(2_000, {})); + + expect(store.snapshot().workspaces).toEqual({}); + expect(store.snapshot().sessions).toEqual({}); + }); +}); diff --git a/packages/server/src/__tests__/monitoring/host-collector.test.ts b/packages/server/src/__tests__/monitoring/host-collector.test.ts new file mode 100644 index 00000000..3b3c215c --- /dev/null +++ b/packages/server/src/__tests__/monitoring/host-collector.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { HostCollector } from "../../monitoring/host-collector.js"; + +type CpuInfo = ReturnType[number]; + +describe("HostCollector", () => { + it("computes cpu deltas and host pressure", () => { + const collector = new HostCollector({ + platform: "linux", + cpus: () => + [ + { times: { user: 100, nice: 0, sys: 0, idle: 900, irq: 0 } }, + { times: { user: 100, nice: 0, sys: 0, idle: 900, irq: 0 } }, + ] as CpuInfo[], + totalmem: () => 1000, + freemem: () => 300, + uptime: () => 120, + loadavg: () => [0.4, 0.3, 0.2], + }); + + collector.collect(); + const summary = collector.collect({ + cpus: [ + { times: { user: 160, nice: 0, sys: 0, idle: 940, irq: 0 } }, + { times: { user: 160, nice: 0, sys: 0, idle: 940, irq: 0 } }, + ] as CpuInfo[], + }); + + expect(summary.cpuPercent).toBe(60); + expect(summary.memoryUsedBytes).toBe(700); + expect(summary.pressure).toBe("normal"); + }); + + it("marks load average unavailable on windows without failing the snapshot", () => { + const collector = new HostCollector({ + platform: "win32", + cpus: () => [{ times: { user: 10, nice: 0, sys: 0, idle: 90, irq: 0 } }] as CpuInfo[], + totalmem: () => 1000, + freemem: () => 600, + uptime: () => 60, + loadavg: () => [0, 0, 0], + }); + + const summary = collector.collect(); + + expect(summary.loadAverage).toBeNull(); + expect(summary.pressure).toBe("unknown"); + }); +}); diff --git a/packages/server/src/__tests__/monitoring/managed-process-registry.test.ts b/packages/server/src/__tests__/monitoring/managed-process-registry.test.ts new file mode 100644 index 00000000..d53f66f2 --- /dev/null +++ b/packages/server/src/__tests__/monitoring/managed-process-registry.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; +import { ManagedProcessRegistry } from "../../monitoring/managed-process-registry.js"; + +describe("ManagedProcessRegistry", () => { + it("registerServerProcess only keeps one entry for the same pid", () => { + const registry = new ManagedProcessRegistry({ now: () => 10 }); + + registry.registerServerProcess(9001); + registry.registerServerProcess(9001); + + expect(registry.listRoots()).toEqual([ + { + ownerId: "server:9001", + rootPid: 9001, + kind: "server", + label: "Coder Studio server", + startedAt: 10, + }, + ]); + }); + + it("creates a terminal root before session binding and patches it in place after bind", () => { + let now = 20; + const registry = new ManagedProcessRegistry({ now: () => now }); + + registry.upsertTerminalRoot({ + terminalId: "term-1", + workspaceId: "ws-1", + pid: 3100, + kind: "agent", + title: "Build agent", + }); + + expect(registry.listRoots()).toEqual([ + { + ownerId: "terminal:term-1", + rootPid: 3100, + kind: "terminal", + label: "Build agent", + workspaceId: "ws-1", + terminalId: "term-1", + startedAt: 20, + }, + ]); + + now = 40; + registry.bindSessionToTerminal("term-1", { + sessionId: "session-1", + providerId: "openai", + label: "Claude Code session", + }); + + expect(registry.listRoots()).toEqual([ + { + ownerId: "terminal:term-1", + rootPid: 3100, + kind: "terminal", + label: "Claude Code session", + workspaceId: "ws-1", + terminalId: "term-1", + sessionId: "session-1", + providerId: "openai", + startedAt: 20, + }, + ]); + }); + + it("unregisterByOwner removes roots cleanly without disturbing other owners", () => { + let now = 5; + const registry = new ManagedProcessRegistry({ now: () => now }); + + registry.registerServerProcess(9001); + + now = 15; + registry.upsertTerminalRoot({ + terminalId: "term-1", + workspaceId: "ws-1", + pid: 3100, + kind: "shell", + title: "Project shell", + }); + registry.bindSessionToTerminal("term-1", { + sessionId: "session-1", + providerId: "openai", + label: "Interactive shell", + }); + + now = 25; + registry.registerBackgroundRoot({ + ownerId: "background:watcher-1", + rootPid: 4500, + kind: "background", + label: "Watcher", + workspaceId: "ws-1", + startedAt: 25, + }); + + registry.unregisterByOwner("terminal:term-1"); + + expect(registry.listRoots()).toEqual([ + { + ownerId: "server:9001", + rootPid: 9001, + kind: "server", + label: "Coder Studio server", + startedAt: 5, + }, + { + ownerId: "background:watcher-1", + rootPid: 4500, + kind: "background", + label: "Watcher", + workspaceId: "ws-1", + startedAt: 25, + }, + ]); + }); +}); diff --git a/packages/server/src/__tests__/monitoring/process-table.test.ts b/packages/server/src/__tests__/monitoring/process-table.test.ts new file mode 100644 index 00000000..8535ef6c --- /dev/null +++ b/packages/server/src/__tests__/monitoring/process-table.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { + parseDarwinPsRows, + parseLinuxPsRows, + parseWindowsProcessRows, +} from "../../monitoring/process-table/index.js"; + +describe("process table adapters", () => { + it("parses macOS ps output into normalized rows", () => { + const rows = parseDarwinPsRows( + " 101 1 6.5 2048 42 /usr/bin/node node server.js\n 202 101 1.5 1024 20 /bin/bash bash" + ); + + expect(rows).toEqual([ + { + pid: 101, + ppid: 1, + cpuPercent: 6.5, + rssBytes: 2048 * 1024, + elapsedSec: 42, + executable: "/usr/bin/node", + command: "node server.js", + }, + { + pid: 202, + ppid: 101, + cpuPercent: 1.5, + rssBytes: 1024 * 1024, + elapsedSec: 20, + executable: "/bin/bash", + command: "bash", + }, + ]); + }); + + it("parses linux ps output into normalized rows", () => { + const rows = parseLinuxPsRows( + "101 1 12.0 8096 99 /usr/bin/node node server.js\n202 101 0.8 2048 12 /usr/bin/python python worker.py" + ); + + expect(rows[0]?.pid).toBe(101); + expect(rows[0]?.rssBytes).toBe(8096 * 1024); + expect(rows[1]?.ppid).toBe(101); + }); + + it("parses windows powershell json rows into normalized rows", () => { + const rows = parseWindowsProcessRows([ + { + Id: 500, + ParentProcessId: 1, + CpuPercent: 4.25, + WorkingSet64: 4096, + ElapsedSec: 30, + Path: "C:\\node.exe", + CommandLine: "node server.js", + }, + ]); + + expect(rows).toEqual([ + { + pid: 500, + ppid: 1, + cpuPercent: 4.25, + rssBytes: 4096, + elapsedSec: 30, + executable: "C:\\node.exe", + command: "node server.js", + }, + ]); + }); +}); diff --git a/packages/server/src/__tests__/monitoring/service.test.ts b/packages/server/src/__tests__/monitoring/service.test.ts new file mode 100644 index 00000000..0923a02a --- /dev/null +++ b/packages/server/src/__tests__/monitoring/service.test.ts @@ -0,0 +1,523 @@ +import { + createDefaultMonitoringSettings, + type Session, + type Terminal, + Topics, +} from "@coder-studio/core"; +import { describe, expect, it, vi } from "vitest"; +import { ManagedProcessRegistry } from "../../monitoring/managed-process-registry.js"; +import { MonitoringService } from "../../monitoring/service.js"; + +interface ActiveTerminalLike { + toDTO(): Terminal; +} + +describe("MonitoringService", () => { + it("does not schedule sampling when monitoring is disabled", () => { + const broadcaster = { broadcast: vi.fn() }; + const setIntervalSpy = vi.fn(); + + const service = new MonitoringService({ + broadcaster, + settingsRepo: { + get: (key: string) => (key === "monitoring.enabled" ? false : undefined), + }, + registry: new ManagedProcessRegistry({ now: () => 1 }), + sessionMgr: { getAll: () => [], findSessionIdByTerminal: () => undefined }, + terminalMgr: { getAll: () => [] }, + hostCollector: { collect: vi.fn() }, + processCollector: { collect: vi.fn() }, + setInterval: setIntervalSpy, + clearInterval: vi.fn(), + now: () => 1, + }); + + service.start(); + + expect(setIntervalSpy).not.toHaveBeenCalled(); + expect(service.getResponse().settings.enabled).toBe(false); + }); + + it("reloads the schedule and broadcasts snapshots when monitoring is enabled", async () => { + const broadcaster = { broadcast: vi.fn() }; + const setIntervalSpy = vi.fn(() => ({ unref: vi.fn() })); + + const service = new MonitoringService({ + broadcaster, + settingsRepo: { + get: (key: string) => { + const settings = { + "monitoring.enabled": true, + "monitoring.hostMetricsEnabled": true, + "monitoring.runtimeSummaryEnabled": true, + "monitoring.workspaceAttributionEnabled": true, + "monitoring.subprocessDrilldownEnabled": false, + "monitoring.sampleIntervalMs": 2000, + } as Record; + return settings[key]; + }, + }, + registry: new ManagedProcessRegistry({ now: () => 10 }), + sessionMgr: { + getAll: () => + [ + { + id: "sess-1", + workspaceId: "ws-1", + terminalId: "term-1", + providerId: "claude", + state: "idle", + capability: "full", + startedAt: 1, + lastActiveAt: 1, + }, + ] satisfies Session[], + findSessionIdByTerminal: () => "sess-1", + }, + terminalMgr: { + getAll: () => [ + { + spec: { workspaceId: "ws-1", kind: "agent", title: "Claude" as string | undefined }, + toDTO: () => + ({ + id: "term-1", + workspaceId: "ws-1", + kind: "agent", + title: "Claude", + cwd: "/tmp", + argv: ["claude"], + cols: 120, + rows: 30, + pid: 100, + alive: true, + createdAt: 1, + }) satisfies Terminal, + }, + ], + }, + hostCollector: { + collect: () => ({ + cpuPercent: 40, + memoryUsedBytes: 400, + memoryTotalBytes: 1000, + memoryAvailableBytes: 600, + loadAverage: [0.2, 0.2, 0.1], + uptimeSec: 10, + pressure: "normal", + }), + }, + processCollector: { + collect: async () => [ + { pid: 100, ppid: 1, cpuPercent: 10, rssBytes: 100, elapsedSec: 5, command: "claude" }, + ], + }, + setInterval: setIntervalSpy, + clearInterval: vi.fn(), + now: () => 10, + }); + + service.start(); + await service.recheck(); + + expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 2000); + expect(broadcaster.broadcast).toHaveBeenCalledWith( + Topics.monitoringSnapshotUpdated, + expect.objectContaining({ + snapshot: expect.objectContaining({ + mode: "standard", + }), + }) + ); + }); + + it("clears history when monitoring is turned off", async () => { + let enabled = true; + const service = new MonitoringService({ + broadcaster: { broadcast: vi.fn() }, + settingsRepo: { + get: (key: string) => { + const settings = { + "monitoring.enabled": enabled, + "monitoring.hostMetricsEnabled": true, + "monitoring.runtimeSummaryEnabled": true, + "monitoring.workspaceAttributionEnabled": true, + "monitoring.subprocessDrilldownEnabled": true, + "monitoring.sampleIntervalMs": 2000, + } as Record; + return settings[key]; + }, + }, + registry: new ManagedProcessRegistry({ now: () => 10 }), + sessionMgr: { getAll: () => [], findSessionIdByTerminal: () => undefined }, + terminalMgr: { getAll: () => [] }, + hostCollector: { + collect: () => ({ + cpuPercent: 30, + memoryUsedBytes: 300, + memoryTotalBytes: 1000, + memoryAvailableBytes: 700, + loadAverage: [0.3, 0.2, 0.1], + uptimeSec: 10, + pressure: "normal", + }), + }, + processCollector: { collect: async () => [] }, + setInterval: vi.fn(() => ({ unref: vi.fn() })), + clearInterval: vi.fn(), + now: () => 10, + }); + + service.start(); + await service.recheck(); + enabled = false; + service.reloadFromSettings(); + + expect(service.getResponse().history.host.points).toEqual([]); + expect(service.getResponse().snapshot.mode).toBe("disabled"); + }); + + it("returns a host-only degraded snapshot when process collection fails", async () => { + const broadcaster = { broadcast: vi.fn() }; + const service = new MonitoringService({ + broadcaster, + settingsRepo: { + get: (key: string) => { + const settings = { + "monitoring.enabled": true, + "monitoring.hostMetricsEnabled": true, + "monitoring.runtimeSummaryEnabled": true, + "monitoring.workspaceAttributionEnabled": true, + "monitoring.subprocessDrilldownEnabled": false, + "monitoring.sampleIntervalMs": createDefaultMonitoringSettings().sampleIntervalMs, + } as Record; + return settings[key]; + }, + }, + registry: new ManagedProcessRegistry({ now: () => 5 }), + sessionMgr: { getAll: () => [], findSessionIdByTerminal: () => undefined }, + terminalMgr: { getAll: () => [] }, + hostCollector: { + collect: () => ({ + cpuPercent: 55, + memoryUsedBytes: 550, + memoryTotalBytes: 1000, + memoryAvailableBytes: 450, + loadAverage: [0.4, 0.3, 0.2], + uptimeSec: 10, + pressure: "normal", + }), + }, + processCollector: { + collect: async () => { + throw new Error("ps failed"); + }, + }, + setInterval: vi.fn(() => ({ unref: vi.fn() })), + clearInterval: vi.fn(), + now: () => 5, + }); + + service.start(); + const response = await service.recheck(); + + expect(response.snapshot.host?.cpuPercent).toBe(55); + expect(response.snapshot.runtime).toBeNull(); + expect(response.telemetry?.degraded).toBe(true); + expect(response.telemetry?.failureReason).toBe("ps failed"); + expect(broadcaster.broadcast).toHaveBeenCalledWith( + Topics.monitoringSnapshotUpdated, + expect.objectContaining({ + snapshot: expect.objectContaining({ + host: expect.objectContaining({ cpuPercent: 55 }), + runtime: null, + }), + telemetry: expect.objectContaining({ + degraded: true, + failureReason: "ps failed", + }), + }) + ); + }); + + it("syncs managed terminal roots from terminal and session managers before sampling", async () => { + const registry = new ManagedProcessRegistry({ now: () => 10 }); + const service = new MonitoringService({ + broadcaster: { broadcast: vi.fn() }, + settingsRepo: { + get: (key: string) => { + const settings = { + "monitoring.enabled": true, + "monitoring.hostMetricsEnabled": true, + "monitoring.runtimeSummaryEnabled": true, + "monitoring.workspaceAttributionEnabled": true, + "monitoring.subprocessDrilldownEnabled": false, + "monitoring.sampleIntervalMs": 2000, + } as Record; + return settings[key]; + }, + }, + registry, + sessionMgr: { + getAll: () => + [ + { + id: "sess-1", + workspaceId: "ws-1", + terminalId: "term-1", + providerId: "claude", + state: "idle", + capability: "full", + startedAt: 1, + lastActiveAt: 1, + title: "Claude Session", + }, + ] satisfies Session[], + findSessionIdByTerminal: () => "sess-1", + }, + terminalMgr: { + getAll: () => [ + { + spec: { workspaceId: "ws-1", kind: "agent", title: "Claude" as string | undefined }, + toDTO: () => + ({ + id: "term-1", + workspaceId: "ws-1", + kind: "agent", + title: "Claude", + cwd: "/tmp", + argv: ["claude"], + cols: 120, + rows: 30, + pid: 100, + alive: true, + createdAt: 1, + }) satisfies Terminal, + }, + ], + }, + hostCollector: { + collect: () => ({ + cpuPercent: 40, + memoryUsedBytes: 400, + memoryTotalBytes: 1000, + memoryAvailableBytes: 600, + loadAverage: [0.2, 0.2, 0.1], + uptimeSec: 10, + pressure: "normal", + }), + }, + processCollector: { + collect: async () => [ + { pid: 100, ppid: 1, cpuPercent: 10, rssBytes: 100, elapsedSec: 5, command: "claude" }, + ], + }, + setInterval: vi.fn(() => ({ unref: vi.fn() })), + clearInterval: vi.fn(), + now: () => 10, + }); + + service.start(); + await service.recheck(); + + expect(registry.listRoots()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + ownerId: "terminal:term-1", + rootPid: 100, + workspaceId: "ws-1", + terminalId: "term-1", + sessionId: "sess-1", + providerId: "claude", + label: "Claude Session", + }), + ]) + ); + }); + + it("labels workspace attribution rows with readable workspace names", async () => { + const registry = new ManagedProcessRegistry({ now: () => 10 }); + const service = new MonitoringService({ + broadcaster: { broadcast: vi.fn() }, + settingsRepo: { + get: (key: string) => { + const settings = { + "monitoring.enabled": true, + "monitoring.hostMetricsEnabled": true, + "monitoring.runtimeSummaryEnabled": true, + "monitoring.workspaceAttributionEnabled": true, + "monitoring.subprocessDrilldownEnabled": false, + "monitoring.sampleIntervalMs": 2000, + } as Record; + return settings[key]; + }, + }, + registry, + sessionMgr: { + getAll: () => + [ + { + id: "sess-1", + workspaceId: "ws_1779980247607_u2lfvdjf", + terminalId: "term-1", + providerId: "codex", + state: "idle", + capability: "full", + startedAt: 1, + lastActiveAt: 1, + title: "Codex", + }, + ] satisfies Session[], + findSessionIdByTerminal: () => "sess-1", + }, + workspaceMgr: { + get: (workspaceId: string) => + workspaceId === "ws_1779980247607_u2lfvdjf" + ? { + id: workspaceId, + path: "/home/spencer/workspace/coder-studio", + } + : undefined, + }, + terminalMgr: { + getAll: () => [ + { + toDTO: () => + ({ + id: "term-1", + workspaceId: "ws_1779980247607_u2lfvdjf", + kind: "agent", + title: "Codex", + cwd: "/home/spencer/workspace/coder-studio", + argv: ["codex"], + cols: 120, + rows: 30, + pid: 100, + alive: true, + createdAt: 1, + }) satisfies Terminal, + }, + ], + }, + hostCollector: { + collect: () => ({ + cpuPercent: 40, + memoryUsedBytes: 400, + memoryTotalBytes: 1000, + memoryAvailableBytes: 600, + loadAverage: [0.2, 0.2, 0.1], + uptimeSec: 10, + pressure: "normal", + }), + }, + processCollector: { + collect: async () => [ + { pid: 100, ppid: 1, cpuPercent: 10, rssBytes: 100, elapsedSec: 5, command: "codex" }, + ], + }, + setInterval: vi.fn(() => ({ unref: vi.fn() })), + clearInterval: vi.fn(), + now: () => 10, + }); + + service.start(); + const response = await service.recheck(); + + expect(response.snapshot.workspaces[0]).toEqual( + expect.objectContaining({ + id: "workspace:ws_1779980247607_u2lfvdjf", + label: "coder-studio", + }) + ); + expect(response.snapshot.sessions[0]).toEqual( + expect.objectContaining({ + parentId: "workspace:ws_1779980247607_u2lfvdjf", + label: "Codex", + }) + ); + }); + + it("unregisters terminal roots that are no longer active", async () => { + const registry = new ManagedProcessRegistry({ now: () => 10 }); + let sessions: Session[] = [ + { + id: "sess-1", + workspaceId: "ws-1", + terminalId: "term-1", + providerId: "claude", + state: "idle", + capability: "full", + startedAt: 1, + lastActiveAt: 1, + }, + ]; + let terminals: ActiveTerminalLike[] = [ + { + toDTO: () => + ({ + id: "term-1", + workspaceId: "ws-1", + kind: "agent", + title: "Claude", + cwd: "/tmp", + argv: ["claude"], + cols: 120, + rows: 30, + pid: 100, + alive: true, + createdAt: 1, + }) satisfies Terminal, + }, + ]; + + const service = new MonitoringService({ + broadcaster: { broadcast: vi.fn() }, + settingsRepo: { + get: (key: string) => { + const settings = { + "monitoring.enabled": true, + "monitoring.hostMetricsEnabled": true, + "monitoring.runtimeSummaryEnabled": true, + "monitoring.workspaceAttributionEnabled": true, + "monitoring.subprocessDrilldownEnabled": true, + "monitoring.sampleIntervalMs": 2000, + } as Record; + return settings[key]; + }, + }, + registry, + sessionMgr: { + getAll: () => sessions, + findSessionIdByTerminal: () => sessions[0]?.id, + }, + terminalMgr: { + getAll: () => terminals, + }, + hostCollector: { + collect: () => ({ + cpuPercent: 30, + memoryUsedBytes: 300, + memoryTotalBytes: 1000, + memoryAvailableBytes: 700, + loadAverage: [0.3, 0.2, 0.1], + uptimeSec: 10, + pressure: "normal", + }), + }, + processCollector: { collect: async () => [] }, + setInterval: vi.fn(() => ({ unref: vi.fn() })), + clearInterval: vi.fn(), + now: () => 10, + }); + + service.start(); + await service.recheck(); + + expect(registry.listRoots().map((root) => root.ownerId)).toContain("terminal:term-1"); + + sessions = []; + terminals = []; + await service.recheck(); + + expect(registry.listRoots().map((root) => root.ownerId)).not.toContain("terminal:term-1"); + }); +}); diff --git a/packages/server/src/__tests__/provider-runtime/command-check.test.ts b/packages/server/src/__tests__/provider-runtime/command-check.test.ts index 9afdb5c6..b279aaa2 100644 --- a/packages/server/src/__tests__/provider-runtime/command-check.test.ts +++ b/packages/server/src/__tests__/provider-runtime/command-check.test.ts @@ -57,4 +57,69 @@ describe("checkCommandAvailable", () => { expect(execFile).toHaveBeenCalledWith("where", ["codex"], { windowsHide: true }); }); + + it("checks the filesystem directly for absolute Windows paths instead of invoking where", async () => { + // `where.exe` rejects `C:\...` arguments with "invalid pattern" because it + // parses the first colon as a path:pattern separator. Make sure we never + // hand absolute paths to it. + const execFile = vi.fn(); + const absolutePath = "C:\\tools\\lsp\\vue\\node_modules\\.bin\\vue-language-server.cmd"; + + await expect( + checkCommandAvailable(absolutePath, { + platform: "win32", + runCommand: execFile, + existsSync: (file) => file === absolutePath, + }) + ).resolves.toBe(true); + + expect(execFile).not.toHaveBeenCalled(); + }); + + it("returns false for absolute Windows paths that do not exist on disk", async () => { + const execFile = vi.fn(); + + await expect( + checkCommandAvailable("C:\\tools\\missing.cmd", { + platform: "win32", + runCommand: execFile, + existsSync: () => false, + pathExt: ".CMD;.EXE", + }) + ).resolves.toBe(false); + + expect(execFile).not.toHaveBeenCalled(); + }); + + it("appends PATHEXT extensions when an absolute Windows path has no extension", async () => { + const execFile = vi.fn(); + const baseline = "C:\\tools\\bin\\vue-language-server"; + const resolved = `${baseline}.CMD`; + + await expect( + checkCommandAvailable(baseline, { + platform: "win32", + runCommand: execFile, + existsSync: (file) => file === resolved, + pathExt: ".EXE;.CMD", + }) + ).resolves.toBe(true); + + expect(execFile).not.toHaveBeenCalled(); + }); + + it("checks the filesystem directly for absolute POSIX paths", async () => { + const execFile = vi.fn(); + const absolutePath = "/opt/coder-studio/lsp-tools/go/bin/gopls"; + + await expect( + checkCommandAvailable(absolutePath, { + platform: "linux", + runCommand: execFile, + existsSync: (file) => file === absolutePath, + }) + ).resolves.toBe(true); + + expect(execFile).not.toHaveBeenCalled(); + }); }); diff --git a/packages/server/src/__tests__/provider-runtime/command-runner.test.ts b/packages/server/src/__tests__/provider-runtime/command-runner.test.ts index 40e36dcf..9ca7f049 100644 --- a/packages/server/src/__tests__/provider-runtime/command-runner.test.ts +++ b/packages/server/src/__tests__/provider-runtime/command-runner.test.ts @@ -116,6 +116,23 @@ describe("runCommandAsString", () => { ); }); + it("routes explicit Windows cmd-shim paths through a shell", async () => { + Object.defineProperty(process, "platform", { configurable: true, value: "win32" }); + spawnMock.mockImplementation(() => { + const child = createChildProcessMock(); + queueMicrotask(() => child.emit("close", 0)); + return child; + }); + + await runCommandAsString("C:\\tools\\vue-language-server.cmd", ["--version"]); + + expect(spawnMock).toHaveBeenCalledWith( + "C:\\tools\\vue-language-server.cmd", + ["--version"], + expect.objectContaining({ cwd: undefined, env: undefined, shell: true, windowsHide: true }) + ); + }); + it("keeps native Windows executables off the shell path", async () => { Object.defineProperty(process, "platform", { configurable: true, value: "win32" }); spawnMock.mockImplementation(() => { diff --git a/packages/server/src/__tests__/provider-runtime/install-manager.test.ts b/packages/server/src/__tests__/provider-runtime/install-manager.test.ts index 55c9a2c6..b0b98c6b 100644 --- a/packages/server/src/__tests__/provider-runtime/install-manager.test.ts +++ b/packages/server/src/__tests__/provider-runtime/install-manager.test.ts @@ -147,6 +147,62 @@ describe("ProviderInstallManager", () => { expect(stored?.steps[0]?.status).toBe("failed"); }); + it("classifies install-step EACCES failures as permission_denied", async () => { + const installError = Object.assign(new Error("spawn npm EACCES"), { + code: "EACCES", + stderr: "Permission denied", + stdout: "", + }); + const manager = new ProviderInstallManager([codexDefinition], { + platform: "linux", + commandExists: vi.fn(async (command: string) => command === "npm"), + runCommand: vi.fn(async () => { + throw installError; + }), + }); + + const started = await manager.start("codex"); + + await vi.waitFor(() => { + expect(manager.get(started.jobId)?.status).toBe("failed"); + }); + + expect(manager.get(started.jobId)).toMatchObject({ + status: "failed", + failure: { + code: "permission_denied", + }, + }); + }); + + it("classifies install-step non-ENOENT non-permission failures as command_failed", async () => { + const installError = Object.assign(new Error("spawn npm EIO"), { + code: "EIO", + stderr: "terminal backend failed", + stdout: "", + }); + const manager = new ProviderInstallManager([codexDefinition], { + platform: "linux", + commandExists: vi.fn(async (command: string) => command === "npm"), + runCommand: vi.fn(async () => { + throw installError; + }), + }); + + const started = await manager.start("codex"); + + await vi.waitFor(() => { + expect(manager.get(started.jobId)?.status).toBe("failed"); + }); + + expect(manager.get(started.jobId)).toMatchObject({ + status: "failed", + failure: { + code: "command_failed", + }, + }); + }); + it("executes Windows install steps with the declared command names", async () => { let npmInstalled = false; let codexInstalled = false; diff --git a/packages/server/src/__tests__/server-monitoring-hydration.test.ts b/packages/server/src/__tests__/server-monitoring-hydration.test.ts new file mode 100644 index 00000000..c97d3d18 --- /dev/null +++ b/packages/server/src/__tests__/server-monitoring-hydration.test.ts @@ -0,0 +1,44 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createServer, type Server } from "../server.js"; +import { SettingsRepo } from "../storage/index.js"; + +describe("server monitoring hydration", () => { + let server: Server | undefined; + let stateDir: string; + + beforeEach(() => { + stateDir = mkdtempSync(join(tmpdir(), "coder-studio-monitoring-state-")); + }); + + afterEach(async () => { + if (server) { + await server.stop(); + server = undefined; + } + rmSync(stateDir, { recursive: true, force: true }); + }); + + it("hydrates persisted monitoring settings into the monitoring service on startup", async () => { + const settingsRepo = new SettingsRepo({ + filePath: join(stateDir, "state", "settings.json"), + }); + settingsRepo.set("monitoring.enabled", true); + settingsRepo.set("monitoring.sampleIntervalMs", 5000); + + server = await createServer({ + stateDir, + host: "127.0.0.1", + port: 0, + }); + + expect(server.__test__?.commandContext.monitoringService?.getResponse().settings).toEqual( + expect.objectContaining({ + enabled: true, + sampleIntervalMs: 5000, + }) + ); + }); +}); diff --git a/packages/server/src/__tests__/session-commands.test.ts b/packages/server/src/__tests__/session-commands.test.ts index 89b70efd..2ec3a93f 100644 --- a/packages/server/src/__tests__/session-commands.test.ts +++ b/packages/server/src/__tests__/session-commands.test.ts @@ -428,6 +428,67 @@ describe("Session Commands", () => { }); }); + it("preserves non-session leaf kinds when removing a typed session pane", async () => { + const workspacePath = mkdtempSync(join(tmpdir(), "coder-studio-close-typed-")); + tempDirs.push(workspacePath); + const workspace = await workspaceMgr.open({ path: workspacePath }); + workspaceMgr.updateUiState(workspace.id, { + ...workspace.uiState, + paneLayout: { + id: "root", + type: "split", + direction: "horizontal", + children: [ + { id: "left", type: "leaf", leafKind: "draft" }, + { id: "center", type: "leaf", leafKind: "session", sessionId: "sess-typed" }, + { id: "right", type: "leaf", leafKind: "editor" }, + ], + }, + }); + + const deleteSpy = vi.spyOn(sessionMgr, "delete").mockImplementation(() => {}); + vi.spyOn(sessionMgr, "get").mockImplementation((sessionId: string) => + sessionId === "sess-typed" + ? ({ + id: "sess-typed", + workspaceId: workspace.id, + terminalId: "term-typed", + providerId: "codex", + capability: "full", + state: "ended", + startedAt: 1, + lastActiveAt: 1, + endedAt: 2, + } as const) + : undefined + ); + + const result = await dispatch( + { + kind: "command", + id: "test-id-close-typed", + op: "session.close", + args: { + sessionId: "sess-typed", + paneDisposition: "remove", + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(deleteSpy).toHaveBeenCalledWith("sess-typed"); + expect(workspaceMgr.get(workspace.id)?.uiState.paneLayout).toEqual({ + id: "root", + type: "split", + direction: "horizontal", + children: [ + { id: "left", type: "leaf", leafKind: "draft" }, + { id: "right", type: "leaf", leafKind: "editor" }, + ], + }); + }); + it("keeps the pane as a draft leaf for desktop disposition", async () => { const workspacePath = mkdtempSync(join(tmpdir(), "coder-studio-close-desktop-")); tempDirs.push(workspacePath); diff --git a/packages/server/src/__tests__/session-integration.test.ts b/packages/server/src/__tests__/session-integration.test.ts index 7e80fd25..1e9fe130 100644 --- a/packages/server/src/__tests__/session-integration.test.ts +++ b/packages/server/src/__tests__/session-integration.test.ts @@ -72,6 +72,7 @@ function createMockPtyHost(spawnCalls: Array<{ argv: string[]; options: unknown }; const pty: PtyProcess = { + pid: 43210, onData: (callback) => { const term = terminals.get(id); if (term) term.onDataCallbacks.push(callback); diff --git a/packages/server/src/__tests__/session-manager-api.test.ts b/packages/server/src/__tests__/session-manager-api.test.ts index 2081322f..4d3be10d 100644 --- a/packages/server/src/__tests__/session-manager-api.test.ts +++ b/packages/server/src/__tests__/session-manager-api.test.ts @@ -36,6 +36,7 @@ describe("SessionManager session-level API", () => { ptyResizes = []; mockPty = { + pid: 43210, onData: vi.fn(), onExit: vi.fn(), write: vi.fn((bytes: Buffer | string) => { diff --git a/packages/server/src/__tests__/session-terminal-exit.test.ts b/packages/server/src/__tests__/session-terminal-exit.test.ts index e2160cf5..ff90de47 100644 --- a/packages/server/src/__tests__/session-terminal-exit.test.ts +++ b/packages/server/src/__tests__/session-terminal-exit.test.ts @@ -47,6 +47,7 @@ function createMockPtyHost(): { const processIndex = processes.length; const pty: PtyProcess = { + pid: 43210, onData: (callback) => { processes[processIndex]?.onDataCallbacks.push(callback); }, diff --git a/packages/server/src/__tests__/system-deps/commands.test.ts b/packages/server/src/__tests__/system-deps/commands.test.ts new file mode 100644 index 00000000..37b29cc3 --- /dev/null +++ b/packages/server/src/__tests__/system-deps/commands.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, it, vi } from "vitest"; +import type { CommandContext } from "../../ws/dispatch.js"; +import { dispatch } from "../../ws/dispatch.js"; + +import "../../commands/system-deps.js"; + +function createContext(overrides: Partial = {}): CommandContext { + return { + workspaceMgr: {} as never, + sessionMgr: {} as never, + terminalMgr: {} as never, + eventBus: {} as never, + broadcaster: { + broadcast: vi.fn(), + sendToClient: () => true, + sendBinaryToClient: () => true, + } as never, + settingsRepo: {} as never, + providerConfigRepo: {} as never, + providerRegistry: [], + fencingMgr: {} as never, + supervisorMgr: {} as never, + autoFetch: {} as never, + activationMgr: {} as never, + lspMgr: {} as never, + providerRuntimeDeps: { + platform: "darwin", + commandExists: vi.fn(async (command: string) => command === "brew" || command === "git"), + runCommand: vi.fn(async (file: string) => { + if (file === "git") { + return { stdout: "git version 2.49.0\n", stderr: "" }; + } + if (file === "node") { + throw Object.assign(new Error("missing node"), { + exitCode: 127, + stdout: "", + stderr: "", + }); + } + return { stdout: "", stderr: "" }; + }), + }, + ...overrides, + }; +} + +describe("system deps commands", () => { + it("returns runtime status through systemDeps.runtimeStatus", async () => { + const result = await dispatch( + { kind: "command", id: "sysdeps-status", op: "systemDeps.runtimeStatus", args: {} }, + createContext() + ); + + expect(result.ok).toBe(true); + expect(result.data).toMatchObject({ + dependencies: { + git: { available: true }, + node: { available: false, autoInstallSupported: true }, + }, + }); + }); + + it("returns install lifecycle errors when the manager is missing or the job id is unknown", async () => { + const unavailable = await dispatch( + { + kind: "command", + id: "sysdeps-start-missing", + op: "systemDeps.install.start", + args: { dependencyId: "git" }, + }, + createContext() + ); + expect(unavailable.ok).toBe(false); + expect(unavailable.error?.code).toBe("system_dependency_install_unavailable"); + + const contextWithManager = createContext({ + systemDependencyInstallMgr: { + start: vi.fn(async () => ({ + jobId: "job-1", + dependencyId: "git", + status: "queued", + steps: [], + interaction: { kind: "none", echo: false }, + })), + get: vi.fn(() => undefined), + submitInput: vi.fn(), + cancel: vi.fn(), + } as never, + }); + + const missingJob = await dispatch( + { + kind: "command", + id: "sysdeps-get-missing", + op: "systemDeps.install.get", + args: { jobId: "missing-job" }, + }, + contextWithManager + ); + expect(missingJob.ok).toBe(false); + expect(missingJob.error?.code).toBe("system_dependency_install_job_not_found"); + }); + + it("binds install lifecycle commands to the owner client", async () => { + let activeWsClientId = "client-a"; + const start = vi.fn(async (_dependencyId: string, ownerId: string, routeClientId: string) => ({ + jobId: "job-1", + dependencyId: "git", + status: "queued", + steps: [], + interaction: { kind: "none", echo: false }, + ownerId, + routeClientId, + })); + const get = vi.fn((jobId: string, ownerId: string, routeClientId: string) => { + if ( + jobId === "job-1" && + ownerId === "tab-a" && + (routeClientId === "client-a" || routeClientId === "client-a-reconnected") + ) { + return { + jobId, + dependencyId: "git", + status: "running", + steps: [], + interaction: { kind: "none", echo: false }, + }; + } + return undefined; + }); + const submitInput = vi.fn( + async (jobId: string, ownerId: string, text: string, routeClientId: string) => ({ + jobId, + dependencyId: "git", + status: "running", + steps: [], + interaction: { kind: "none", echo: false }, + ownerId, + routeClientId, + submittedText: text, + }) + ); + const cancel = vi.fn(async (jobId: string, ownerId: string, routeClientId: string) => ({ + jobId, + dependencyId: "git", + status: "cancelled", + steps: [], + interaction: { kind: "none", echo: false }, + ownerId, + routeClientId, + })); + + const context = createContext({ + activationMgr: { + getLease: vi.fn(() => ({ + clientInstanceId: "tab-a", + wsClientId: activeWsClientId, + })), + } as never, + systemDependencyInstallMgr: { + start, + get, + submitInput, + cancel, + } as never, + }); + + const started = await dispatch( + { + kind: "command", + id: "sysdeps-start-owner", + op: "systemDeps.install.start", + args: { dependencyId: "git" }, + }, + context, + "client-a" + ); + + expect(started.ok).toBe(true); + expect(start).toHaveBeenCalledWith("git", "tab-a", "client-a"); + + const ownerGet = await dispatch( + { + kind: "command", + id: "sysdeps-get-owner", + op: "systemDeps.install.get", + args: { jobId: "job-1" }, + }, + context, + "client-a" + ); + expect(ownerGet.ok).toBe(true); + expect(get).toHaveBeenCalledWith("job-1", "tab-a", "client-a"); + + activeWsClientId = "client-a-reconnected"; + const reconnectedGet = await dispatch( + { + kind: "command", + id: "sysdeps-get-owner-reconnected", + op: "systemDeps.install.get", + args: { jobId: "job-1" }, + }, + context, + "client-a-reconnected" + ); + expect(reconnectedGet.ok).toBe(true); + expect(get).toHaveBeenCalledWith("job-1", "tab-a", "client-a-reconnected"); + + const forbiddenGet = await dispatch( + { + kind: "command", + id: "sysdeps-get-forbidden", + op: "systemDeps.install.get", + args: { jobId: "job-1" }, + }, + context, + "client-b" + ); + expect(forbiddenGet.ok).toBe(false); + expect(forbiddenGet.error?.code).toBe("system_dependency_install_job_not_found"); + + const ownerInput = await dispatch( + { + kind: "command", + id: "sysdeps-input-owner", + op: "systemDeps.install.input", + args: { jobId: "job-1", text: "hunter2\n" }, + }, + context, + "client-a-reconnected" + ); + expect(ownerInput.ok).toBe(true); + expect(submitInput).toHaveBeenCalledWith("job-1", "tab-a", "hunter2\n", "client-a-reconnected"); + + const ownerCancel = await dispatch( + { + kind: "command", + id: "sysdeps-cancel-owner", + op: "systemDeps.install.cancel", + args: { jobId: "job-1" }, + }, + context, + "client-a-reconnected" + ); + expect(ownerCancel.ok).toBe(true); + expect(cancel).toHaveBeenCalledWith("job-1", "tab-a", "client-a-reconnected"); + }); +}); diff --git a/packages/server/src/__tests__/system-deps/install-manager.test.ts b/packages/server/src/__tests__/system-deps/install-manager.test.ts new file mode 100644 index 00000000..67397689 --- /dev/null +++ b/packages/server/src/__tests__/system-deps/install-manager.test.ts @@ -0,0 +1,335 @@ +import { Topics } from "@coder-studio/core"; +import { describe, expect, it, vi } from "vitest"; +import { SystemDependencyInstallManager } from "../../system-deps/install-manager.js"; + +function createFakePtyHost() { + let onData: ((data: string) => void) | undefined; + let onExit: + | ((event: { exitCode: number; signal?: number; reason?: "exit" | "pty_disconnected" }) => void) + | undefined; + const writes: string[] = []; + + return { + writes, + host: { + spawn: vi.fn(() => ({ + onData: (cb: (data: string) => void) => { + onData = cb; + }, + onExit: ( + cb: (event: { + exitCode: number; + signal?: number; + reason?: "exit" | "pty_disconnected"; + }) => void + ) => { + onExit = cb; + }, + write: (data: string | Buffer) => { + writes.push(Buffer.isBuffer(data) ? data.toString("utf8") : data); + }, + resize: () => {}, + kill: async () => { + onExit?.({ exitCode: 130 }); + }, + })), + }, + emitData: (data: string) => onData?.(data), + emitExit: ( + event: { exitCode?: number; signal?: number; reason?: "exit" | "pty_disconnected" } = {} + ) => + onExit?.({ + exitCode: event.exitCode ?? 0, + signal: event.signal, + reason: event.reason, + }), + }; +} + +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((innerResolve, innerReject) => { + resolve = innerResolve; + reject = innerReject; + }); + return { promise, resolve, reject }; +} + +describe("SystemDependencyInstallManager", () => { + it("reuses the active job for the owner, rebinds output after reconnect, waits for password input, and verifies success", async () => { + const pty = createFakePtyHost(); + const sendToClient = vi.fn(() => true); + let gitInstalled = false; + const manager = new SystemDependencyInstallManager({ + platform: "linux", + ptyHost: pty.host, + broadcaster: { sendToClient } as never, + commandExists: vi.fn( + async (command: string) => command === "apt-get" || (gitInstalled && command === "git") + ), + runCommand: vi.fn(async (file: string) => { + if (file === "git") { + if (!gitInstalled) { + throw Object.assign(new Error("missing"), { exitCode: 127, stdout: "", stderr: "" }); + } + return { stdout: "git version 2.49.0\n", stderr: "" }; + } + throw Object.assign(new Error("missing"), { exitCode: 127, stdout: "", stderr: "" }); + }), + }); + + const first = await manager.start("git", "tab-a", "client-a"); + const second = await manager.start("git", "tab-a", "client-a"); + + expect(second.jobId).toBe(first.jobId); + await expect(manager.start("git", "tab-b", "client-b")).rejects.toMatchObject({ + code: "system_dependency_install_in_progress", + }); + + pty.emitData("[sudo] password for spencer:"); + + await vi.waitFor(() => { + expect(manager.get(first.jobId, "tab-a", "client-a")?.status).toBe("waiting_input"); + }); + expect(manager.get(first.jobId, "tab-b", "client-b")).toBeUndefined(); + + expect(manager.get(first.jobId, "tab-a", "client-a-reconnected")?.status).toBe("waiting_input"); + + await manager.submitInput(first.jobId, "tab-a", "hunter2\n", "client-a-reconnected"); + expect(pty.writes.at(-1)).toBe("hunter2\n"); + + gitInstalled = true; + pty.emitData("installed git\n"); + pty.emitExit({ exitCode: 0 }); + + await vi.waitFor(() => { + expect(manager.get(first.jobId, "tab-a", "client-a-reconnected")?.status).toBe("succeeded"); + }); + + expect(sendToClient).toHaveBeenCalledWith( + "client-a-reconnected", + expect.objectContaining({ + kind: "event", + topic: Topics.systemDependencyInstallOutput(first.jobId), + data: expect.objectContaining({ jobId: first.jobId, chunk: "installed git\n" }), + }) + ); + }); + + it("marks a cancelled job when the user aborts the install", async () => { + const pty = createFakePtyHost(); + const manager = new SystemDependencyInstallManager({ + platform: "linux", + ptyHost: pty.host, + broadcaster: { sendToClient: vi.fn(() => true) } as never, + commandExists: vi.fn(async (command: string) => command === "apt-get"), + runCommand: vi.fn(async () => { + throw Object.assign(new Error("missing"), { exitCode: 127, stdout: "", stderr: "" }); + }), + }); + + const job = await manager.start("git", "tab-a", "client-a"); + await manager.cancel(job.jobId, "tab-a", "client-a"); + + expect(manager.get(job.jobId, "tab-a", "client-a")).toMatchObject({ + status: "cancelled", + failure: { code: "user_cancelled" }, + }); + }); + + it("keeps a cancelled job cancelled if the user aborts during verification", async () => { + const pty = createFakePtyHost(); + const verifyDeferred = createDeferred<{ stdout: string; stderr: string }>(); + let gitInstalled = false; + const manager = new SystemDependencyInstallManager({ + platform: "linux", + ptyHost: pty.host, + broadcaster: { sendToClient: vi.fn(() => true) } as never, + commandExists: vi.fn( + async (command: string) => command === "apt-get" || (gitInstalled && command === "git") + ), + runCommand: vi.fn(async (file: string) => { + if (file === "git") { + return verifyDeferred.promise; + } + throw Object.assign(new Error("missing"), { exitCode: 127, stdout: "", stderr: "" }); + }), + }); + + const job = await manager.start("git", "tab-a", "client-a"); + gitInstalled = true; + pty.emitExit({ exitCode: 0 }); + + await vi.waitFor(() => { + expect(manager.get(job.jobId, "tab-a", "client-a")?.currentStepId).toBe("verify-git"); + }); + + await manager.cancel(job.jobId, "tab-a", "client-a"); + verifyDeferred.resolve({ stdout: "git version 2.49.0\n", stderr: "" }); + + await vi.waitFor(() => { + expect(manager.get(job.jobId, "tab-a", "client-a")).toMatchObject({ + status: "cancelled", + failure: { code: "user_cancelled" }, + }); + }); + }); + + it("classifies permission denied failures from install output", async () => { + const pty = createFakePtyHost(); + const manager = new SystemDependencyInstallManager({ + platform: "linux", + ptyHost: pty.host, + broadcaster: { sendToClient: vi.fn(() => true) } as never, + commandExists: vi.fn(async (command: string) => command === "apt-get"), + runCommand: vi.fn(async () => { + throw Object.assign(new Error("missing"), { exitCode: 127, stdout: "", stderr: "" }); + }), + }); + + const job = await manager.start("git", "tab-a", "client-a"); + pty.emitData( + "E: Could not open lock file /var/lib/dpkg/lock-frontend - open (13: Permission denied)\n" + ); + pty.emitExit({ exitCode: 100 }); + + await vi.waitFor(() => { + expect(manager.get(job.jobId, "tab-a", "client-a")).toMatchObject({ + status: "failed", + failure: { code: "permission_denied" }, + }); + }); + }); + + it("classifies pty disconnect failures distinctly", async () => { + const pty = createFakePtyHost(); + const manager = new SystemDependencyInstallManager({ + platform: "linux", + ptyHost: pty.host, + broadcaster: { sendToClient: vi.fn(() => true) } as never, + commandExists: vi.fn(async (command: string) => command === "apt-get"), + runCommand: vi.fn(async () => { + throw Object.assign(new Error("missing"), { exitCode: 127, stdout: "", stderr: "" }); + }), + }); + + const job = await manager.start("git", "tab-a", "client-a"); + pty.emitExit({ exitCode: 1, reason: "pty_disconnected" }); + + await vi.waitFor(() => { + expect(manager.get(job.jobId, "tab-a", "client-a")).toMatchObject({ + status: "failed", + failure: { code: "pty_disconnected" }, + }); + }); + }); + + it("does not misclassify signal exits as pty disconnects", async () => { + const pty = createFakePtyHost(); + const manager = new SystemDependencyInstallManager({ + platform: "linux", + ptyHost: pty.host, + broadcaster: { sendToClient: vi.fn(() => true) } as never, + commandExists: vi.fn(async (command: string) => command === "apt-get"), + runCommand: vi.fn(async () => { + throw Object.assign(new Error("missing"), { exitCode: 127, stdout: "", stderr: "" }); + }), + }); + + const job = await manager.start("git", "tab-a", "client-a"); + pty.emitExit({ exitCode: 143, signal: 15 }); + + await vi.waitFor(() => { + expect(manager.get(job.jobId, "tab-a", "client-a")).toMatchObject({ + status: "failed", + failure: { code: "command_failed" }, + }); + }); + }); + + it("allows the owner to retry after a failed install", async () => { + const firstPty = createFakePtyHost(); + const secondPty = createFakePtyHost(); + const spawn = vi + .fn() + .mockReturnValueOnce(firstPty.host.spawn()) + .mockReturnValueOnce(secondPty.host.spawn()); + const manager = new SystemDependencyInstallManager({ + platform: "linux", + ptyHost: { spawn } as never, + broadcaster: { sendToClient: vi.fn(() => true) } as never, + commandExists: vi.fn(async (command: string) => command === "apt-get"), + runCommand: vi.fn(async () => { + throw Object.assign(new Error("missing"), { exitCode: 127, stdout: "", stderr: "" }); + }), + }); + + const first = await manager.start("git", "tab-a", "client-a"); + firstPty.emitExit({ exitCode: 1 }); + + await vi.waitFor(() => { + expect(manager.get(first.jobId, "tab-a", "client-a")?.status).toBe("failed"); + }); + + const retried = await manager.start("git", "tab-a", "client-a"); + expect(retried.jobId).not.toBe(first.jobId); + expect(spawn).toHaveBeenCalledTimes(2); + }); + + it("classifies PTY spawn EACCES failures as permission_denied", async () => { + const manager = new SystemDependencyInstallManager({ + platform: "linux", + ptyHost: { + spawn: vi.fn(() => { + throw Object.assign(new Error("spawn sudo EACCES"), { + code: "EACCES", + stderr: "Permission denied", + }); + }), + } as never, + broadcaster: { sendToClient: vi.fn(() => true) } as never, + commandExists: vi.fn(async (command: string) => command === "apt-get"), + runCommand: vi.fn(async () => { + throw Object.assign(new Error("missing"), { exitCode: 127, stdout: "", stderr: "" }); + }), + }); + + const job = await manager.start("git", "tab-a", "client-a"); + + expect(job).toMatchObject({ + status: "failed", + failure: { + code: "permission_denied", + }, + }); + }); + + it("classifies PTY spawn failures without a known permission or ENOENT code as command_failed", async () => { + const manager = new SystemDependencyInstallManager({ + platform: "linux", + ptyHost: { + spawn: vi.fn(() => { + throw Object.assign(new Error("spawn failed"), { + code: "EIO", + stderr: "terminal backend failed", + }); + }), + } as never, + broadcaster: { sendToClient: vi.fn(() => true) } as never, + commandExists: vi.fn(async (command: string) => command === "apt-get"), + runCommand: vi.fn(async () => { + throw Object.assign(new Error("missing"), { exitCode: 127, stdout: "", stderr: "" }); + }), + }); + + const job = await manager.start("git", "tab-a", "client-a"); + + expect(job).toMatchObject({ + status: "failed", + failure: { + code: "command_failed", + }, + }); + }); +}); diff --git a/packages/server/src/__tests__/system-deps/interaction-detector.test.ts b/packages/server/src/__tests__/system-deps/interaction-detector.test.ts new file mode 100644 index 00000000..7a12ba7a --- /dev/null +++ b/packages/server/src/__tests__/system-deps/interaction-detector.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { detectSystemDependencyInteraction } from "../../system-deps/interaction-detector.js"; + +describe("detectSystemDependencyInteraction", () => { + it("detects sudo password prompts without enabling echo", () => { + expect(detectSystemDependencyInteraction("[sudo] password for spencer:")).toEqual({ + kind: "sudo_password", + promptExcerpt: "[sudo] password for spencer:", + echo: false, + }); + }); + + it("detects confirmation prompts", () => { + expect(detectSystemDependencyInteraction("Proceed? [Y/n]")).toEqual({ + kind: "confirm", + promptExcerpt: "Proceed? [Y/n]", + echo: true, + }); + }); + + it("returns none when output is not interactive", () => { + expect(detectSystemDependencyInteraction("installed git")).toEqual({ + kind: "none", + echo: false, + }); + }); +}); diff --git a/packages/server/src/__tests__/system-deps/runtime-status.test.ts b/packages/server/src/__tests__/system-deps/runtime-status.test.ts new file mode 100644 index 00000000..fa0ff634 --- /dev/null +++ b/packages/server/src/__tests__/system-deps/runtime-status.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from "vitest"; +import { buildSystemDependencyRuntimeStatus } from "../../system-deps/runtime-status.js"; + +describe("buildSystemDependencyRuntimeStatus", () => { + it("uses commandExists as the availability gate before reading versions", async () => { + const runCommand = vi.fn(async (file: string) => { + if (file === "git") { + return { stdout: "git version 2.49.0\n", stderr: "" }; + } + if (file === "node") { + return { stdout: "v24.1.0\n", stderr: "" }; + } + throw new Error(`unexpected command: ${file}`); + }); + + const status = await buildSystemDependencyRuntimeStatus({ + platform: "darwin", + commandExists: vi.fn(async (command: string) => command === "brew" || command === "node"), + runCommand, + }); + + expect(status.dependencies.git).toMatchObject({ + dependencyId: "git", + available: false, + version: undefined, + autoInstallSupported: true, + installReadiness: "ready", + packageManager: "brew", + }); + expect(status.dependencies.node).toMatchObject({ + dependencyId: "node", + available: true, + version: "v24.1.0", + }); + expect(runCommand).toHaveBeenCalledTimes(1); + expect(runCommand).toHaveBeenCalledWith("node", ["--version"], { windowsHide: true }); + }); + + it("marks git installable on macOS when brew exists but git is missing", async () => { + const runCommand = vi.fn(async (file: string) => { + if (file === "git") { + throw Object.assign(new Error("missing git"), { exitCode: 127, stdout: "", stderr: "" }); + } + if (file === "node") { + return { stdout: "v24.1.0\n", stderr: "" }; + } + throw new Error(`unexpected command: ${file}`); + }); + + const status = await buildSystemDependencyRuntimeStatus({ + platform: "darwin", + commandExists: vi.fn(async (command: string) => command === "brew" || command === "node"), + runCommand, + }); + + expect(status.dependencies.git).toMatchObject({ + dependencyId: "git", + available: false, + autoInstallSupported: true, + installReadiness: "ready", + packageManager: "brew", + }); + expect(status.dependencies.node).toMatchObject({ + available: true, + version: "v24.1.0", + }); + }); + + it("reports unsupported_package_manager when Linux has neither apt nor brew", async () => { + const status = await buildSystemDependencyRuntimeStatus({ + platform: "linux", + commandExists: vi.fn(async () => false), + runCommand: vi.fn(async () => { + throw Object.assign(new Error("missing"), { exitCode: 127, stdout: "", stderr: "" }); + }), + }); + + expect(status.dependencies.git.installReadiness).toBe("unsupported_package_manager"); + expect(status.dependencies.node.autoInstallSupported).toBe(false); + }); +}); diff --git a/packages/server/src/__tests__/terminal-events.test.ts b/packages/server/src/__tests__/terminal-events.test.ts index 74bc1ea5..26c719af 100644 --- a/packages/server/src/__tests__/terminal-events.test.ts +++ b/packages/server/src/__tests__/terminal-events.test.ts @@ -21,6 +21,7 @@ describe("Terminal Events", () => { beforeEach(() => { // Create mock PTY process mockPty = { + pid: 43210, onData: vi.fn(), onExit: vi.fn(), write: vi.fn(), diff --git a/packages/server/src/__tests__/terminal-ring-buffer-tail.test.ts b/packages/server/src/__tests__/terminal-ring-buffer-tail.test.ts index 25db9269..f31e5294 100644 --- a/packages/server/src/__tests__/terminal-ring-buffer-tail.test.ts +++ b/packages/server/src/__tests__/terminal-ring-buffer-tail.test.ts @@ -6,6 +6,7 @@ import type { PtyHost, PtyProcess, TerminalDatabase, TerminalSpec } from "../ter describe("TerminalManager.getRingBufferTail", () => { it("returns the last N bytes from the terminal ring buffer", () => { const mockPty: PtyProcess = { + pid: 43210, onData: vi.fn(), onExit: vi.fn(), write: vi.fn(), diff --git a/packages/server/src/__tests__/workspace-commands.test.ts b/packages/server/src/__tests__/workspace-commands.test.ts index 81467e8c..90671663 100644 --- a/packages/server/src/__tests__/workspace-commands.test.ts +++ b/packages/server/src/__tests__/workspace-commands.test.ts @@ -1,5 +1,5 @@ import { mkdtempSync, rmSync } from "node:fs"; -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, stat, writeFile } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -191,6 +191,96 @@ describe("Workspace Commands", () => { }); }); + describe("workspace.mkdir", () => { + it("creates a directory at an absolute browse path", async () => { + const dir = join(tmpdir(), `workspace-mkdir-test-${Date.now()}`); + await mkdir(dir, { recursive: true }); + + const result = await dispatch( + { + kind: "command", + id: "workspace-mkdir-success", + op: "workspace.mkdir", + args: { + path: join(dir, "demo"), + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + const createdEntry = await stat(join(dir, "demo")); + expect(createdEntry.isDirectory()).toBe(true); + }); + + it("rejects creating a directory that already exists", async () => { + const dir = join(tmpdir(), `workspace-mkdir-exists-${Date.now()}`); + await mkdir(join(dir, "demo"), { recursive: true }); + + const result = await dispatch( + { + kind: "command", + id: "workspace-mkdir-exists", + op: "workspace.mkdir", + args: { + path: join(dir, "demo"), + }, + }, + ctx + ); + + expect(result.ok).toBe(false); + expect(result.error).toMatchObject({ + code: "already_exists", + }); + }); + + it.each(["", ".", ".."])('rejects invalid requested folder path "%s"', async (path) => { + const result = await dispatch( + { + kind: "command", + id: `workspace-mkdir-invalid-${path || "empty"}`, + op: "workspace.mkdir", + args: { + path, + }, + }, + ctx + ); + + expect(result.ok).toBe(false); + expect(result.error).toMatchObject({ + code: "invalid_path", + message: "Folder name is required", + }); + }); + + it.each([ + "~/.", + "~/..", + "foo/.", + "foo/..", + ])('rejects invalid requested folder path with trailing segment "%s"', async (path) => { + const result = await dispatch( + { + kind: "command", + id: `workspace-mkdir-invalid-trailing-${path.replaceAll("/", "-")}`, + op: "workspace.mkdir", + args: { + path, + }, + }, + ctx + ); + + expect(result.ok).toBe(false); + expect(result.error).toMatchObject({ + code: "invalid_path", + message: "Folder name is required", + }); + }); + }); + describe("workspace.close", () => { it("should error if workspace not found", async () => { const result = await dispatch( @@ -268,6 +358,154 @@ describe("Workspace Commands", () => { }); }); + it("persists typed pane leaves with draft and editor kinds", async () => { + const dir = join(tmpdir(), `workspace-command-typed-pane-test-${Date.now()}`); + await mkdir(dir); + + const openResult = await dispatch( + { + kind: "command", + id: "open-workspace-typed-pane-layout", + op: "workspace.open", + args: { + path: dir, + }, + }, + ctx + ); + + expect(openResult.ok).toBe(true); + + const workspaceId = (openResult.data as { id: string }).id; + const result = await dispatch( + { + kind: "command", + id: "set-ui-state-typed-pane-layout", + op: "workspace.uiState.set", + args: { + workspaceId, + uiState: { + leftPanelWidth: 320, + bottomPanelHeight: 210, + focusMode: false, + paneLayout: { + id: "root", + type: "split", + direction: "horizontal", + children: [ + { id: "left", type: "leaf", leafKind: "draft" }, + { id: "right", type: "leaf", leafKind: "editor" }, + ], + }, + }, + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect((result.data as { uiState: { paneLayout: unknown } }).uiState.paneLayout).toEqual({ + id: "root", + type: "split", + direction: "horizontal", + children: [ + { id: "left", type: "leaf", leafKind: "draft" }, + { id: "right", type: "leaf", leafKind: "editor" }, + ], + }); + }); + + it("rejects invalid typed pane leaves", async () => { + const dir = join(tmpdir(), `workspace-command-invalid-pane-test-${Date.now()}`); + await mkdir(dir); + + const openResult = await dispatch( + { + kind: "command", + id: "open-workspace-invalid-pane-layout", + op: "workspace.open", + args: { + path: dir, + }, + }, + ctx + ); + + expect(openResult.ok).toBe(true); + const workspaceId = (openResult.data as { id: string }).id; + + const draftWithSessionId = await dispatch( + { + kind: "command", + id: "set-ui-state-invalid-draft-pane-layout", + op: "workspace.uiState.set", + args: { + workspaceId, + uiState: { + leftPanelWidth: 320, + bottomPanelHeight: 210, + focusMode: false, + paneLayout: { + id: "root", + type: "leaf", + leafKind: "draft", + sessionId: "sess-invalid", + }, + }, + }, + }, + ctx + ); + expect(draftWithSessionId.ok).toBe(false); + + const sessionWithoutSessionId = await dispatch( + { + kind: "command", + id: "set-ui-state-invalid-session-pane-layout", + op: "workspace.uiState.set", + args: { + workspaceId, + uiState: { + leftPanelWidth: 320, + bottomPanelHeight: 210, + focusMode: false, + paneLayout: { + id: "root", + type: "leaf", + leafKind: "session", + }, + }, + }, + }, + ctx + ); + expect(sessionWithoutSessionId.ok).toBe(false); + + const editorWithSessionId = await dispatch( + { + kind: "command", + id: "set-ui-state-invalid-editor-pane-layout", + op: "workspace.uiState.set", + args: { + workspaceId, + uiState: { + leftPanelWidth: 320, + bottomPanelHeight: 210, + focusMode: false, + paneLayout: { + id: "root", + type: "leaf", + leafKind: "editor", + sessionId: "sess-invalid", + }, + }, + }, + }, + ctx + ); + expect(editorWithSessionId.ok).toBe(false); + }); + it("persists fileTreeExpandedDirs into workspace ui state", async () => { const dir = join(tmpdir(), `workspace-expanded-dirs-test-${Date.now()}`); await mkdir(dir); @@ -309,6 +547,51 @@ describe("Workspace Commands", () => { .fileTreeExpandedDirs ).toEqual(["packages", "packages/web"]); }); + + it("persists open editor paths and the active editor into workspace ui state", async () => { + const dir = join(tmpdir(), `workspace-open-editors-test-${Date.now()}`); + await mkdir(dir); + + const openResult = await dispatch( + { + kind: "command", + id: "open-workspace-open-editors", + op: "workspace.open", + args: { path: dir }, + }, + ctx + ); + + expect(openResult.ok).toBe(true); + const workspaceId = (openResult.data as { id: string }).id; + + const result = await dispatch( + { + kind: "command", + id: "set-ui-state-open-editors", + op: "workspace.uiState.set", + args: { + workspaceId, + uiState: { + leftPanelWidth: 320, + bottomPanelHeight: 210, + focusMode: false, + openEditorPaths: ["README.md", "src/app.tsx"], + activeEditorPath: "src/app.tsx", + }, + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect( + (result.data as { uiState: { openEditorPaths?: string[] } }).uiState.openEditorPaths + ).toEqual(["README.md", "src/app.tsx"]); + expect( + (result.data as { uiState: { activeEditorPath?: string | null } }).uiState.activeEditorPath + ).toBe("src/app.tsx"); + }); }); describe("workspace.lastViewedTarget", () => { diff --git a/packages/server/src/commands/diagnostics.ts b/packages/server/src/commands/diagnostics.ts index a5e1d837..75c8831b 100644 --- a/packages/server/src/commands/diagnostics.ts +++ b/packages/server/src/commands/diagnostics.ts @@ -6,8 +6,8 @@ import type { ProviderRuntimeStatusEntry, } from "@coder-studio/core"; import { z } from "zod"; -import { runCommandAsString } from "../provider-runtime/command-runner.js"; import { buildProviderRuntimeStatus } from "../provider-runtime/runtime-status.js"; +import { buildSystemDependencyRuntimeStatus } from "../system-deps/runtime-status.js"; import { validatePath } from "../workspace/validator.js"; import { type CommandContext, registerCommand } from "../ws/dispatch.js"; @@ -281,45 +281,38 @@ function buildMobileHostCheck(ctx: CommandContext): { }; } -async function readCommandVersion( - command: string, - args: string[], - ctx: CommandContext -): Promise { - const runner = ctx.providerRuntimeDeps?.runCommand ?? runCommandAsString; - - try { - const { stdout } = await runner(command, args, { windowsHide: true }); - const version = stdout.trim(); - return version.length > 0 ? version : null; - } catch { - return null; - } -} - async function buildBaseRuntimeChecks( ctx: CommandContext ): Promise<{ canContinue: boolean; checks: DiagnosticsCheck[] }> { - const gitVersion = await readCommandVersion("git", ["--version"], ctx); - const nodeVersion = await readCommandVersion("node", ["--version"], ctx); - const checks: DiagnosticsCheck[] = [ - { - id: "runtime:git", - code: gitVersion ? "git_ready" : "git_missing", - status: gitVersion ? "ready" : "needs_attention", - version: gitVersion ?? undefined, - }, - { - id: "runtime:nodejs", - code: nodeVersion ? "nodejs_ready" : "nodejs_missing", - status: nodeVersion ? "ready" : "needs_attention", - version: nodeVersion ?? undefined, - }, - ]; - + const runtime = await buildSystemDependencyRuntimeStatus(ctx.providerRuntimeDeps); + const git = runtime.dependencies.git; + const node = runtime.dependencies.node; return { - canContinue: Boolean(gitVersion && nodeVersion), - checks, + canContinue: git.available && node.available, + checks: [ + { + id: "runtime:git", + code: git.available ? "git_ready" : "git_missing", + status: git.available ? "ready" : "needs_attention", + dependencyId: "git", + autoInstallSupported: git.autoInstallSupported, + installReadiness: git.installReadiness, + manualGuideKeys: git.manualGuideKeys, + docUrl: git.docUrl, + version: git.version, + }, + { + id: "runtime:nodejs", + code: node.available ? "nodejs_ready" : "nodejs_missing", + status: node.available ? "ready" : "needs_attention", + dependencyId: "node", + autoInstallSupported: node.autoInstallSupported, + installReadiness: node.installReadiness, + manualGuideKeys: node.manualGuideKeys, + docUrl: node.docUrl, + version: node.version, + }, + ], }; } @@ -328,16 +321,20 @@ async function buildSessionStartDiagnostics( ctx: CommandContext ): Promise { const workspaceSelection = await buildWorkspaceSelectionChecks(args, ctx); + const baseRuntime = await buildBaseRuntimeChecks(ctx); const providerChecks = await buildAllProviderChecks(ctx, args.providerId); const mobileHost = buildMobileHostCheck(ctx); const checks: DiagnosticsCheck[] = [ ...workspaceSelection.checks, + ...baseRuntime.checks, ...providerChecks.checks, buildServerAuthCheck(ctx), mobileHost.check, ]; const canContinue = - workspaceSelection.canContinue && providerChecks.canContinueForPreferredProvider; + workspaceSelection.canContinue && + baseRuntime.canContinue && + providerChecks.canContinueForPreferredProvider; return { context: "session_start", @@ -434,6 +431,7 @@ async function buildDiagnostics( switch (args.context as DiagnosticsContext) { case "workspace_open": { const workspaceSelection = await buildWorkspaceSelectionChecks(args, ctx); + const baseRuntime = await buildBaseRuntimeChecks(ctx); const providerChecks = await buildAllProviderChecks(ctx, args.providerId); const mobileHost = buildMobileHostCheck(ctx); return { @@ -441,6 +439,7 @@ async function buildDiagnostics( canContinue: workspaceSelection.canContinue, checks: [ ...workspaceSelection.checks, + ...baseRuntime.checks, ...providerChecks.checks, buildServerAuthCheck(ctx), mobileHost.check, diff --git a/packages/server/src/commands/file.ts b/packages/server/src/commands/file.ts index 2cb5a565..e95ccee9 100644 --- a/packages/server/src/commands/file.ts +++ b/packages/server/src/commands/file.ts @@ -8,10 +8,15 @@ import { createDirectory, createFile, deleteEntry, - readFile, + readFile as readWorkspaceFile, renameEntry, - writeFile, + writeFile as writeWorkspaceFile, } from "../fs/file-io.js"; +import { + applySearchSession, + createSearchSession, + previewSearchSessionFile, +} from "../fs/search-replace.js"; import { readTree, searchFiles } from "../fs/tree.js"; import { registerCommand } from "../ws/dispatch.js"; @@ -86,7 +91,108 @@ registerCommand( throw { code: "workspace_not_found", message: `Workspace not found: ${args.workspaceId}` }; } - return readFile(args.workspaceId, workspace.path, args.path); + return readWorkspaceFile(args.workspaceId, workspace.path, args.path); + } +); + +// file.searchSession.start +registerCommand( + "file.searchSession.start", + z.object({ + workspaceId: z.string(), + query: z.string(), + replace: z.string(), + isRegex: z.boolean(), + matchCase: z.boolean(), + matchWholeWord: z.boolean(), + preserveCase: z.boolean(), + includeGlobs: z.array(z.string()), + excludeGlobs: z.array(z.string()), + useIgnoreFiles: z.boolean(), + useExcludeSettings: z.boolean(), + onlyOpenEditors: z.boolean(), + openEditorPaths: z.array(z.string()), + maxFiles: z.number().int().positive().max(100), + maxMatchesPerFile: z.number().int().positive().max(100), + }), + async (args, ctx) => { + const workspace = ctx.workspaceMgr.get(args.workspaceId); + if (!workspace) { + throw { code: "workspace_not_found", message: `Workspace not found: ${args.workspaceId}` }; + } + + const result = await createSearchSession(workspace.path, { + query: args.query, + replace: args.replace, + isRegex: args.isRegex, + matchCase: args.matchCase, + matchWholeWord: args.matchWholeWord, + preserveCase: args.preserveCase, + includeGlobs: args.includeGlobs, + excludeGlobs: args.excludeGlobs, + useIgnoreFiles: args.useIgnoreFiles, + useExcludeSettings: args.useExcludeSettings, + onlyOpenEditors: args.onlyOpenEditors, + openEditorPaths: args.openEditorPaths, + maxFiles: args.maxFiles, + maxMatchesPerFile: args.maxMatchesPerFile, + }); + + return result.result; + } +); + +// file.searchSession.previewFile +registerCommand( + "file.searchSession.previewFile", + z.object({ + workspaceId: z.string(), + sessionId: z.string(), + path: z.string(), + }), + async (args, ctx) => { + const workspace = ctx.workspaceMgr.get(args.workspaceId); + if (!workspace) { + throw { code: "workspace_not_found", message: `Workspace not found: ${args.workspaceId}` }; + } + + const result = await previewSearchSessionFile(workspace.path, args.sessionId, args.path); + if (!result) { + throw { code: "stale_session", message: "Search session is stale or missing" }; + } + + return result; + } +); + +// file.searchSession.apply +registerCommand( + "file.searchSession.apply", + z.object({ + workspaceId: z.string(), + sessionId: z.string(), + scope: z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("all") }), + z.object({ kind: z.literal("file"), path: z.string() }), + z.object({ kind: z.literal("match"), path: z.string(), matchId: z.string() }), + ]), + }), + async (args, ctx) => { + const workspace = ctx.workspaceMgr.get(args.workspaceId); + if (!workspace) { + throw { code: "workspace_not_found", message: `Workspace not found: ${args.workspaceId}` }; + } + + const result = await applySearchSession(workspace.path, args.sessionId, args.scope); + if (result.status === "ok" || result.status === "partial") { + ctx.eventBus.emit({ + type: "fs.dirty", + workspaceId: args.workspaceId, + reason: "file_content", + }); + } + + return result; } ); @@ -198,7 +304,7 @@ registerCommand( throw { code: "workspace_not_found", message: `Workspace not found: ${args.workspaceId}` }; } - const result = await writeFile(workspace.path, args.path, args.content, args.baseHash); + const result = await writeWorkspaceFile(workspace.path, args.path, args.content, args.baseHash); ctx.eventBus.emit({ type: "fs.dirty", workspaceId: args.workspaceId, diff --git a/packages/server/src/commands/git.ts b/packages/server/src/commands/git.ts index 88337700..9f019832 100644 --- a/packages/server/src/commands/git.ts +++ b/packages/server/src/commands/git.ts @@ -20,6 +20,7 @@ import { unstageFiles, } from "../git/cli.js"; import { getFileDiff } from "../git/diff.js"; +import { getGitCommitDetail, getGitCommitFileDiff } from "../git/history.js"; import type { CommandContext } from "../ws/dispatch.js"; import { registerCommand } from "../ws/dispatch.js"; import { emitGitStateChanged } from "./git-events.js"; @@ -137,6 +138,42 @@ registerCommand( } ); +// git.commitDetail +registerCommand( + "git.commitDetail", + z.object({ + workspaceId: z.string(), + sha: gitCommitRevisionSchema, + }), + async (args, ctx) => { + const workspace = ctx.workspaceMgr.get(args.workspaceId); + if (!workspace) { + throw { code: "workspace_not_found", message: `Workspace not found: ${args.workspaceId}` }; + } + + return getGitCommitDetail(workspace.path, args.sha); + } +); + +// git.commitFileDiff +registerCommand( + "git.commitFileDiff", + z.object({ + workspaceId: z.string(), + sha: gitCommitRevisionSchema, + path: z.string(), + oldPath: z.string().optional(), + }), + async (args, ctx) => { + const workspace = ctx.workspaceMgr.get(args.workspaceId); + if (!workspace) { + throw { code: "workspace_not_found", message: `Workspace not found: ${args.workspaceId}` }; + } + + return getGitCommitFileDiff(workspace.path, args); + } +); + // git.unstage registerCommand( "git.unstage", diff --git a/packages/server/src/commands/index.ts b/packages/server/src/commands/index.ts index 6f5c34c5..73c3aecc 100644 --- a/packages/server/src/commands/index.ts +++ b/packages/server/src/commands/index.ts @@ -21,8 +21,10 @@ import "./settings.js"; import "./diagnostics.js"; import "./provider.js"; import "./custom-provider.js"; +import "./system-deps.js"; import "./supervisor.js"; import "./worktree.js"; import "./fencing.js"; import "./lsp.js"; import "./updates.js"; +import "./monitoring.js"; diff --git a/packages/server/src/commands/lsp.ts b/packages/server/src/commands/lsp.ts index 0dda7f85..c6184c15 100644 --- a/packages/server/src/commands/lsp.ts +++ b/packages/server/src/commands/lsp.ts @@ -54,7 +54,7 @@ registerCommand( "lsp.install.start", z.object({ workspaceId: z.string(), - serverKind: z.enum(["typescript", "python", "go", "rust"]), + serverKind: z.enum(["typescript", "python", "go", "rust", "vue"]), }), async (args, ctx) => { if (!ctx.lspToolInstallMgr) { @@ -197,3 +197,12 @@ registerCommand( }), async (args, ctx) => ctx.lspMgr.documentSymbols(args) ); + +registerCommand( + "lsp.semanticTokens", + z.object({ + workspaceId: z.string(), + path: z.string(), + }), + async (args, ctx) => ctx.lspMgr.semanticTokens(args) +); diff --git a/packages/server/src/commands/monitoring.ts b/packages/server/src/commands/monitoring.ts new file mode 100644 index 00000000..786fb3f1 --- /dev/null +++ b/packages/server/src/commands/monitoring.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; +import { registerCommand } from "../ws/dispatch.js"; + +function resolveMonitoringService( + ctx: Parameters[2] extends ( + args: never, + ctx: infer T, + clientId?: string + ) => Promise + ? T + : never +) { + if (!ctx.monitoringService) { + throw Object.assign(new Error("Monitoring service unavailable"), { + code: "monitoring_unavailable", + }); + } + + return ctx.monitoringService; +} + +registerCommand("monitoring.get", z.object({}).default({}), async (_args, ctx) => { + return resolveMonitoringService(ctx).getResponse(); +}); + +registerCommand("monitoring.recheck", z.object({}).default({}), async (_args, ctx) => { + return await resolveMonitoringService(ctx).recheck(); +}); diff --git a/packages/server/src/commands/settings.test.ts b/packages/server/src/commands/settings.test.ts index 1ce679d0..e04b91cf 100644 --- a/packages/server/src/commands/settings.test.ts +++ b/packages/server/src/commands/settings.test.ts @@ -237,6 +237,39 @@ describe("settings commands", () => { expect(updateService.reloadScheduleFromSettings).toHaveBeenCalledTimes(1); }); + it("settings.update persists monitoring settings and reloads the monitoring service", async () => { + const monitoringService = { + reloadFromSettings: vi.fn(), + }; + ctx.monitoringService = monitoringService as never; + + const result = await dispatch( + { + kind: "command", + id: "settings-update-monitoring", + op: "settings.update", + args: { + settings: { + monitoring: { + enabled: true, + hostMetricsEnabled: true, + runtimeSummaryEnabled: true, + workspaceAttributionEnabled: true, + subprocessDrilldownEnabled: false, + sampleIntervalMs: 5000, + }, + }, + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(settingsRepo.get("monitoring.enabled")).toBe(true); + expect(settingsRepo.get("monitoring.sampleIntervalMs")).toBe(5000); + expect(monitoringService.reloadFromSettings).toHaveBeenCalledTimes(1); + }); + it("settings.update rejects unsupported update check intervals", async () => { const result = await dispatch( { diff --git a/packages/server/src/commands/settings.ts b/packages/server/src/commands/settings.ts index a5edb1ac..77255332 100644 --- a/packages/server/src/commands/settings.ts +++ b/packages/server/src/commands/settings.ts @@ -4,6 +4,7 @@ import { DEFAULT_SUPERVISOR_EVALUATION_TIMEOUT_SEC, + isMonitoringSampleIntervalMs, isUpdateCheckIntervalSec, MAX_SUPERVISOR_EVALUATION_TIMEOUT_SEC, MAX_SUPERVISOR_RETRY_DELAY_SEC, @@ -124,6 +125,16 @@ const SettingsSchema = z.object({ checkIntervalSec: z.number().int().refine(isUpdateCheckIntervalSec).optional(), }) .optional(), + monitoring: z + .object({ + enabled: z.boolean().optional(), + hostMetricsEnabled: z.boolean().optional(), + runtimeSummaryEnabled: z.boolean().optional(), + workspaceAttributionEnabled: z.boolean().optional(), + subprocessDrilldownEnabled: z.boolean().optional(), + sampleIntervalMs: z.number().int().refine(isMonitoringSampleIntervalMs).optional(), + }) + .optional(), providers: ProviderSettingsSchema.optional(), }); @@ -227,6 +238,17 @@ registerCommand( ctx.updateService?.reloadScheduleFromSettings(); } + if ( + flatSettings["monitoring.enabled"] !== undefined || + flatSettings["monitoring.hostMetricsEnabled"] !== undefined || + flatSettings["monitoring.runtimeSummaryEnabled"] !== undefined || + flatSettings["monitoring.workspaceAttributionEnabled"] !== undefined || + flatSettings["monitoring.subprocessDrilldownEnabled"] !== undefined || + flatSettings["monitoring.sampleIntervalMs"] !== undefined + ) { + ctx.monitoringService?.reloadFromSettings(); + } + return { updated: [ ...Object.keys(flatSettings), diff --git a/packages/server/src/commands/system-deps.ts b/packages/server/src/commands/system-deps.ts new file mode 100644 index 00000000..2ead7c9e --- /dev/null +++ b/packages/server/src/commands/system-deps.ts @@ -0,0 +1,103 @@ +import { SYSTEM_DEPENDENCY_IDS, type SystemDependencyInstallJobSnapshot } from "@coder-studio/core"; +import { z } from "zod"; +import { buildSystemDependencyRuntimeStatus } from "../system-deps/runtime-status.js"; +import type { CommandContext } from "../ws/dispatch.js"; +import { registerCommand } from "../ws/dispatch.js"; + +function resolveInstallOwnerId(ctx: CommandContext, clientId?: string): string | undefined { + if (!clientId) { + return undefined; + } + + const activeLease = ctx.activationMgr?.getLease?.(); + if (activeLease?.wsClientId === clientId) { + return activeLease.clientInstanceId; + } + + return clientId; +} + +registerCommand("systemDeps.runtimeStatus", z.object({}), async (_args, ctx) => { + return buildSystemDependencyRuntimeStatus(ctx.providerRuntimeDeps); +}); + +registerCommand( + "systemDeps.install.start", + z.object({ + dependencyId: z.enum(SYSTEM_DEPENDENCY_IDS), + }), + async (args, ctx, clientId) => { + if (!ctx.systemDependencyInstallMgr) { + throw { + code: "system_dependency_install_unavailable", + message: "System dependency install manager not configured", + }; + } + + const ownerId = resolveInstallOwnerId(ctx, clientId); + return ctx.systemDependencyInstallMgr.start(args.dependencyId, ownerId, clientId); + } +); + +registerCommand( + "systemDeps.install.get", + z.object({ + jobId: z.string(), + }), + async (args, ctx, clientId): Promise => { + if (!ctx.systemDependencyInstallMgr) { + throw { + code: "system_dependency_install_unavailable", + message: "System dependency install manager not configured", + }; + } + + const ownerId = resolveInstallOwnerId(ctx, clientId); + const job = ctx.systemDependencyInstallMgr.get(args.jobId, ownerId, clientId); + if (!job) { + throw { + code: "system_dependency_install_job_not_found", + message: `Install job not found: ${args.jobId}`, + }; + } + + return job; + } +); + +registerCommand( + "systemDeps.install.input", + z.object({ + jobId: z.string(), + text: z.string(), + }), + async (args, ctx, clientId) => { + if (!ctx.systemDependencyInstallMgr) { + throw { + code: "system_dependency_install_unavailable", + message: "System dependency install manager not configured", + }; + } + + const ownerId = resolveInstallOwnerId(ctx, clientId); + return ctx.systemDependencyInstallMgr.submitInput(args.jobId, ownerId, args.text, clientId); + } +); + +registerCommand( + "systemDeps.install.cancel", + z.object({ + jobId: z.string(), + }), + async (args, ctx, clientId) => { + if (!ctx.systemDependencyInstallMgr) { + throw { + code: "system_dependency_install_unavailable", + message: "System dependency install manager not configured", + }; + } + + const ownerId = resolveInstallOwnerId(ctx, clientId); + return ctx.systemDependencyInstallMgr.cancel(args.jobId, ownerId, clientId); + } +); diff --git a/packages/server/src/commands/workspace.ts b/packages/server/src/commands/workspace.ts index 6beffc45..27ee8ec7 100644 --- a/packages/server/src/commands/workspace.ts +++ b/packages/server/src/commands/workspace.ts @@ -4,8 +4,10 @@ import { readdir, realpath } from "node:fs/promises"; import { homedir } from "node:os"; -import { isAbsolute, join, resolve } from "node:path"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; +import type { WorkspacePaneNode } from "@coder-studio/core"; import { z } from "zod"; +import { createDirectory } from "../fs/file-io.js"; import { inspectWorkspaceIntelligence } from "../workspace/intelligence.js"; import { registerCommand } from "../ws/dispatch.js"; @@ -43,6 +45,51 @@ async function buildRootPaths(currentPath: string): Promise { return Array.from(roots); } +const workspacePaneLeafSchema = z.union([ + z + .object({ + id: z.string(), + type: z.literal("leaf"), + leafKind: z.undefined().optional(), + sessionId: z.string().optional(), + }) + .strict(), + z + .object({ + id: z.string(), + type: z.literal("leaf"), + leafKind: z.literal("draft"), + }) + .strict(), + z + .object({ + id: z.string(), + type: z.literal("leaf"), + leafKind: z.literal("session"), + sessionId: z.string(), + }) + .strict(), + z + .object({ + id: z.string(), + type: z.literal("leaf"), + leafKind: z.literal("editor"), + }) + .strict(), +]); + +const workspacePaneNodeSchema: z.ZodType = z.lazy(() => + z.union([ + workspacePaneLeafSchema, + z.object({ + id: z.string(), + type: z.literal("split"), + direction: z.enum(["horizontal", "vertical"]).optional(), + children: z.array(workspacePaneNodeSchema).optional(), + }), + ]) +); + // workspace.list registerCommand("workspace.list", z.object({}), async (_args, ctx) => { return ctx.workspaceMgr.list(); @@ -75,6 +122,31 @@ registerCommand( } ); +registerCommand( + "workspace.mkdir", + z.object({ + path: z.string(), + }), + async (args) => { + const requestedName = basename(args.path); + + if (!requestedName || requestedName === "." || requestedName === "..") { + throw { code: "invalid_path", message: "Folder name is required" }; + } + + const targetPath = resolveBrowsePath(args.path); + const parentPath = dirname(targetPath); + const dirName = basename(targetPath); + + if (!dirName || dirName === "." || dirName === "..") { + throw { code: "invalid_path", message: "Folder name is required" }; + } + + await createDirectory(parentPath, dirName); + return { ok: true }; + } +); + // workspace.open registerCommand( "workspace.open", @@ -127,27 +199,9 @@ registerCommand( focusMode: z.boolean(), activeSessionId: z.string().optional(), fileTreeExpandedDirs: z.array(z.string()).optional(), - paneLayout: z - .object({ - id: z.string(), - type: z.enum(["leaf", "split"]), - sessionId: z.string().optional(), - direction: z.enum(["horizontal", "vertical"]).optional(), - children: z - .lazy(() => - z.array( - z.object({ - id: z.string(), - type: z.enum(["leaf", "split"]), - sessionId: z.string().optional(), - direction: z.enum(["horizontal", "vertical"]).optional(), - children: z.any().optional(), - }) - ) - ) - .optional(), - }) - .optional(), + paneLayout: workspacePaneNodeSchema.optional(), + openEditorPaths: z.array(z.string()).optional(), + activeEditorPath: z.string().nullable().optional(), }), }), async (args, ctx) => { diff --git a/packages/server/src/fs/gitignore.ts b/packages/server/src/fs/gitignore.ts index c9e672ea..e7b5545b 100644 --- a/packages/server/src/fs/gitignore.ts +++ b/packages/server/src/fs/gitignore.ts @@ -47,6 +47,44 @@ function isIgnoredByGitignore(ig: ReturnType, path: string): bool return ig.ignores(path) || ig.ignores(`${path}/`); } +export interface GitignoreMatcher { + hasRootGitignore: boolean; + rootPath: string; + rules: ReturnType | null; +} + +export function createGitignoreMatcher(rootPath: string): GitignoreMatcher { + const gitignorePath = join(rootPath, ".gitignore"); + if (!existsSync(gitignorePath)) { + return { + hasRootGitignore: false, + rootPath, + rules: null, + }; + } + + return { + hasRootGitignore: true, + rootPath, + rules: ignore().add(readFileSync(gitignorePath, "utf-8")), + }; +} + +export function isPathGitignored(matcher: GitignoreMatcher, relativePath: string): boolean { + const normalizedPath = normalizePath(relativePath); + if ( + !matcher.rules || + !normalizedPath || + normalizedPath.startsWith("..") || + normalizedPath === ".git" || + normalizedPath.startsWith(".git/") + ) { + return false; + } + + return isIgnoredByGitignore(matcher.rules, normalizedPath); +} + /** * Creates a filter function that respects .gitignore rules for a given directory. * Returns false if the entry should be skipped (ignored), true if it should be included. @@ -59,23 +97,20 @@ export function createGitignoreFilter( rootPath: string, dirPath: string ): (name: string) => boolean { - const gitignorePath = join(rootPath, ".gitignore"); + const matcher = createGitignoreMatcher(rootPath); - if (!existsSync(gitignorePath)) { + if (!matcher.hasRootGitignore) { // No .gitignore: default to skipping dotfiles, node_modules, .git return (name: string) => !isDefaultTreeIgnored(name); } - const gitignoreContent = readFileSync(gitignorePath, "utf-8"); - const ig = ignore().add(gitignoreContent); - return (name: string) => { if (isAlwaysTreeIgnored(name)) { return false; } const relativePath = relativeToRoot(rootPath, join(dirPath, name)); - return !isIgnoredByGitignore(ig, relativePath); + return !isPathGitignored(matcher, relativePath); }; } diff --git a/packages/server/src/fs/search-replace.ts b/packages/server/src/fs/search-replace.ts new file mode 100644 index 00000000..71ccc257 --- /dev/null +++ b/packages/server/src/fs/search-replace.ts @@ -0,0 +1,663 @@ +import type { + SearchSessionApplyResult, + SearchSessionFilePreview, + SearchSessionFileResult, + SearchSessionMatchPreview, + SearchSessionStartResult, +} from "@coder-studio/core"; +import { createHash, randomUUID } from "crypto"; +import { existsSync, readFileSync } from "fs"; +import { readdir, readFile, stat } from "fs/promises"; +import ignore from "ignore"; +import { basename, join, relative } from "path"; +import { resolveSafe, writeFile } from "./file-io.js"; + +const MAX_FILE_BYTES = 1_000_000; +const SESSION_TTL_MS = 5 * 60 * 1000; +const STANDARD_IGNORE_SOURCE_PATHS = [ + ".gitignore", + ".ignore", + ".rgignore", + ".git/info/exclude", +] as const; + +export interface CreateSearchSessionOptions { + query: string; + replace: string; + isRegex: boolean; + matchCase: boolean; + matchWholeWord: boolean; + preserveCase: boolean; + includeGlobs: string[]; + excludeGlobs: string[]; + useIgnoreFiles: boolean; + useExcludeSettings: boolean; + onlyOpenEditors: boolean; + openEditorPaths: string[]; + maxFiles: number; + maxMatchesPerFile: number; +} + +interface SearchSessionDescriptor extends CreateSearchSessionOptions { + rootPath: string; +} + +interface SearchSessionRecord { + id: string; + descriptor: SearchSessionDescriptor; + createdAt: number; + files: Map; + result: SearchSessionStartResult; +} + +interface SearchSessionFileState { + path: string; + name: string; + baseHash: string; + originalContent: string; + modifiedContent: string; + matches: MatchCandidate[]; +} + +interface MatchCandidate { + id: string; + startOffset: number; + endOffset: number; + line: number; + column: number; + endColumn: number; + preview: string; + previewColumnStart: number; + previewColumnEnd: number; + replacementPreview: string; + replacementPreviewColumnStart: number; + replacementPreviewColumnEnd: number; + isReplacementPreviewTruncated: boolean; + replacementText: string; +} + +interface SearchIgnoreMatcher { + rules: ReturnType | null; +} + +const searchSessions = new Map(); + +export async function createSearchSession( + rootPath: string, + options: CreateSearchSessionOptions +): Promise<{ sessionId: string; result: SearchSessionStartResult }> { + const query = options.query.trim(); + if (!query) { + const sessionId = randomUUID(); + const result: SearchSessionStartResult = { + sessionId, + files: [], + totalMatchCount: 0, + totalFileCount: 0, + hasMoreFiles: false, + truncatedMatchFileCount: 0, + skippedBinaryFileCount: 0, + skippedLargeFileCount: 0, + }; + return { sessionId, result }; + } + + const descriptor: SearchSessionDescriptor = { + ...options, + rootPath, + }; + const matcher = buildMatcher(descriptor); + const files = new Map(); + const openEditorPathSet = new Set(options.openEditorPaths.map(normalizeRelativePath)); + const counters = { + totalMatchCount: 0, + totalFileCount: 0, + skippedBinaryFileCount: 0, + skippedLargeFileCount: 0, + }; + + // The current UI exposes one compact toggle for standard ignore/exclude sources, + // so the backend treats these two flags as a single gate until settings-backed + // exclude sources are added separately. + const useStandardIgnoreRules = descriptor.useIgnoreFiles || descriptor.useExcludeSettings; + + await walkWorkspace( + rootPath, + async (relativePath, absolutePath) => { + if (descriptor.onlyOpenEditors && !openEditorPathSet.has(relativePath)) { + return; + } + + if (!matchesPathFilters(relativePath, descriptor.includeGlobs, descriptor.excludeGlobs)) { + return; + } + + const fileStat = await stat(absolutePath); + if (fileStat.size > MAX_FILE_BYTES) { + counters.skippedLargeFileCount += 1; + return; + } + + const buffer = await readFile(absolutePath); + if (isBinaryFile(buffer)) { + counters.skippedBinaryFileCount += 1; + return; + } + + const content = buffer.toString("utf8"); + const baseHash = hashContent(content); + const matches = collectMatches(content, matcher, descriptor.replace, descriptor.preserveCase); + if (matches.length === 0) { + return; + } + + const modifiedContent = applyMatchesToContent(content, matches); + counters.totalMatchCount += matches.length; + counters.totalFileCount += 1; + files.set(relativePath, { + path: relativePath, + name: basename(relativePath), + baseHash, + originalContent: content, + modifiedContent, + matches, + }); + }, + useStandardIgnoreRules + ); + + const visibleFiles = Array.from(files.values()) + .sort((a, b) => a.path.localeCompare(b.path)) + .slice(0, descriptor.maxFiles) + .map((file) => ({ + path: file.path, + name: file.name, + matchCount: file.matches.length, + hasMoreMatches: file.matches.length > descriptor.maxMatchesPerFile, + baseHash: file.baseHash, + matches: file.matches + .slice(0, descriptor.maxMatchesPerFile) + .map(toMatchPreview), + })); + + const sessionId = randomUUID(); + const result: SearchSessionStartResult = { + sessionId, + files: visibleFiles, + totalMatchCount: counters.totalMatchCount, + totalFileCount: counters.totalFileCount, + hasMoreFiles: files.size > descriptor.maxFiles, + truncatedMatchFileCount: visibleFiles.filter((file) => file.hasMoreMatches).length, + skippedBinaryFileCount: counters.skippedBinaryFileCount, + skippedLargeFileCount: counters.skippedLargeFileCount, + }; + + searchSessions.set(sessionId, { + id: sessionId, + descriptor, + createdAt: Date.now(), + files, + result, + }); + + return { sessionId, result }; +} + +export async function previewSearchSessionFile( + rootPath: string, + sessionId: string, + path: string +): Promise { + const session = getSearchSession(sessionId); + if (!session || session.descriptor.rootPath !== rootPath) { + return null; + } + + const file = session.files.get(normalizeRelativePath(path)); + if (!file) { + return null; + } + + return { + kind: "search-replace-file-diff", + path: file.path, + title: file.path, + sessionId, + baseHash: file.baseHash, + originalContent: file.originalContent, + modifiedContent: file.modifiedContent, + }; +} + +export async function applySearchSession( + rootPath: string, + sessionId: string, + scope: + | { kind: "all" } + | { kind: "file"; path: string } + | { kind: "match"; path: string; matchId: string } +): Promise { + const session = getSearchSession(sessionId); + if (!session || session.descriptor.rootPath !== rootPath) { + return staleApplyResult(sessionId); + } + + const selectedFiles = + scope.kind === "all" + ? Array.from(session.files.values()) + : scope.kind === "file" + ? [session.files.get(normalizeRelativePath(scope.path))].filter(isDefined) + : [session.files.get(normalizeRelativePath(scope.path))].filter(isDefined); + + const results: SearchSessionApplyResult["results"] = []; + + for (const file of selectedFiles) { + const targetMatches = + scope.kind === "match" + ? file.matches.filter((match) => match.id === scope.matchId) + : file.matches; + + if (targetMatches.length === 0) { + results.push({ + path: file.path, + status: "not_found", + replacedMatchCount: 0, + }); + continue; + } + + const currentContent = await readFile(resolveSafe(rootPath, file.path), "utf8").catch( + () => null + ); + if (currentContent === null) { + results.push({ + path: file.path, + status: "not_found", + replacedMatchCount: 0, + }); + continue; + } + + if (hashContent(currentContent) !== file.baseHash) { + results.push({ + path: file.path, + status: "conflict", + replacedMatchCount: 0, + }); + continue; + } + + const nextContent = + scope.kind === "all" || scope.kind === "file" + ? file.modifiedContent + : applyMatchesToContent(file.originalContent, targetMatches); + + await writeFile(rootPath, file.path, nextContent, file.baseHash); + results.push({ + path: file.path, + status: "applied", + replacedMatchCount: targetMatches.length, + }); + } + + const appliedFileCount = results.filter((item) => item.status === "applied").length; + const conflictFileCount = results.filter((item) => item.status === "conflict").length; + const skippedFileCount = results.filter( + (item) => item.status === "skipped" || item.status === "not_found" + ).length; + + return { + sessionId, + status: conflictFileCount > 0 || skippedFileCount > 0 ? "partial" : "ok", + appliedFileCount, + conflictFileCount, + skippedFileCount, + results, + }; +} + +function getSearchSession(sessionId: string): SearchSessionRecord | null { + const session = searchSessions.get(sessionId); + if (!session) { + return null; + } + + if (Date.now() - session.createdAt > SESSION_TTL_MS) { + searchSessions.delete(sessionId); + return null; + } + + return session; +} + +function staleApplyResult(sessionId: string): SearchSessionApplyResult { + return { + sessionId, + status: "stale_session", + appliedFileCount: 0, + conflictFileCount: 0, + skippedFileCount: 0, + results: [], + }; +} + +function buildMatcher(descriptor: SearchSessionDescriptor) { + const flags = descriptor.matchCase ? "gd" : "gdi"; + try { + if (descriptor.isRegex) { + const source = descriptor.matchWholeWord ? `\\b(?:${descriptor.query})\\b` : descriptor.query; + return new RegExp(source, flags); + } + + const escaped = escapeRegExp(descriptor.query); + const source = descriptor.matchWholeWord ? `\\b${escaped}\\b` : escaped; + return new RegExp(source, flags); + } catch (error) { + const message = error instanceof Error ? error.message : "Invalid search pattern"; + throw { code: "invalid_regex", message }; + } +} + +function collectMatches( + content: string, + matcher: RegExp, + replace: string, + preserveCase: boolean +): MatchCandidate[] { + const matches: MatchCandidate[] = []; + const globalMatcher = new RegExp( + matcher.source, + matcher.flags.includes("g") ? matcher.flags : `${matcher.flags}g` + ); + for (const match of content.matchAll(globalMatcher)) { + const matchedText = match[0] ?? ""; + const index = match.index ?? 0; + const replacementText = applyPreserveCase( + expandReplacement(replace, match), + matchedText, + preserveCase + ); + const lineInfo = offsetToLineInfo(content, index, index + matchedText.length); + const previewInfo = buildPreview(content, index, index + matchedText.length, replacementText); + matches.push({ + id: `${lineInfo.line}:${lineInfo.column}:${lineInfo.endColumn}:${matches.length}`, + startOffset: index, + endOffset: index + matchedText.length, + line: lineInfo.line, + column: lineInfo.column, + endColumn: lineInfo.endColumn, + preview: previewInfo.preview, + previewColumnStart: previewInfo.previewColumnStart, + previewColumnEnd: previewInfo.previewColumnEnd, + replacementPreview: previewInfo.replacementPreview, + replacementPreviewColumnStart: previewInfo.replacementPreviewColumnStart, + replacementPreviewColumnEnd: previewInfo.replacementPreviewColumnEnd, + isReplacementPreviewTruncated: false, + replacementText, + }); + if (matchedText.length === 0) { + globalMatcher.lastIndex += 1; + } + } + return matches; +} + +function buildPreview( + content: string, + startOffset: number, + endOffset: number, + replacementText: string +) { + const lineStart = content.lastIndexOf("\n", startOffset - 1) + 1; + const lineEndIndex = content.indexOf("\n", endOffset); + const lineEnd = lineEndIndex === -1 ? content.length : lineEndIndex; + const preview = content.slice(lineStart, lineEnd); + const previewColumnStart = startOffset - lineStart + 1; + const previewColumnEnd = endOffset - lineStart + 1; + const replacementPreview = + preview.slice(0, previewColumnStart - 1) + + replacementText + + preview.slice(previewColumnEnd - 1); + return { + preview, + previewColumnStart, + previewColumnEnd, + replacementPreview, + replacementPreviewColumnStart: previewColumnStart, + replacementPreviewColumnEnd: previewColumnStart + replacementText.length, + }; +} + +function offsetToLineInfo(content: string, startOffset: number, endOffset: number) { + const prefix = content.slice(0, startOffset); + const lines = prefix.split("\n"); + const line = lines.length; + const column = (lines.at(-1)?.length ?? 0) + 1; + const endColumn = column + (endOffset - startOffset); + return { line, column, endColumn }; +} + +function applyMatchesToContent(content: string, matches: MatchCandidate[]) { + let cursor = 0; + let next = ""; + const ordered = [...matches].sort((a, b) => a.startOffset - b.startOffset); + for (const match of ordered) { + next += content.slice(cursor, match.startOffset); + next += match.replacementText; + cursor = match.endOffset; + } + next += content.slice(cursor); + return next; +} + +function expandReplacement(replace: string, match: RegExpMatchArray) { + return replace.replace(/\$(\$|&|`|'|\d{1,2})/g, (token, group) => { + if (group === "$") { + return "$"; + } + if (group === "&") { + return match[0] ?? ""; + } + if (group === "`") { + return ""; + } + if (group === "'") { + return ""; + } + const index = Number(group); + return Number.isNaN(index) ? token : (match[index] ?? ""); + }); +} + +function applyPreserveCase(replacement: string, matchedText: string, preserveCase: boolean) { + if (!preserveCase || !replacement) { + return replacement; + } + + if (matchedText === matchedText.toUpperCase()) { + return replacement.toUpperCase(); + } + + if (matchedText === matchedText.toLowerCase()) { + return replacement.toLowerCase(); + } + + if ( + matchedText[0] === matchedText[0]?.toUpperCase() && + matchedText.slice(1) === matchedText.slice(1).toLowerCase() + ) { + return replacement.charAt(0).toUpperCase() + replacement.slice(1).toLowerCase(); + } + + return replacement; +} + +function toMatchPreview(match: MatchCandidate): SearchSessionMatchPreview { + return { + id: match.id, + line: match.line, + column: match.column, + endColumn: match.endColumn, + preview: match.preview, + previewColumnStart: match.previewColumnStart, + previewColumnEnd: match.previewColumnEnd, + replacementPreview: match.replacementPreview, + replacementPreviewColumnStart: match.replacementPreviewColumnStart, + replacementPreviewColumnEnd: match.replacementPreviewColumnEnd, + isReplacementPreviewTruncated: match.isReplacementPreviewTruncated, + }; +} + +async function walkWorkspace( + rootPath: string, + onFile: (relativePath: string, absolutePath: string) => Promise, + useIgnoreFiles: boolean +) { + const matcher = createSearchIgnoreMatcher(rootPath); + + async function visit(dirPath: string): Promise { + const entries = await readdir(dirPath, { withFileTypes: true }); + entries.sort((a, b) => a.name.localeCompare(b.name)); + + for (const entry of entries) { + if (entry.name === ".git" || entry.name === "node_modules") { + continue; + } + + const absolutePath = join(dirPath, entry.name); + const relativePath = normalizeRelativePath(relative(rootPath, absolutePath)); + + if (useIgnoreFiles && isSearchPathIgnored(matcher, relativePath)) { + continue; + } + + if (entry.isDirectory()) { + await visit(absolutePath); + continue; + } + + if (!entry.isFile()) { + continue; + } + + await onFile(relativePath, absolutePath); + } + } + + await visit(rootPath); +} + +function createSearchIgnoreMatcher(rootPath: string): SearchIgnoreMatcher { + const rules = ignore(); + let hasRules = false; + + for (const relativePath of STANDARD_IGNORE_SOURCE_PATHS) { + const absolutePath = join(rootPath, relativePath); + if (!existsSync(absolutePath)) { + continue; + } + + const content = readFileSync(absolutePath, "utf8"); + if (!content.trim()) { + continue; + } + + rules.add(content); + hasRules = true; + } + + return { + rules: hasRules ? rules : null, + }; +} + +function isSearchPathIgnored(matcher: SearchIgnoreMatcher, relativePath: string): boolean { + const normalizedPath = normalizeRelativePath(relativePath); + if ( + !matcher.rules || + !normalizedPath || + normalizedPath.startsWith("..") || + normalizedPath === ".git" || + normalizedPath.startsWith(".git/") + ) { + return false; + } + + return matcher.rules.ignores(normalizedPath) || matcher.rules.ignores(`${normalizedPath}/`); +} + +function matchesPathFilters(path: string, includeGlobs: string[], excludeGlobs: string[]) { + const included = + includeGlobs.length === 0 || includeGlobs.some((pattern) => globMatches(path, pattern)); + if (!included) { + return false; + } + return !excludeGlobs.some((pattern) => globMatches(path, pattern)); +} + +function globMatches(path: string, pattern: string) { + const normalizedPattern = pattern.trim(); + if (!normalizedPattern) { + return false; + } + + const regexp = globToRegExp(normalizedPattern); + return regexp.test(path); +} + +function globToRegExp(pattern: string) { + let source = "^"; + + for (let index = 0; index < pattern.length; index += 1) { + const char = pattern.charAt(index); + const next = pattern.charAt(index + 1); + const nextNext = pattern.charAt(index + 2); + + if (char === "*" && next === "*" && nextNext === "/") { + source += "(?:.*/)?"; + index += 2; + continue; + } + + if (char === "*" && next === "*") { + source += ".*"; + index += 1; + continue; + } + + if (char === "*") { + source += "[^/]*"; + continue; + } + + if (char === "?") { + source += "."; + continue; + } + + source += escapeRegExp(char); + } + + source += "$"; + return new RegExp(source); +} + +function normalizeRelativePath(path: string) { + return path.replace(/\\/g, "/"); +} + +function isBinaryFile(buffer: Buffer) { + return buffer.subarray(0, 8000).includes(0); +} + +function hashContent(content: string) { + return createHash("sha256").update(content).digest("hex"); +} + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function isDefined(value: T | null | undefined): value is T { + return value !== null && value !== undefined; +} diff --git a/packages/server/src/fs/tree.ts b/packages/server/src/fs/tree.ts index bf07450d..8aaf4909 100644 --- a/packages/server/src/fs/tree.ts +++ b/packages/server/src/fs/tree.ts @@ -6,7 +6,12 @@ import type { FileNode } from "@coder-studio/core"; import { readdir, stat } from "fs/promises"; import { join, relative } from "path"; -import { createGitignoreFilter, createTreeVisibilityFilter } from "./gitignore.js"; +import { + createGitignoreFilter, + createGitignoreMatcher, + createTreeVisibilityFilter, + isPathGitignored, +} from "./gitignore.js"; export interface ReadTreeResult { path: string; @@ -25,6 +30,7 @@ export interface ReadTreeResult { export async function readTree(rootPath: string, subdir?: string): Promise { const targetPath = subdir ? join(rootPath, subdir) : rootPath; const filter = createTreeVisibilityFilter(); + const gitignoreMatcher = createGitignoreMatcher(rootPath); const entries = await readdir(targetPath, { withFileTypes: true }); const nodes: FileNode[] = []; @@ -36,12 +42,14 @@ export async function readTree(rootPath: string, subdir?: string): Promise { @@ -71,24 +84,137 @@ async function pathExists(cwd: string, filePath: string): Promise { } } -async function readTextAtRevision( +function toGitSpec(revision: GitRevisionSource, filePath: string): string { + return revision === "INDEX" ? `:${filePath}` : `${revision}:${filePath}`; +} + +export async function pathExistsAtGitRevision( cwd: string, - revision: "HEAD" | "INDEX" | "WORKTREE", + revision: GitRevisionSource, filePath: string -) { +): Promise { + if (revision === "WORKTREE") { + return pathExists(cwd, filePath); + } + + try { + await runGit(cwd, ["cat-file", "-e", toGitSpec(revision, filePath)]); + return true; + } catch { + return false; + } +} + +export async function readTextAtGitRevision( + cwd: string, + revision: GitRevisionSource, + filePath: string +): Promise { if (revision === "WORKTREE") { return readFile(resolveSafe(cwd, filePath), "utf-8"); } try { - const gitSpec = revision === "INDEX" ? `:${filePath}` : `${revision}:${filePath}`; - const result = await runGit(cwd, ["show", gitSpec]); + const result = await runGit(cwd, ["show", toGitSpec(revision, filePath)]); return result.stdout; } catch { return ""; } } +function deriveHistoricalStatus( + originalExists: boolean, + modifiedExists: boolean +): "modified" | "added" | "deleted" { + if (!originalExists && modifiedExists) { + return "added"; + } + + if (originalExists && !modifiedExists) { + return "deleted"; + } + + return "modified"; +} + +export async function buildHistoricalTextDiffPayload( + input: HistoricalDiffInput +): Promise { + const originalExists = + Boolean(input.originalPath) && + Boolean(input.originalRevision) && + (await pathExistsAtGitRevision( + input.cwd, + input.originalRevision as GitRevisionSource, + input.originalPath as string + )); + const modifiedExists = + Boolean(input.modifiedPath) && + Boolean(input.modifiedRevision) && + (await pathExistsAtGitRevision( + input.cwd, + input.modifiedRevision as GitRevisionSource, + input.modifiedPath as string + )); + const status = deriveHistoricalStatus(originalExists, modifiedExists); + + return { + diff: input.diff, + renderAs: "text", + status, + ...(input.originalPath ? { originalPath: input.originalPath } : {}), + ...(input.modifiedPath ? { modifiedPath: input.modifiedPath } : {}), + originalContent: + originalExists && input.originalPath && input.originalRevision + ? await readTextAtGitRevision(input.cwd, input.originalRevision, input.originalPath) + : "", + modifiedContent: + modifiedExists && input.modifiedPath && input.modifiedRevision + ? await readTextAtGitRevision(input.cwd, input.modifiedRevision, input.modifiedPath) + : "", + ...(input.originalRevision ? { originalRevision: input.originalRevision } : {}), + ...(input.modifiedRevision ? { modifiedRevision: input.modifiedRevision } : {}), + }; +} + +export async function buildHistoricalImageDiffPayload( + input: HistoricalDiffInput +): Promise { + const imagePath = input.modifiedPath ?? input.originalPath; + const imageType = imagePath ? getImageTypeInfo(imagePath) : null; + if (!imageType) { + throw { code: "not_an_image", message: "File is not an image" }; + } + + const originalExists = + Boolean(input.originalPath) && + Boolean(input.originalRevision) && + (await pathExistsAtGitRevision( + input.cwd, + input.originalRevision as GitRevisionSource, + input.originalPath as string + )); + const modifiedExists = + Boolean(input.modifiedPath) && + Boolean(input.modifiedRevision) && + (await pathExistsAtGitRevision( + input.cwd, + input.modifiedRevision as GitRevisionSource, + input.modifiedPath as string + )); + + return { + diff: input.diff, + renderAs: "image", + status: deriveHistoricalStatus(originalExists, modifiedExists), + mime: imageType.mime, + ...(originalExists && input.originalPath ? { originalPath: input.originalPath } : {}), + ...(modifiedExists && input.modifiedPath ? { modifiedPath: input.modifiedPath } : {}), + ...(input.originalRevision ? { originalRevision: input.originalRevision } : {}), + ...(input.modifiedRevision ? { modifiedRevision: input.modifiedRevision } : {}), + }; +} + async function deriveFileDiffStatus( cwd: string, filePath: string, @@ -119,34 +245,41 @@ async function buildTextDiffResult( staged: boolean, diff: string ): Promise { + const payload = await buildHistoricalTextDiffPayload({ + cwd, + diff, + originalPath: filePath, + modifiedPath: filePath, + originalRevision: staged ? "HEAD" : "INDEX", + modifiedRevision: staged ? "INDEX" : "WORKTREE", + }); const status = await deriveFileDiffStatus(cwd, filePath, staged); + return { + diff: payload.diff, + renderAs: payload.renderAs, + status, + originalContent: payload.originalContent, + modifiedContent: payload.modifiedContent, + }; +} - if (status === "added") { - return { - diff, - renderAs: "text", - status, - originalContent: "", - modifiedContent: await readTextAtRevision(cwd, staged ? "INDEX" : "WORKTREE", filePath), - }; - } - - if (status === "deleted") { - return { - diff, - renderAs: "text", - status, - originalContent: await readTextAtRevision(cwd, staged ? "HEAD" : "INDEX", filePath), - modifiedContent: "", - }; - } - +function buildImageDiffResult( + filePath: string, + imageMime: string, + status: "modified" | "added" | "deleted", + originalRevision: "HEAD" | "INDEX", + modifiedRevision: "INDEX" | "WORKTREE", + diff: string +): FileDiffResult { return { diff, - renderAs: "text", + renderAs: "image", status, - originalContent: await readTextAtRevision(cwd, staged ? "HEAD" : "INDEX", filePath), - modifiedContent: await readTextAtRevision(cwd, staged ? "INDEX" : "WORKTREE", filePath), + originalRevision, + modifiedRevision, + mime: imageMime, + originalPath: status === "added" ? undefined : filePath, + modifiedPath: status === "deleted" ? undefined : filePath, }; } @@ -168,13 +301,7 @@ export async function getFileDiff( if (!staged && !(await isTrackedPath(cwd, path))) { const diff = await getUntrackedFileDiff(cwd, path); if (imageType) { - return { - diff, - renderAs: "image", - status: "added", - originalRevision: "HEAD", - modifiedRevision: "WORKTREE", - }; + return buildImageDiffResult(path, imageType.mime, "added", "HEAD", "WORKTREE", diff); } return buildTextDiffResult(cwd, path, staged, diff); @@ -183,13 +310,14 @@ export async function getFileDiff( const args = staged ? ["diff", "--staged", "--", path] : ["diff", "--", path]; const result = await runGit(cwd, args); if (imageType && /Binary files .* differ/.test(result.stdout)) { - return { - diff: result.stdout, - renderAs: "image", - status: await deriveFileDiffStatus(cwd, path, staged), - originalRevision: staged ? "HEAD" : "INDEX", - modifiedRevision: staged ? "INDEX" : "WORKTREE", - }; + return buildImageDiffResult( + path, + imageType.mime, + await deriveFileDiffStatus(cwd, path, staged), + staged ? "HEAD" : "INDEX", + staged ? "INDEX" : "WORKTREE", + result.stdout + ); } return buildTextDiffResult(cwd, path, staged, result.stdout); diff --git a/packages/server/src/git/history.ts b/packages/server/src/git/history.ts new file mode 100644 index 00000000..ef35ad4d --- /dev/null +++ b/packages/server/src/git/history.ts @@ -0,0 +1,212 @@ +import type { + GitCommitDetail, + GitCommitFileEntry, + GitCommitSummary, + GitFileDiffPayload, +} from "@coder-studio/core"; +import { getImageTypeInfo } from "../fs/image.js"; +import { runGit } from "./cli.js"; +import { buildHistoricalImageDiffPayload, buildHistoricalTextDiffPayload } from "./diff.js"; + +const EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; + +interface CommitMetadata extends GitCommitSummary { + parentSha?: string; + parentCount: number; +} + +function toRenderMode(path: string): "text" | "image" { + return getImageTypeInfo(path) ? "image" : "text"; +} + +function mapNameStatus(statusToken: string): Exclude { + const code = statusToken[0]; + switch (code) { + case "A": + return "added"; + case "D": + return "deleted"; + case "R": + case "C": + return "renamed"; + default: + return "modified"; + } +} + +async function getCommitMetadata(cwd: string, sha: string): Promise { + const { stdout } = await runGit(cwd, [ + "show", + "-s", + "--format=%H%x1f%h%x1f%s%x1f%an%x1f%at%x1f%P", + "--no-color", + "--end-of-options", + sha, + ]); + const [fullSha = "", shortSha = "", subject = "", authorName = "", authoredAt = "0", parents] = + stdout.trimEnd().split("\x1f"); + return { + sha: fullSha, + shortSha, + subject, + authorName, + authoredAt: Number.parseInt(authoredAt, 10) * 1000, + parentSha: parents ? parents.split(" ")[0] : undefined, + parentCount: parents ? parents.split(" ").filter(Boolean).length : 0, + }; +} + +async function getCommitChangedFiles(cwd: string, sha: string): Promise { + const { stdout } = await runGit(cwd, [ + "diff-tree", + "--no-commit-id", + "--root", + "--name-status", + "-M", + "-r", + "-z", + sha, + ]); + const records = stdout.split("\0").filter((record) => record.length > 0); + const files: GitCommitFileEntry[] = []; + + for (let index = 0; index < records.length; index += 1) { + const statusToken = records[index]; + if (!statusToken) { + continue; + } + + const status = mapNameStatus(statusToken); + if (status === "renamed") { + const oldPath = records[index + 1]; + const path = records[index + 2]; + if (!oldPath || !path) { + break; + } + + files.push({ + path, + oldPath, + status, + renderAs: toRenderMode(path), + }); + index += 2; + continue; + } + + const path = records[index + 1]; + if (!path) { + break; + } + + files.push({ + path, + status, + renderAs: toRenderMode(path), + }); + index += 1; + } + + return files; +} + +function uniquePathspecs(...paths: Array): string[] { + return [...new Set(paths.filter((value): value is string => Boolean(value)))]; +} + +async function getCommitDiffForPaths( + cwd: string, + sha: string, + parentSha: string | undefined, + pathspecs: string[] +): Promise { + const baseRevision = parentSha ?? EMPTY_TREE_SHA; + const args = [ + "diff", + "--find-renames", + "--no-color", + "--no-ext-diff", + baseRevision, + sha, + "--", + ...pathspecs, + ]; + const { stdout } = await runGit(cwd, args); + return stdout; +} + +function resolveCommitFileEntry( + files: GitCommitFileEntry[], + args: { path: string; oldPath?: string } +): GitCommitFileEntry | undefined { + return ( + files.find((file) => file.path === args.path && file.oldPath === args.oldPath) ?? + files.find((file) => file.path === args.path) ?? + (args.oldPath ? files.find((file) => file.oldPath === args.oldPath) : undefined) + ); +} + +function assertStructuredHistorySupported(commit: CommitMetadata): void { + if (commit.parentCount > 1) { + throw { + code: "git_merge_commit_unsupported", + message: `Structured history is not supported for merge commit ${commit.sha}`, + }; + } +} + +export async function getGitCommitDetail(cwd: string, sha: string): Promise { + const [commit, files] = await Promise.all([ + getCommitMetadata(cwd, sha), + getCommitChangedFiles(cwd, sha), + ]); + assertStructuredHistorySupported(commit); + return { + commit, + files, + }; +} + +export async function getGitCommitFileDiff( + cwd: string, + args: { sha: string; path: string; oldPath?: string } +): Promise { + const detail = await getGitCommitDetail(cwd, args.sha); + const entry = resolveCommitFileEntry(detail.files, args); + if (!entry) { + throw { + code: "git_commit_file_not_found", + message: `File ${args.oldPath ?? args.path} is not part of commit ${args.sha}`, + }; + } + const originalPath = args.oldPath ?? entry?.oldPath ?? args.path; + const modifiedPath = args.path; + const diff = await getCommitDiffForPaths( + cwd, + args.sha, + detail.commit.parentSha, + uniquePathspecs(originalPath, modifiedPath) + ); + const originalRevision = detail.commit.parentSha ?? EMPTY_TREE_SHA; + const modifiedRevision = args.sha; + + if ((entry?.renderAs ?? toRenderMode(modifiedPath)) === "image") { + return buildHistoricalImageDiffPayload({ + cwd, + diff, + originalPath, + modifiedPath, + originalRevision, + modifiedRevision, + }); + } + + return buildHistoricalTextDiffPayload({ + cwd, + diff, + originalPath, + modifiedPath, + originalRevision, + modifiedRevision, + }); +} diff --git a/packages/server/src/git/image-revision.ts b/packages/server/src/git/image-revision.ts index e247959a..d0656f5a 100644 --- a/packages/server/src/git/image-revision.ts +++ b/packages/server/src/git/image-revision.ts @@ -4,6 +4,7 @@ import { getImageTypeInfo } from "../fs/image.js"; import { GitError } from "./cli.js"; const execFileAsync = promisify(execFile); +const GIT_COMMIT_REVISION_RE = /^[0-9a-fA-F]{7,64}$/; export interface GitImageRevisionAsset { exists: boolean; @@ -11,10 +12,12 @@ export interface GitImageRevisionAsset { bytes?: Buffer; } -export type GitImageRevisionSelector = "HEAD" | "INDEX"; +export type GitImageRevisionSelector = "HEAD" | "INDEX" | string; export function parseGitImageRevisionSelector(revision: string): GitImageRevisionSelector | null { - return revision === "HEAD" || revision === "INDEX" ? revision : null; + return revision === "HEAD" || revision === "INDEX" || GIT_COMMIT_REVISION_RE.test(revision) + ? revision + : null; } export async function readImageAtRevision( diff --git a/packages/server/src/lsp-tools/definitions.test.ts b/packages/server/src/lsp-tools/definitions.test.ts new file mode 100644 index 00000000..9947c8f9 --- /dev/null +++ b/packages/server/src/lsp-tools/definitions.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from "vitest"; +import { resolveManagedPythonCommand } from "./definitions.js"; + +describe("resolveManagedPythonCommand", () => { + it("returns the first available candidate on POSIX hosts without probing", async () => { + const commandExists = vi.fn(async (cmd: string) => cmd === "python3"); + const runCommand = vi.fn(); + + await expect( + resolveManagedPythonCommand(commandExists, "linux", runCommand as never) + ).resolves.toBe("python3"); + // POSIX hosts do not have Microsoft Store stubs; the helper must NOT + // execute the candidate just to check the version. + expect(runCommand).not.toHaveBeenCalled(); + }); + + it("returns null when no candidate is on PATH", async () => { + await expect( + resolveManagedPythonCommand( + vi.fn(async () => false), + "linux" + ) + ).resolves.toBeNull(); + }); + + it("on Windows, rejects a candidate whose `--version` prints nothing (Store stub)", async () => { + // Windows ships zero-byte App Execution Aliases for `python` / + // `python3`. `where.exe` reports them as present, but invoking them + // silently exits with empty stdout/stderr because Python is not + // actually installed. + const commandExists = vi.fn(async () => true); + const runCommand = vi.fn(async () => ({ stdout: "", stderr: "" })); + + await expect( + resolveManagedPythonCommand(commandExists, "win32", runCommand) + ).resolves.toBeNull(); + expect(runCommand).toHaveBeenCalledTimes(2); + expect(runCommand).toHaveBeenCalledWith( + "python3", + ["--version"], + expect.objectContaining({ windowsHide: true }) + ); + expect(runCommand).toHaveBeenCalledWith( + "python", + ["--version"], + expect.objectContaining({ windowsHide: true }) + ); + }); + + it("on Windows, accepts a candidate whose --version prints output on stdout", async () => { + const commandExists = vi.fn(async () => true); + const runCommand = vi.fn(async () => ({ stdout: "Python 3.12.0\n", stderr: "" })); + + await expect(resolveManagedPythonCommand(commandExists, "win32", runCommand)).resolves.toBe( + "python3" + ); + }); + + it("on Windows, accepts a candidate whose --version prints to stderr (older Pythons)", async () => { + // Pythons < 3.4 print the version banner to stderr instead of stdout. + const commandExists = vi.fn(async () => true); + const runCommand = vi.fn(async () => ({ stdout: "", stderr: "Python 2.7.18\n" })); + + await expect(resolveManagedPythonCommand(commandExists, "win32", runCommand)).resolves.toBe( + "python3" + ); + }); + + it("on Windows, falls through to the next candidate when the first one's probe fails to spawn", async () => { + const commandExists = vi.fn(async () => true); + const runCommand = vi.fn(async (file: string) => { + if (file === "python3") { + // simulate spawn failure (file is a stub that can't be executed) + throw new Error("spawn python3 ENOENT"); + } + return { stdout: "Python 3.12.0\n", stderr: "" }; + }); + + await expect(resolveManagedPythonCommand(commandExists, "win32", runCommand)).resolves.toBe( + "python" + ); + }); + + it("on Windows, returns null when both candidates are stubs that print nothing", async () => { + const commandExists = vi.fn(async () => true); + const runCommand = vi.fn(async () => ({ stdout: "", stderr: "" })); + + await expect( + resolveManagedPythonCommand(commandExists, "win32", runCommand) + ).resolves.toBeNull(); + }); +}); diff --git a/packages/server/src/lsp-tools/definitions.ts b/packages/server/src/lsp-tools/definitions.ts index e9d4033d..330fcd00 100644 --- a/packages/server/src/lsp-tools/definitions.ts +++ b/packages/server/src/lsp-tools/definitions.ts @@ -1,4 +1,9 @@ import type { LspServerKind } from "@coder-studio/core"; +import { type CommandRunner, runCommandAsString } from "../provider-runtime/command-runner.js"; + +export const VUE_LANGUAGE_SERVER_VERSION = "3.3.2"; +export const VUE_TYPESCRIPT_VERSION = "6.0.3"; +export const VUE_MANAGED_VERSION = `${VUE_LANGUAGE_SERVER_VERSION}-typescript-${VUE_TYPESCRIPT_VERSION}`; export interface LspToolDefinition { serverKind: LspServerKind; @@ -64,6 +69,17 @@ export const LSP_TOOL_DEFINITIONS: Record = { supportedPlatforms: ["linux", "darwin", "win32"], }, }, + vue: { + serverKind: "vue", + displayName: "Vue language server", + defaultCommand: "vue-language-server", + defaultArgs: ["--stdio"], + managed: { + version: VUE_MANAGED_VERSION, + prerequisites: ["npm"], + supportedPlatforms: ["linux", "darwin", "win32"], + }, + }, }; export function getLspToolDefinition(serverKind: LspServerKind): LspToolDefinition { @@ -87,14 +103,41 @@ export function getManagedPrerequisites( export async function resolveManagedPythonCommand( commandExists: (command: string) => Promise, - platform: NodeJS.Platform = process.platform + platform: NodeJS.Platform = process.platform, + runCommand: CommandRunner = runCommandAsString ): Promise { const candidates = getManagedPrerequisites("python", platform); for (const candidate of candidates) { - if (await commandExists(candidate)) { - return candidate; + if (!(await commandExists(candidate))) { + continue; + } + if (platform === "win32" && !(await isWindowsPythonAlive(candidate, runCommand))) { + // `where python(3)` happily returns + // `%LOCALAPPDATA%\Microsoft\WindowsApps\python(3).exe` even when Python + // is not installed — those are zero-byte Microsoft Store "App Execution + // Aliases" that redirect to the Store. Accepting them would pass the + // prerequisite check and then explode at the `python -m venv ...` + // install step with an empty/non-existent venv. Probe the candidate + // with `--version` and require it to actually print something. + continue; } + return candidate; } return null; } + +/** + * Returns true if invoking ` --version` produces any output. Python + * prints its version to stdout from 3.4 onwards and stderr on older builds, + * so we accept either. The Microsoft Store stub prints nothing. + */ +async function isWindowsPythonAlive(command: string, runCommand: CommandRunner): Promise { + try { + const result = await runCommand(command, ["--version"], { windowsHide: true }); + const combined = `${result.stdout}\n${result.stderr}`.trim(); + return combined.length > 0; + } catch { + return false; + } +} diff --git a/packages/server/src/lsp-tools/install-manager.integration.test.ts b/packages/server/src/lsp-tools/install-manager.integration.test.ts new file mode 100644 index 00000000..63e47dd3 --- /dev/null +++ b/packages/server/src/lsp-tools/install-manager.integration.test.ts @@ -0,0 +1,220 @@ +/** + * Integration tests for `LspToolInstallManager`'s verify step. + * + * Goal: prove the verify step works for every managed LSP under both POSIX + * and Windows path conventions, *without mocking `commandExists`*. That makes + * this the only place that exercises the real `checkCommandAvailable` against + * the absolute path the install plan computes. + * + * Why this matters: every managed LSP (python, go, rust, vue) verifies by + * passing an absolute path to `commandExists`. Windows `where.exe` rejects + * absolute paths because it parses the colon as a `path:pattern` separator, + * so before the absolute-path branch in `checkCommandAvailable`, every LSP + * install silently failed at verify. We assert here that an on-disk + * executable at the planned path passes the verify step for every kind. + * + * Strategy: + * - Mock only `runCommand` (so we don't actually invoke npm/pip/go/curl). + * - In the runCommand mock, write a real file at the expected + * `executablePath` so the verify step's `fs.existsSync` succeeds. + * - Run for every managed serverKind, under both `linux` and `win32`. + */ + +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import type { LspServerKind, Workspace } from "@coder-studio/core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { VUE_MANAGED_VERSION } from "./definitions.js"; +import { LspToolInstallManager } from "./install-manager.js"; +import { FileManifestStore } from "./manifest-store.js"; + +const workspace: Workspace = { + id: "ws-1", + path: "/repo", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { leftPanelWidth: 240, bottomPanelHeight: 180, focusMode: false }, +}; + +interface ExpectedInstall { + serverKind: LspServerKind; + expectedPath: (root: string, platform: NodeJS.Platform) => string; +} + +const CASES: ExpectedInstall[] = [ + { + serverKind: "python", + expectedPath: (root, platform) => + join( + root, + "python", + "1.14.0", + "venv", + platform === "win32" ? "Scripts" : "bin", + platform === "win32" ? "pylsp.exe" : "pylsp" + ), + }, + { + serverKind: "go", + expectedPath: (root, platform) => + join(root, "go", "v0.21.1", "bin", platform === "win32" ? "gopls.exe" : "gopls"), + }, + { + serverKind: "rust", + expectedPath: (root, platform) => + join( + root, + "rust", + "2026-05-18", + "bin", + platform === "win32" ? "rust-analyzer.exe" : "rust-analyzer" + ), + }, + { + serverKind: "vue", + expectedPath: (root, platform) => + join( + root, + "vue", + VUE_MANAGED_VERSION, + "node_modules", + ".bin", + platform === "win32" ? "vue-language-server.cmd" : "vue-language-server" + ), + }, +]; + +// We can only meaningfully exercise the verify step on the host's actual +// platform — `path.join` and `fs.existsSync` use host conventions, and the +// real `checkCommandAvailable` walks the host PATH for any bare-name +// fallbacks. Cross-platform behavior of `checkCommandAvailable` itself is +// covered in detail by `command-check.test.ts`. +const PLATFORM = process.platform; + +describe(`LspToolInstallManager verify step (platform=${PLATFORM})`, () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + // Whitelist the bare-name prerequisites so the test doesn't depend on + // python3 / go / npm actually being installed on the runner. The verify + // step itself still goes through the *real* checkCommandAvailable. + const allowedPrereqs = new Set(["npm", "python", "python3", "go"]); + async function smartCommandExists(command: string): Promise { + if (allowedPrereqs.has(command)) { + return true; + } + const { checkCommandAvailable } = await import("../provider-runtime/command-check.js"); + return checkCommandAvailable(command, { platform: PLATFORM }); + } + + it.each(CASES)("$serverKind verify accepts the absolute managed executable path", async ({ + serverKind, + expectedPath: pathFn, + }) => { + const root = mkdtempSync(join(tmpdir(), `lsp-install-${serverKind}-`)); + const expectedPath = pathFn(root, PLATFORM); + + const runCommand = vi.fn(async (file: string, args: string[]) => { + // On Windows, the python prereq resolver probes ` + // --version` to defend against the Microsoft Store stub. Return a + // believable banner so the probe accepts the candidate; otherwise + // the install would short-circuit with `missing_prerequisite` + // before ever reaching the verify step under test. + if ( + PLATFORM === "win32" && + serverKind === "python" && + (file === "python" || file === "python3") && + args[0] === "--version" + ) { + return { stdout: "Python 3.12.0\n", stderr: "" }; + } + // Simulate the install step actually putting the executable on disk + // so the verify step (real `checkCommandAvailable`) can find it. + mkdirSync(dirname(expectedPath), { recursive: true }); + writeFileSync(expectedPath, "#!/usr/bin/env sh\nexit 0\n", { mode: 0o755 }); + return { stdout: "", stderr: "" }; + }); + + const manager = new LspToolInstallManager({ + manifestStore: new FileManifestStore(root), + platform: PLATFORM, + commandExists: smartCommandExists, + runCommand, + }); + + const job = await manager.start({ workspace, serverKind }); + + await vi.waitFor( + () => { + const snapshot = manager.get(job.jobId); + expect(snapshot?.status).not.toBe("running"); + expect(snapshot?.status).not.toBe("queued"); + }, + { timeout: 5000 } + ); + + const final = manager.get(job.jobId); + expect(final?.status).toBe("succeeded"); + + // Manifest written → verify step actually accepted the on-disk file. + // On Windows this is the regression test for the `where.exe` colon bug. + const manifest = new FileManifestStore(root).read(serverKind); + expect(manifest).toMatchObject({ + serverKind, + executablePath: expectedPath, + platform: PLATFORM, + source: "managed", + }); + }); + + it.each( + CASES + )("$serverKind verify rejects when no executable exists at the expected path", async ({ + serverKind, + }) => { + const root = mkdtempSync(join(tmpdir(), `lsp-install-${serverKind}-miss-`)); + + const manager = new LspToolInstallManager({ + manifestStore: new FileManifestStore(root), + platform: PLATFORM, + commandExists: smartCommandExists, + // runCommand succeeds but never writes the file, simulating an + // install step that silently completed without producing the binary. + // For python on Windows we also need to satisfy the `--version` + // probe, otherwise the prereq resolver would short-circuit before + // ever reaching the verify step under test. + runCommand: vi.fn(async (file: string, args: string[]) => { + if ( + PLATFORM === "win32" && + serverKind === "python" && + (file === "python" || file === "python3") && + args[0] === "--version" + ) { + return { stdout: "Python 3.12.0\n", stderr: "" }; + } + return { stdout: "", stderr: "" }; + }), + }); + + const job = await manager.start({ workspace, serverKind }); + + await vi.waitFor( + () => { + const snapshot = manager.get(job.jobId); + expect(snapshot?.status).not.toBe("running"); + expect(snapshot?.status).not.toBe("queued"); + }, + { timeout: 5000 } + ); + + const final = manager.get(job.jobId); + expect(final?.status).toBe("failed"); + // The verify step is the last one in every install plan; if it errored + // because the file isn't there, the failure should not be the missing + // prerequisites code (those were satisfied by smartCommandExists). + expect(final?.failure?.code).not.toBe("missing_prerequisite"); + }); +}); diff --git a/packages/server/src/lsp-tools/install-manager.test.ts b/packages/server/src/lsp-tools/install-manager.test.ts index f187a6d7..9b0ffb61 100644 --- a/packages/server/src/lsp-tools/install-manager.test.ts +++ b/packages/server/src/lsp-tools/install-manager.test.ts @@ -3,6 +3,11 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import type { Workspace } from "@coder-studio/core"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { + VUE_LANGUAGE_SERVER_VERSION, + VUE_MANAGED_VERSION, + VUE_TYPESCRIPT_VERSION, +} from "./definitions.js"; import { LspToolInstallManager } from "./install-manager.js"; import { FileManifestStore } from "./manifest-store.js"; @@ -22,8 +27,12 @@ describe("LspToolInstallManager", () => { }); it("returns missing_prerequisite when python3 is unavailable", async () => { + // Pin platform so the prerequisite list is deterministic (on win32 the + // manager also tries `python` as a fallback, which would otherwise leak + // into `missingCommands`). const manager = new LspToolInstallManager({ manifestStore: new FileManifestStore(mkdtempSync(join(tmpdir(), "lsp-tools-"))), + platform: "linux", commandExists: vi.fn(async () => false), runCommand: vi.fn(async () => ({ stdout: "", stderr: "" })), }); @@ -40,6 +49,30 @@ describe("LspToolInstallManager", () => { }); }); + it("fails with missing_prerequisite when the Windows python candidate is a Microsoft Store stub", async () => { + // Regression test: `where.exe python` returns the zero-byte App + // Execution Alias at `%LOCALAPPDATA%\Microsoft\WindowsApps\python.exe` + // even when Python is not installed. The manager must reject the + // candidate via the version probe instead of silently falling through + // to the venv install step (which then fails opaquely). + const manager = new LspToolInstallManager({ + manifestStore: new FileManifestStore(mkdtempSync(join(tmpdir(), "lsp-tools-"))), + platform: "win32", + // `where` finds both stubs. + commandExists: vi.fn(async () => true), + // ...but invoking the stub produces no output (Store stub behavior). + runCommand: vi.fn(async () => ({ stdout: "", stderr: "" })), + }); + + const job = await manager.start({ workspace, serverKind: "python" }); + + expect(job.status).toBe("failed"); + expect(job.failure).toMatchObject({ + code: "missing_prerequisite", + missingCommands: ["python3", "python"], + }); + }); + it("allows managed Python install on Windows when python is available but python3 is not", async () => { const root = mkdtempSync(join(tmpdir(), "lsp-tools-")); let installed = false; @@ -63,6 +96,13 @@ describe("LspToolInstallManager", () => { return false; }), runCommand: vi.fn(async (file: string, args: string[]) => { + // The resolver also probes `python --version` on Windows to defend + // against Microsoft Store stubs. Return a believable version banner + // so the candidate is accepted. + if (file === "python" && args[0] === "--version") { + return { stdout: "Python 3.12.0\n", stderr: "" }; + } + if (file === "python" && args[0] === "-m" && args[1] === "venv") { return { stdout: "created venv", stderr: "" }; } @@ -72,7 +112,7 @@ describe("LspToolInstallManager", () => { return { stdout: "installed pylsp", stderr: "" }; } - throw new Error(`unexpected command: ${file}`); + throw new Error(`unexpected command: ${file} ${args.join(" ")}`); }), }); @@ -157,6 +197,71 @@ describe("LspToolInstallManager", () => { }); }); + it("installs the vue language server into the managed tool directory and writes a manifest", async () => { + const root = mkdtempSync(join(tmpdir(), "lsp-tools-")); + let installed = false; + const executablePath = join( + root, + "vue", + VUE_MANAGED_VERSION, + "node_modules", + ".bin", + process.platform === "win32" ? "vue-language-server.cmd" : "vue-language-server" + ); + const runCommand = vi.fn(async (file: string) => { + if (file === "npm") { + installed = true; + return { stdout: "installed vue-language-server", stderr: "" }; + } + + throw new Error(`unexpected command: ${file}`); + }); + + const manager = new LspToolInstallManager({ + manifestStore: new FileManifestStore(root), + commandExists: vi.fn(async (command: string) => { + if (command === "npm") { + return true; + } + + if (command === executablePath) { + return installed; + } + + return false; + }), + runCommand, + }); + + const started = await manager.start({ + workspace, + serverKind: "vue", + }); + + await vi.waitFor(() => { + expect(manager.get(started.jobId)?.status).toBe("succeeded"); + }); + + expect(new FileManifestStore(root).read("vue")).toMatchObject({ + serverKind: "vue", + version: VUE_MANAGED_VERSION, + executablePath, + source: "managed", + }); + expect(runCommand).toHaveBeenCalledWith( + "npm", + [ + "install", + "--no-save", + `@vue/language-server@${VUE_LANGUAGE_SERVER_VERSION}`, + `typescript@${VUE_TYPESCRIPT_VERSION}`, + ], + expect.objectContaining({ + cwd: join(root, "vue", VUE_MANAGED_VERSION), + }) + ); + }); + it("classifies install-step ENOENT failures as command_not_found", async () => { const installError = Object.assign(new Error("spawn python3 ENOENT"), { code: "ENOENT", @@ -165,6 +270,10 @@ describe("LspToolInstallManager", () => { }); const manager = new LspToolInstallManager({ manifestStore: new FileManifestStore(mkdtempSync(join(tmpdir(), "lsp-tools-"))), + // Pin platform so the Windows-only Microsoft Store stub probe (which + // calls `python --version`) doesn't intercept the ENOENT we want the + // install step itself to surface. + platform: "linux", commandExists: vi.fn(async () => true), runCommand: vi.fn(async () => { throw installError; @@ -214,6 +323,12 @@ describe("LspToolInstallManager", () => { return false; }), runCommand: vi.fn(async (file: string, args: string[]) => { + // On Windows the resolver probes `python3 --version` to defend + // against the Microsoft Store stub. Return a believable banner. + if (file === "python3" && args[0] === "--version") { + return { stdout: "Python 3.12.0\n", stderr: "" }; + } + if (file === "python3" && args[0] === "-m" && args[1] === "venv") { return { stdout: "created venv", stderr: "" }; } @@ -223,7 +338,7 @@ describe("LspToolInstallManager", () => { return { stdout: "installed pylsp", stderr: "" }; } - throw new Error(`unexpected command: ${file}`); + throw new Error(`unexpected command: ${file} ${args.join(" ")}`); }), }); @@ -260,7 +375,15 @@ describe("LspToolInstallManager", () => { it("downloads rust-analyzer into the managed tool directory and writes a manifest", async () => { const root = mkdtempSync(join(tmpdir(), "lsp-tools-")); let installed = false; - const executablePath = join(root, "rust", "2026-05-18", "bin", "rust-analyzer"); + // The real manager picks `.exe` on Windows; mirror that here so the + // commandExists mock matches the path the verify step actually checks. + const executablePath = join( + root, + "rust", + "2026-05-18", + "bin", + process.platform === "win32" ? "rust-analyzer.exe" : "rust-analyzer" + ); const manager = new LspToolInstallManager({ manifestStore: new FileManifestStore(root), diff --git a/packages/server/src/lsp-tools/install-manager.ts b/packages/server/src/lsp-tools/install-manager.ts index 303b05d0..61e88833 100644 --- a/packages/server/src/lsp-tools/install-manager.ts +++ b/packages/server/src/lsp-tools/install-manager.ts @@ -18,6 +18,8 @@ import { getLspToolDefinition, getManagedPrerequisites, resolveManagedPythonCommand, + VUE_LANGUAGE_SERVER_VERSION, + VUE_TYPESCRIPT_VERSION, } from "./definitions.js"; import { FileManifestStore } from "./manifest-store.js"; @@ -115,7 +117,11 @@ export class LspToolInstallManager { const missingPrerequisites: string[] = []; let pythonCommand: string | null = null; if (input.serverKind === "python") { - pythonCommand = await resolveManagedPythonCommand(commandExists, platform); + pythonCommand = await resolveManagedPythonCommand( + commandExists, + platform, + this.deps.runCommand + ); if (!pythonCommand) { missingPrerequisites.push(...getManagedPrerequisites("python", platform)); } @@ -155,17 +161,7 @@ export class LspToolInstallManager { } const installRoot = join(this.deps.manifestStore.getRoot(), input.serverKind, managed.version); - const executablePath = - input.serverKind === "python" - ? join( - installRoot, - "venv", - platform === "win32" ? "Scripts" : "bin", - platform === "win32" ? "pylsp.exe" : "pylsp" - ) - : input.serverKind === "go" - ? join(installRoot, "bin", platform === "win32" ? "gopls.exe" : "gopls") - : join(installRoot, "bin", platform === "win32" ? "rust-analyzer.exe" : "rust-analyzer"); + const executablePath = resolveManagedExecutablePath(input.serverKind, installRoot, platform); const plannedSteps = this.planInstallSteps({ serverKind: input.serverKind, @@ -201,22 +197,14 @@ export class LspToolInstallManager { this.jobs.set(job.jobId, job); const installRoot = join(this.deps.manifestStore.getRoot(), serverKind, managed.version); - const executablePath = - serverKind === "python" - ? join( - installRoot, - "venv", - platform === "win32" ? "Scripts" : "bin", - platform === "win32" ? "pylsp.exe" : "pylsp" - ) - : serverKind === "go" - ? join(installRoot, "bin", platform === "win32" ? "gopls.exe" : "gopls") - : join(installRoot, "bin", platform === "win32" ? "rust-analyzer.exe" : "rust-analyzer"); + const executablePath = resolveManagedExecutablePath(serverKind, installRoot, platform); const commandExists = this.deps.commandExists ?? ((command: string) => checkCommandAvailable(command, this.deps)); const pythonCommand = - serverKind === "python" ? await resolveManagedPythonCommand(commandExists, platform) : null; + serverKind === "python" + ? await resolveManagedPythonCommand(commandExists, platform, this.deps.runCommand) + : null; mkdirSync(dirname(executablePath), { recursive: true }); @@ -353,6 +341,31 @@ export class LspToolInstallManager { ]; } + if (input.serverKind === "vue") { + return [ + { + id: "install-vue-lsp", + title: "Install Vue language server", + kind: "install", + command: "npm", + args: [ + "install", + "--no-save", + `@vue/language-server@${VUE_LANGUAGE_SERVER_VERSION}`, + `typescript@${VUE_TYPESCRIPT_VERSION}`, + ], + cwd: input.installRoot, + }, + { + id: "verify-vue-lsp", + title: "Verify Vue language server", + kind: "verify", + command: input.executablePath, + args: ["--version"], + }, + ]; + } + return [ { id: "install-rust-lsp", @@ -400,6 +413,36 @@ export class LspToolInstallManager { } } +function resolveManagedExecutablePath( + serverKind: LspServerKind, + installRoot: string, + platform: NodeJS.Platform +): string { + if (serverKind === "python") { + return join( + installRoot, + "venv", + platform === "win32" ? "Scripts" : "bin", + platform === "win32" ? "pylsp.exe" : "pylsp" + ); + } + + if (serverKind === "go") { + return join(installRoot, "bin", platform === "win32" ? "gopls.exe" : "gopls"); + } + + if (serverKind === "rust") { + return join(installRoot, "bin", platform === "win32" ? "rust-analyzer.exe" : "rust-analyzer"); + } + + return join( + installRoot, + "node_modules", + ".bin", + platform === "win32" ? "vue-language-server.cmd" : "vue-language-server" + ); +} + function toSnapshotStep(step: InstallPlanStep): LspToolInstallStepSnapshot { return { id: step.id, diff --git a/packages/server/src/lsp-tools/manager.test.ts b/packages/server/src/lsp-tools/manager.test.ts index 052a1933..c37d6a11 100644 --- a/packages/server/src/lsp-tools/manager.test.ts +++ b/packages/server/src/lsp-tools/manager.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import type { Workspace } from "@coder-studio/core"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { VUE_MANAGED_VERSION } from "./definitions.js"; import { LspToolManager } from "./manager.js"; import { FileManifestStore } from "./manifest-store.js"; @@ -92,6 +93,9 @@ describe("LspToolManager.resolve", () => { const manager = new LspToolManager({ manifestStore: new FileManifestStore(root), + // Pin platform so the win32-only Microsoft Store stub probe doesn't + // reach for the real `python --version` on the host. + platform: "linux", commandExists: vi.fn(async (command: string) => command === "python3"), resolveBundledCommand: vi.fn(() => null), }); @@ -128,6 +132,7 @@ describe("LspToolManager.resolve", () => { const manager = new LspToolManager({ manifestStore: new FileManifestStore(root), + platform: "linux", commandExists: vi.fn(async (command: string) => command === "python3"), resolveBundledCommand: vi.fn(() => null), }); @@ -195,10 +200,100 @@ describe("LspToolManager.resolve", () => { expect(result.args.slice(1)).toEqual(["--stdio"]); }); + it("rejects a Windows system PATH command whose `--version` prints nothing (e.g. broken rustup shim)", async () => { + // Regression test: `~/.cargo/bin/rust-analyzer.exe` exists on PATH as a + // rustup proxy even when the `rust-analyzer` component is not installed. + // Running it prints "Unknown binary 'rust-analyzer.exe' in official + // toolchain" to stderr and exits — the manager must fall through to the + // managed install path instead of pretending the system has a working + // rust-analyzer (which causes opaque LSP initialize timeouts). + const root = mkdtempSync(join(tmpdir(), "lsp-tools-")); + const manager = new LspToolManager({ + manifestStore: new FileManifestStore(root), + platform: "win32", + commandExists: vi.fn(async () => true), + runCommand: vi.fn(async () => { + // Simulate the rustup proxy: throws because of the non-zero exit. + const err = Object.assign(new Error("Command failed with exit code 1"), { + exitCode: 1, + stdout: "", + stderr: "", + }); + throw err; + }), + resolveBundledCommand: vi.fn(() => null), + }); + + const result = await manager.resolve({ + workspace, + serverKind: "rust", + env: {}, + }); + + expect(result).toMatchObject({ + kind: "tool_missing", + serverKind: "rust", + autoInstallSupported: true, + }); + }); + + it("accepts a Windows system command whose `--version` produces output", async () => { + const root = mkdtempSync(join(tmpdir(), "lsp-tools-")); + const manager = new LspToolManager({ + manifestStore: new FileManifestStore(root), + platform: "win32", + commandExists: vi.fn(async () => true), + runCommand: vi.fn(async () => ({ + stdout: "rust-analyzer 1.92.0 (ded5c06c 2025-12-08)\n", + stderr: "", + })), + resolveBundledCommand: vi.fn(() => null), + }); + + const result = await manager.resolve({ + workspace, + serverKind: "rust", + env: {}, + }); + + expect(result).toMatchObject({ + kind: "ready", + source: "system", + command: "rust-analyzer", + }); + }); + + it("skips the `--version` probe on POSIX hosts because broken proxies are uncommon there", async () => { + const root = mkdtempSync(join(tmpdir(), "lsp-tools-")); + const runCommand = vi.fn(); + const manager = new LspToolManager({ + manifestStore: new FileManifestStore(root), + platform: "linux", + commandExists: vi.fn(async () => true), + runCommand, + resolveBundledCommand: vi.fn(() => null), + }); + + const result = await manager.resolve({ + workspace, + serverKind: "rust", + env: {}, + }); + + expect(result).toMatchObject({ + kind: "ready", + source: "system", + }); + // POSIX must NOT incur the extra `--version` spawn for every LSP we + // resolve — it adds startup latency without any meaningful protection. + expect(runCommand).not.toHaveBeenCalled(); + }); + it("returns tool_missing when no source is available", async () => { const root = mkdtempSync(join(tmpdir(), "lsp-tools-")); const manager = new LspToolManager({ manifestStore: new FileManifestStore(root), + platform: "linux", commandExists: vi.fn(async (command: string) => command === "python3"), resolveBundledCommand: vi.fn(() => null), }); @@ -241,4 +336,48 @@ describe("LspToolManager.resolve", () => { missingCommands: ["rust-analyzer"], }); }); + + it("prefers a managed vue install over system PATH", async () => { + const root = mkdtempSync(join(tmpdir(), "lsp-tools-")); + const executablePath = join( + root, + "vue", + VUE_MANAGED_VERSION, + "node_modules", + ".bin", + process.platform === "win32" ? "vue-language-server.cmd" : "vue-language-server" + ); + mkdirSync(dirname(executablePath), { recursive: true }); + writeFileSync(executablePath, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + writeFileSync( + join(root, "vue", "manifest.json"), + JSON.stringify({ + serverKind: "vue", + version: VUE_MANAGED_VERSION, + executablePath, + installedAt: 1, + source: "managed", + platform: process.platform, + }) + ); + + const manager = new LspToolManager({ + manifestStore: new FileManifestStore(root), + commandExists: vi.fn(async () => true), + resolveBundledCommand: vi.fn(() => null), + }); + + const result = await manager.resolve({ + workspace, + serverKind: "vue", + env: {}, + }); + + expect(result).toMatchObject({ + kind: "ready", + source: "managed", + command: executablePath, + args: ["--stdio"], + }); + }); }); diff --git a/packages/server/src/lsp-tools/manager.ts b/packages/server/src/lsp-tools/manager.ts index c6b3c939..ade0c182 100644 --- a/packages/server/src/lsp-tools/manager.ts +++ b/packages/server/src/lsp-tools/manager.ts @@ -12,6 +12,7 @@ import { type CommandCheckDeps, checkCommandAvailable, } from "../provider-runtime/command-check.js"; +import { type CommandRunner, runCommandAsString } from "../provider-runtime/command-runner.js"; import { getLspCommandOverridePrefix, getLspToolDefinition, @@ -102,7 +103,7 @@ export class LspToolManager { }; } - if (await commandExists(definition.defaultCommand)) { + if (await this.isSystemCommandUsable(definition.defaultCommand, commandExists)) { return { kind: "ready", serverKind: input.serverKind, @@ -147,6 +148,43 @@ export class LspToolManager { }; } + /** + * Decide whether a system-PATH command should be treated as a usable LSP + * source. `commandExists` only checks PATH presence, but Windows is full of + * zero-byte or proxy shims that satisfy that check yet refuse to actually + * run: + * + * - `%LOCALAPPDATA%\Microsoft\WindowsApps\python(3).exe` — Microsoft + * Store app execution aliases that redirect to the Store when the + * underlying app isn't installed. + * - `~/.cargo/bin/rust-analyzer.exe` when the `rust-analyzer` rustup + * component isn't installed — the rustup proxy prints + * "Unknown binary 'rust-analyzer.exe' in official toolchain" and exits. + * + * Accepting either of those as a "system" source leads to ambiguous LSP + * timeouts the first time a hover lands. Probe with `--version` on + * Windows so we fall through to the managed install path instead. + */ + private async isSystemCommandUsable( + command: string, + commandExists: CommandAvailabilityCheck + ): Promise { + if (!(await commandExists(command))) { + return false; + } + const platform = this.deps.platform ?? process.platform; + if (platform !== "win32") { + return true; + } + const runCommand: CommandRunner = this.deps.runCommand ?? runCommandAsString; + try { + const result = await runCommand(command, ["--version"], { windowsHide: true }); + return `${result.stdout}\n${result.stderr}`.trim().length > 0; + } catch { + return false; + } + } + private resolveOverride( serverKind: LspServerKind, env: NodeJS.ProcessEnv @@ -238,7 +276,11 @@ export class LspToolManager { if (managed && workspace.targetRuntime === "native") { if (definition.serverKind === "python") { - const pythonCommand = await resolveManagedPythonCommand(commandExists, platform); + const pythonCommand = await resolveManagedPythonCommand( + commandExists, + platform, + this.deps.runCommand + ); if (!pythonCommand) { missingPrerequisites.push(...getManagedPrerequisites("python", platform)); } diff --git a/packages/server/src/lsp-tools/runtime-status.ts b/packages/server/src/lsp-tools/runtime-status.ts index d3432baa..6b455a78 100644 --- a/packages/server/src/lsp-tools/runtime-status.ts +++ b/packages/server/src/lsp-tools/runtime-status.ts @@ -1,7 +1,7 @@ import type { LspServerKind, Workspace } from "@coder-studio/core"; import type { LspToolManager } from "./manager.js"; -const SERVER_KINDS: LspServerKind[] = ["typescript", "python", "go", "rust"]; +const SERVER_KINDS: LspServerKind[] = ["typescript", "python", "go", "rust", "vue"]; export async function buildLspRuntimeStatus(input: { workspace: Workspace; diff --git a/packages/server/src/lsp/document-store.test.ts b/packages/server/src/lsp/document-store.test.ts index cde620a2..a7e58e1a 100644 --- a/packages/server/src/lsp/document-store.test.ts +++ b/packages/server/src/lsp/document-store.test.ts @@ -5,6 +5,13 @@ import { pathToFileURL } from "node:url"; import { describe, expect, it } from "vitest"; import { DocumentStore } from "./document-store.js"; +// POSIX-style fixtures (`/repo`, `file:///repo/...`) only resolve correctly +// when the host treats `/` as an absolute root. On Windows `path.resolve("/repo")` +// returns `:\repo`, which breaks both URI construction and reverse +// lookup. Gate the POSIX-only assertions to keep the suite green on every host +// while preserving the actual coverage on the platforms where it matters. +const itPosix = process.platform === "win32" ? it.skip : it; + describe("DocumentStore", () => { it("tracks open/change/close versions and replayable snapshots", () => { const store = new DocumentStore("/repo"); @@ -31,7 +38,7 @@ describe("DocumentStore", () => { expect(store.listReplayable()).toHaveLength(0); }); - it("maps file URIs back to workspace-relative paths without a leading slash", () => { + itPosix("maps file URIs back to workspace-relative paths without a leading slash", () => { const store = new DocumentStore("/repo"); expect(store.fromUri("file:///repo/e2e/fixtures/lsp-workspace/shared.ts")).toBe( @@ -39,7 +46,7 @@ describe("DocumentStore", () => { ); }); - it("encodes spaces in file URIs and decodes them back to relative paths", () => { + itPosix("encodes spaces in file URIs and decodes them back to relative paths", () => { const store = new DocumentStore("/repo with spaces"); const opened = store.open({ path: "dir/a b.ts", @@ -61,18 +68,24 @@ describe("DocumentStore", () => { ); }); - it("maps POSIX file URIs back to workspace-relative paths when the workspace path is a symlink alias", () => { - const realRoot = mkdtempSync(join(tmpdir(), "document-store-real-")); - const aliasParent = mkdtempSync(join(tmpdir(), "document-store-alias-")); - const aliasRoot = join(aliasParent, "workspace"); - - mkdirSync(join(realRoot, "src")); - symlinkSync(realRoot, aliasRoot, "dir"); - - const store = new DocumentStore(aliasRoot); - - expect(store.fromUri(pathToFileURL(join(realRoot, "src/main.ts")).toString())).toBe( - "src/main.ts" - ); - }); + // `symlinkSync(... "dir")` requires elevated privileges or Developer Mode on + // Windows. The behaviour we care about (resolving symlink-aliased workspace + // roots) is POSIX-only in practice, so gate the test to non-Windows hosts. + itPosix( + "maps POSIX file URIs back to workspace-relative paths when the workspace path is a symlink alias", + () => { + const realRoot = mkdtempSync(join(tmpdir(), "document-store-real-")); + const aliasParent = mkdtempSync(join(tmpdir(), "document-store-alias-")); + const aliasRoot = join(aliasParent, "workspace"); + + mkdirSync(join(realRoot, "src")); + symlinkSync(realRoot, aliasRoot, "dir"); + + const store = new DocumentStore(aliasRoot); + + expect(store.fromUri(pathToFileURL(join(realRoot, "src/main.ts")).toString())).toBe( + "src/main.ts" + ); + } + ); }); diff --git a/packages/server/src/lsp/manager.test.ts b/packages/server/src/lsp/manager.test.ts index 79c4e7a0..3326f164 100644 --- a/packages/server/src/lsp/manager.test.ts +++ b/packages/server/src/lsp/manager.test.ts @@ -272,6 +272,267 @@ describe("LspManager", () => { expect(stop).toHaveBeenCalledTimes(1); }); + it("starts a vue session for vue files when the tool manager resolves ready", async () => { + const vueSummary = { + workspaceId: "ws-1", + serverKind: "vue" as const, + status: "ready" as const, + capabilities: { + definition: true, + references: true, + hover: true, + documentSymbols: true, + diagnostics: true, + }, + }; + const fakeSession = { + start: vi.fn(async () => vueSummary), + stop: vi.fn(async () => {}), + getSummary: () => vueSummary, + openDocument: async () => 1, + changeDocument: async () => 2, + closeDocument: async () => {}, + definition: async () => [], + declaration: async () => [], + typeDefinition: async () => [], + references: async () => [], + hover: async () => null, + documentSymbols: async () => [], + }; + + const manager = new LspManager({ + workspaceMgr: { + get: () => ({ + id: "ws-1", + path: process.cwd(), + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { leftPanelWidth: 250, bottomPanelHeight: 200, focusMode: false }, + }), + }, + eventBus: { emit: vi.fn() }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + requestTimeoutMs: 1000, + idleTtlMs: 1000, + restartLimit: 2, + lspToolMgr: { + resolve: vi.fn(async () => ({ + kind: "ready" as const, + serverKind: "vue" as const, + displayName: "Vue language server", + source: "managed" as const, + command: "/tools/vue-language-server", + args: ["--stdio"], + })), + } as never, + createSession: vi.fn(() => fakeSession), + }); + + await expect( + manager.ensureSession({ + workspaceId: "ws-1", + path: "src/App.vue", + }) + ).resolves.toMatchObject({ + kind: "ready", + summary: { serverKind: "vue" }, + }); + }); + + it("attaches a typescript companion + tsserver bridge to vue sessions when both ends resolve", async () => { + const vueSummary = { + workspaceId: "ws-1", + serverKind: "vue" as const, + status: "ready" as const, + capabilities: { + definition: true, + references: true, + hover: true, + documentSymbols: true, + diagnostics: true, + }, + }; + const sessionDeps: Array = []; + const createSession = vi.fn((deps) => { + sessionDeps.push(deps); + return { + start: vi.fn(async () => vueSummary), + stop: vi.fn(async () => {}), + getSummary: () => vueSummary, + openDocument: async () => 1, + changeDocument: async () => 2, + closeDocument: async () => {}, + definition: async () => [], + declaration: async () => [], + typeDefinition: async () => [], + references: async () => [], + hover: async () => null, + documentSymbols: async () => [], + }; + }); + const resolve = vi.fn(async (input: { serverKind: "vue" | "typescript" }) => + input.serverKind === "vue" + ? { + kind: "ready" as const, + serverKind: "vue" as const, + displayName: "Vue language server", + source: "managed" as const, + command: "/tmp/coder-studio/lsp-tools/vue/3.3.2/node_modules/.bin/vue-language-server", + args: ["--stdio"], + } + : { + kind: "ready" as const, + serverKind: "typescript" as const, + displayName: "TypeScript language server", + source: "bundled" as const, + command: "/usr/local/bin/node", + args: ["/bundled/lib/cli.mjs", "--stdio"], + } + ); + + const manager = new LspManager({ + workspaceMgr: { + get: () => ({ + id: "ws-1", + path: process.cwd(), + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { leftPanelWidth: 250, bottomPanelHeight: 200, focusMode: false }, + }), + }, + eventBus: { emit: vi.fn() }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + requestTimeoutMs: 1000, + idleTtlMs: 1000, + restartLimit: 2, + lspToolMgr: { resolve } as never, + createSession, + vueBridgeMode: "auto", + }); + + await expect( + manager.ensureSession({ workspaceId: "ws-1", path: "src/App.vue" }) + ).resolves.toMatchObject({ kind: "ready", summary: { serverKind: "vue" } }); + + expect(resolve).toHaveBeenCalledWith(expect.objectContaining({ serverKind: "vue" })); + expect(resolve).toHaveBeenCalledWith(expect.objectContaining({ serverKind: "typescript" })); + + const created = sessionDeps[0] as { spec: { companion?: { initializationOptions?: unknown } } }; + expect(created).toMatchObject({ + spec: { + serverKind: "vue", + command: "/tmp/coder-studio/lsp-tools/vue/3.3.2/node_modules/.bin/vue-language-server", + bridges: { tsserverRequest: true }, + companion: { + command: "/usr/local/bin/node", + args: ["/bundled/lib/cli.mjs", "--stdio"], + }, + }, + }); + // Plugin location comes back in the host's path style; normalize for the + // assertion so it works on both POSIX and Windows. + const plugins = ( + created.spec.companion?.initializationOptions as { + plugins?: Array<{ name: string; location: string; languages: string[] }>; + } + )?.plugins; + expect(plugins).toHaveLength(1); + expect(plugins?.[0]?.name).toBe("@vue/typescript-plugin"); + expect(plugins?.[0]?.languages).toEqual(["vue"]); + expect(plugins?.[0]?.location.replace(/\\/g, "/")).toMatch( + /tmp.coder-studio.lsp-tools.vue.3\.3\.2.node_modules.@vue.language-server$/ + ); + }); + + it("omits the vue tsserver bridge when CODER_STUDIO_VUE_TSSERVER_BRIDGE is off", async () => { + const sessionDeps: Array = []; + const createSession = vi.fn((deps) => { + sessionDeps.push(deps); + return { + start: vi.fn(async () => ({ + workspaceId: "ws-1", + serverKind: "vue" as const, + status: "ready" as const, + capabilities: { + definition: true, + references: true, + hover: true, + documentSymbols: true, + diagnostics: true, + }, + })), + stop: vi.fn(async () => {}), + getSummary: () => + ({ + workspaceId: "ws-1", + serverKind: "vue", + status: "ready", + capabilities: { + definition: true, + references: true, + hover: true, + documentSymbols: true, + diagnostics: true, + }, + }) as never, + openDocument: async () => 1, + changeDocument: async () => 2, + closeDocument: async () => {}, + definition: async () => [], + declaration: async () => [], + typeDefinition: async () => [], + references: async () => [], + hover: async () => null, + documentSymbols: async () => [], + }; + }); + const resolve = vi.fn(async () => ({ + kind: "ready" as const, + serverKind: "vue" as const, + displayName: "Vue language server", + source: "managed" as const, + command: "/install/node_modules/.bin/vue-language-server", + args: ["--stdio"], + })); + + const manager = new LspManager({ + workspaceMgr: { + get: () => ({ + id: "ws-1", + path: process.cwd(), + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { leftPanelWidth: 250, bottomPanelHeight: 200, focusMode: false }, + }), + }, + eventBus: { emit: vi.fn() }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + requestTimeoutMs: 1000, + idleTtlMs: 1000, + restartLimit: 2, + lspToolMgr: { resolve } as never, + createSession, + vueBridgeMode: "off", + }); + + await manager.ensureSession({ workspaceId: "ws-1", path: "src/App.vue" }); + + // Only the vue resolve call — no typescript companion resolution either. + expect(resolve).toHaveBeenCalledTimes(1); + + const [created] = sessionDeps; + expect(created).toMatchObject({ + spec: { + serverKind: "vue", + bridges: undefined, + companion: undefined, + }, + }); + }); + it("coalesces concurrent ensureSession calls for the same workspace and server kind", async () => { let resolveStart: ((summary: typeof readySummary) => void) | null = null; const startPromise = new Promise((resolve) => { diff --git a/packages/server/src/lsp/manager.ts b/packages/server/src/lsp/manager.ts index 8ea68534..17931036 100644 --- a/packages/server/src/lsp/manager.ts +++ b/packages/server/src/lsp/manager.ts @@ -5,12 +5,19 @@ import type { LspHoverResult, LspLocation, LspRuntimeMode, + LspSemanticTokens, LspSessionSummary, Workspace, } from "@coder-studio/core"; -import { LspToolManager } from "../lsp-tools/manager.js"; +import { LspToolManager, type ResolvedLspToolCommand } from "../lsp-tools/manager.js"; import { resolveLspServerKind, wrapLspCommandForWorkspace } from "./server-factory.js"; import { LspSession } from "./session.js"; +import { + buildVueSpecParts, + inferVueLanguageServerLocation, + parseVueBridgeMode, + type VueBridgeMode, +} from "./vue-spec.js"; type LspSessionDeps = ConstructorParameters[0]; @@ -31,6 +38,7 @@ interface LspSessionLike { references(input: { path: string; line: number; column: number }): Promise; hover(input: { path: string; line: number; column: number }): Promise; documentSymbols(input: { path: string }): Promise; + semanticTokens(input: { path: string }): Promise; } interface ManagedSessionEntry { @@ -57,10 +65,23 @@ export class LspManager { error: (...args: unknown[]) => void; }; requestTimeoutMs: number; + /** + * Timeout for the one-off LSP `initialize` request. Defaults to + * `requestTimeoutMs * 10` inside `LspSession`; override here if you + * want a different ceiling without inflating `requestTimeoutMs` (which + * also governs every hover/definition query). + */ + initializeTimeoutMs?: number; idleTtlMs: number; restartLimit: number; lspToolMgr: LspToolManager; createSession?: (deps: LspSessionDeps) => LspSessionLike; + /** + * Optional override for the Vue tsserver bridge. When omitted, the + * manager reads `process.env.CODER_STUDIO_VUE_TSSERVER_BRIDGE` (`auto` + * or `off`). Useful in tests to avoid touching the real environment. + */ + vueBridgeMode?: VueBridgeMode; } ) {} @@ -125,12 +146,18 @@ export class LspManager { }; } + const vueParts = + serverKind === "vue" ? await this.composeVueSpecParts(workspace, resolution) : null; + const spec = wrapLspCommandForWorkspace({ workspace, serverKind, command: resolution.command, args: resolution.args, rootPath: workspace.path, + initializationOptions: vueParts?.initializationOptions, + companion: vueParts?.companion, + bridges: vueParts?.bridges, }); const key = this.keyFor(input.workspaceId, spec.serverKind); @@ -171,6 +198,7 @@ export class LspManager { workspacePath: workspace.path, spec, requestTimeoutMs: this.deps.requestTimeoutMs, + initializeTimeoutMs: this.deps.initializeTimeoutMs, logger: this.deps.logger, onDiagnostics: (payload) => this.deps.eventBus.emit({ @@ -314,6 +342,14 @@ export class LspManager { return session ? await session.documentSymbols(input) : null; } + async semanticTokens(input: { workspaceId: string; path: string }) { + if (this.runtimeMode === "off") { + return null; + } + const session = await this.getSessionForPath(input.workspaceId, input.path); + return session ? await session.semanticTokens(input) : null; + } + async disposeWorkspace(workspaceId: string): Promise { const keys = Array.from(this.sessions.keys()).filter((key) => key.startsWith(`${workspaceId}::`) @@ -338,6 +374,90 @@ export class LspManager { return this.deps.createSession ? this.deps.createSession(deps) : new LspSession(deps); } + private async composeVueSpecParts( + workspace: Workspace, + vueResolution: ResolvedLspToolCommand + ): Promise | null> { + const bridgeMode = + this.deps.vueBridgeMode ?? parseVueBridgeMode(process.env.CODER_STUDIO_VUE_TSSERVER_BRIDGE); + + const vueLanguageServerLocation = inferVueLanguageServerLocation(vueResolution.command); + if (!vueLanguageServerLocation) { + // We can't tell the TypeScript server where `@vue/typescript-plugin` + // lives, so without that the bridge would be useless. Run Volar alone + // and accept that semantic features won't return — better than failing + // to start at all. + this.deps.logger.warn( + { command: vueResolution.command }, + "could not infer @vue/language-server install location; vue tsserver bridge disabled" + ); + return buildVueSpecParts({ + vueCommand: vueResolution.command, + vueArgs: vueResolution.args, + vueLanguageServerLocation: "", + typescriptCommand: "", + typescriptArgs: [], + bridgeMode: "off", + }); + } + + if (bridgeMode === "off") { + return buildVueSpecParts({ + vueCommand: vueResolution.command, + vueArgs: vueResolution.args, + vueLanguageServerLocation, + typescriptCommand: "", + typescriptArgs: [], + bridgeMode: "off", + }); + } + + let tsResolution: Awaited>; + try { + tsResolution = await this.deps.lspToolMgr.resolve({ + workspace, + serverKind: "typescript", + }); + } catch (error) { + this.deps.logger.warn( + { err: error }, + "failed to resolve typescript companion for vue session; bridge disabled" + ); + return buildVueSpecParts({ + vueCommand: vueResolution.command, + vueArgs: vueResolution.args, + vueLanguageServerLocation, + typescriptCommand: "", + typescriptArgs: [], + bridgeMode: "off", + }); + } + + if (tsResolution.kind !== "ready") { + this.deps.logger.warn( + { missing: tsResolution.missingCommands }, + "typescript language server unavailable for vue tsserver bridge" + ); + return buildVueSpecParts({ + vueCommand: vueResolution.command, + vueArgs: vueResolution.args, + vueLanguageServerLocation, + typescriptCommand: "", + typescriptArgs: [], + bridgeMode: "off", + }); + } + + return buildVueSpecParts({ + vueCommand: vueResolution.command, + vueArgs: vueResolution.args, + vueLanguageServerLocation, + typescriptCommand: tsResolution.command, + typescriptArgs: tsResolution.args, + bridgeMode: "auto", + }); + } + private async getSessionForPath( workspaceId: string, path: string diff --git a/packages/server/src/lsp/server-factory.test.ts b/packages/server/src/lsp/server-factory.test.ts index 1221c392..edaf1a49 100644 --- a/packages/server/src/lsp/server-factory.test.ts +++ b/packages/server/src/lsp/server-factory.test.ts @@ -23,6 +23,10 @@ describe("resolveLspServerKind", () => { expect(resolveLspServerKind("src/a.jsx")).toBe("typescript"); }); + it("maps vue files to the vue server kind", () => { + expect(resolveLspServerKind("src/App.vue")).toBe("vue"); + }); + it("returns null for unsupported languages", () => { expect(resolveLspServerKind("assets/logo.svg")).toBeNull(); }); diff --git a/packages/server/src/lsp/server-factory.ts b/packages/server/src/lsp/server-factory.ts index e93dac9e..ec816c21 100644 --- a/packages/server/src/lsp/server-factory.ts +++ b/packages/server/src/lsp/server-factory.ts @@ -1,10 +1,36 @@ import type { LspServerKind, Workspace } from "@coder-studio/core"; +/** + * A secondary LSP process started alongside the primary one. + * + * Currently only used for Vue: Volar 3.x removed its embedded TypeScript + * service and now relies on the LSP client to relay `tsserver/request` + * notifications to a TypeScript Language Server with `@vue/typescript-plugin` + * loaded. The companion is that TS server. + */ +export interface LspCompanionSpec { + command: string; + args: string[]; + initializationOptions?: unknown; +} + export interface LspServerSpec { serverKind: LspServerKind; command: string; args: string[]; rootPath: string; + initializationOptions?: unknown; + companion?: LspCompanionSpec; + bridges?: { + /** + * When true, `tsserver/request` notifications received on the primary + * connection are forwarded to the companion via + * `workspace/executeCommand("typescript.tsserverRequest", ...)`, and the + * response is sent back via a `tsserver/response` notification. Required + * for Volar 3.x to answer hover/definition/quickinfo requests. + */ + tsserverRequest?: boolean; + }; } const TYPESCRIPT_EXTENSIONS = new Set([ @@ -20,6 +46,7 @@ const TYPESCRIPT_EXTENSIONS = new Set([ const PYTHON_EXTENSIONS = new Set([".py"]); const GO_EXTENSIONS = new Set([".go"]); const RUST_EXTENSIONS = new Set([".rs"]); +const VUE_EXTENSIONS = new Set([".vue"]); export function resolveLspServerKind(path: string): LspServerKind | null { const extension = path.slice(path.lastIndexOf(".")).toLowerCase(); @@ -40,6 +67,10 @@ export function resolveLspServerKind(path: string): LspServerKind | null { return "rust"; } + if (VUE_EXTENSIONS.has(extension)) { + return "vue"; + } + return null; } @@ -49,6 +80,9 @@ export function wrapLspCommandForWorkspace(spec: { command: string; args: string[]; rootPath: string; + initializationOptions?: unknown; + companion?: LspCompanionSpec; + bridges?: LspServerSpec["bridges"]; }): LspServerSpec { if (spec.workspace.targetRuntime !== "wsl") { return { @@ -56,18 +90,36 @@ export function wrapLspCommandForWorkspace(spec: { command: spec.command, args: spec.args, rootPath: spec.rootPath, + initializationOptions: spec.initializationOptions, + companion: spec.companion, + bridges: spec.bridges, }; } - return { - serverKind: spec.serverKind, + const wrapWithWsl = (command: string, args: string[]): { command: string; args: string[] } => ({ command: "wsl", args: [ ...(spec.workspace.wslDistro ? ["-d", spec.workspace.wslDistro] : []), "--", - spec.command, - ...spec.args, + command, + ...args, ], + }); + + const primary = wrapWithWsl(spec.command, spec.args); + + return { + serverKind: spec.serverKind, + command: primary.command, + args: primary.args, rootPath: spec.rootPath, + initializationOptions: spec.initializationOptions, + companion: spec.companion + ? { + ...wrapWithWsl(spec.companion.command, spec.companion.args), + initializationOptions: spec.companion.initializationOptions, + } + : undefined, + bridges: spec.bridges, }; } diff --git a/packages/server/src/lsp/session.test.ts b/packages/server/src/lsp/session.test.ts index 30d83e19..2986a747 100644 --- a/packages/server/src/lsp/session.test.ts +++ b/packages/server/src/lsp/session.test.ts @@ -1,7 +1,10 @@ import { join } from "node:path"; +import { LSP_SEMANTIC_TOKEN_MODIFIERS, LSP_SEMANTIC_TOKEN_TYPES } from "@coder-studio/core"; import { describe, expect, it, vi } from "vitest"; import { LspSession } from "./session.js"; +const FAKE_LSP = join(process.cwd(), "src/__tests__/fixtures/fake-lsp-server.js"); + describe.sequential("LspSession", () => { it("coalesces concurrent start calls until initialization completes", async () => { const session = new LspSession({ @@ -51,7 +54,8 @@ describe.sequential("LspSession", () => { }, }); - await session.start(); + const summary = await session.start(); + expect(summary.capabilities.semanticTokens).toBe(true); await session.openDocument({ path: "e2e/fixtures/lsp-workspace/broken.ts", languageId: "typescript", @@ -100,6 +104,21 @@ describe.sequential("LspSession", () => { expect(symbols?.[0]?.name).toBe("sharedValue"); + const semanticTokens = await session.semanticTokens({ + path: "e2e/fixtures/lsp-workspace/shared.ts", + }); + + expect(semanticTokens).toEqual({ + resultId: "semantic-1", + data: [ + 0, + 13, + 11, + LSP_SEMANTIC_TOKEN_TYPES.indexOf("variable"), + 1 << LSP_SEMANTIC_TOKEN_MODIFIERS.indexOf("declaration"), + ], + }); + expect(diagnostics).toHaveBeenCalledWith( expect.objectContaining({ workspaceId: "ws-1", @@ -390,6 +409,289 @@ describe.sequential("LspSession", () => { } }); + it("fans hover requests out to the companion and merges contents from both ends", async () => { + // Two fake-lsp processes: primary returns one hover string, companion + // returns another. The session should merge both into one hover payload + // so users see Vue-specific *and* TS-semantic information together. + const session = new LspSession({ + workspaceId: "ws-1", + workspacePath: process.cwd(), + spec: { + serverKind: "vue", + command: "node", + args: [FAKE_LSP], + rootPath: process.cwd(), + companion: { + command: "node", + args: [FAKE_LSP], + }, + bridges: { tsserverRequest: true }, + }, + onDiagnostics: vi.fn(), + requestTimeoutMs: 2000, + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + }); + + await session.openDocument({ + path: "e2e/fixtures/lsp-workspace/shared.ts", + languageId: "vue", + text: "export const sharedValue = 1;\n", + }); + + const hover = await session.hover({ + path: "e2e/fixtures/lsp-workspace/shared.ts", + line: 1, + column: 16, + }); + + expect(hover?.contents).toEqual([ + // Same content twice because both legs return the same fake hover. + // The merge step preserves both entries — proof that the companion + // result was actually consulted. + expect.stringContaining("sharedValue"), + expect.stringContaining("sharedValue"), + ]); + + await session.stop(); + }); + + it("fans definition requests out to the companion and deduplicates merged locations", async () => { + const session = new LspSession({ + workspaceId: "ws-1", + workspacePath: process.cwd(), + spec: { + serverKind: "vue", + command: "node", + args: [FAKE_LSP], + rootPath: process.cwd(), + companion: { + command: "node", + args: [FAKE_LSP], + }, + bridges: { tsserverRequest: true }, + }, + onDiagnostics: vi.fn(), + requestTimeoutMs: 2000, + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + }); + + await session.openDocument({ + path: "e2e/fixtures/lsp-workspace/shared.ts", + languageId: "vue", + text: "export const sharedValue = 1;\n", + }); + await session.openDocument({ + path: "e2e/fixtures/lsp-workspace/consumer.ts", + languageId: "vue", + text: 'import { sharedValue } from "./shared";\nexport const computedValue = sharedValue + 1;\n', + }); + + const definition = await session.definition({ + path: "e2e/fixtures/lsp-workspace/consumer.ts", + line: 1, + column: 12, + }); + + // Both fake servers return the same single location; merge+dedupe yields one. + expect(definition).toHaveLength(1); + expect(definition?.[0]?.path).toBe("e2e/fixtures/lsp-workspace/shared.ts"); + + await session.stop(); + }); + + it("kills the companion process when the primary exits", async () => { + // If Volar crashes we must not leave the TypeScript companion alive + // (otherwise idle-TTL cleanup leaks a process per session). + const session = new LspSession({ + workspaceId: "ws-1", + workspacePath: process.cwd(), + spec: { + serverKind: "vue", + // Primary exits 150ms after initialize. + command: "node", + args: [FAKE_LSP, "--exit-after-init-ms=150"], + rootPath: process.cwd(), + companion: { + // Companion stays alive normally. + command: "node", + args: [FAKE_LSP], + }, + bridges: { tsserverRequest: true }, + }, + onDiagnostics: vi.fn(), + requestTimeoutMs: 2000, + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + }); + + // Pull the companion field via a typed accessor for inspection. + type WithCompanion = LspSession & { + companion: null | { child: { killed: boolean } }; + }; + + await session.start(); + // Companion was spawned alongside primary. + expect((session as WithCompanion).companion).not.toBeNull(); + const companionChild = (session as WithCompanion).companion?.child; + expect(companionChild).toBeDefined(); + + // Wait long enough for the primary to exit and the termination handler + // to fire. + await vi.waitFor( + () => { + expect((session as WithCompanion).companion).toBeNull(); + }, + { timeout: 2000 } + ); + // The companion's process should have received SIGTERM. + expect(companionChild?.killed).toBe(true); + expect(session.getSummary().status).toBe("stopped"); + + await session.stop(); + }); + + it("stops the companion when the session is explicitly stopped", async () => { + const session = new LspSession({ + workspaceId: "ws-1", + workspacePath: process.cwd(), + spec: { + serverKind: "vue", + command: "node", + args: [FAKE_LSP], + rootPath: process.cwd(), + companion: { + command: "node", + args: [FAKE_LSP], + }, + bridges: { tsserverRequest: true }, + }, + onDiagnostics: vi.fn(), + requestTimeoutMs: 2000, + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + }); + + type WithCompanion = LspSession & { + companion: null | { child: { killed: boolean } }; + }; + + await session.start(); + const companionChild = (session as WithCompanion).companion?.child; + expect(companionChild).toBeDefined(); + + await session.stop(); + expect(companionChild?.killed).toBe(true); + expect((session as WithCompanion).companion).toBeNull(); + }); + + it("uses initializeTimeoutMs (not requestTimeoutMs) for the LSP initialize handshake", async () => { + // Regression test: rust-analyzer's `initialize` routinely takes 10-30s in + // real projects, but per-request semantic queries should still fail fast. + // The session must wait the longer ceiling for initialize and the short + // one for hover/definition. Here we simulate a 350ms initialize and a + // 1000ms hover; with a request timeout of 200ms the initialize must + // still succeed and the hover must still time out. + const previousInit = process.env.CODER_STUDIO_FAKE_LSP_INIT_DELAY_MS; + const previousHover = process.env.CODER_STUDIO_FAKE_LSP_HOVER_DELAY_MS; + process.env.CODER_STUDIO_FAKE_LSP_INIT_DELAY_MS = "350"; + process.env.CODER_STUDIO_FAKE_LSP_HOVER_DELAY_MS = "1000"; + + try { + const session = new LspSession({ + workspaceId: "ws-1", + workspacePath: process.cwd(), + spec: { + serverKind: "rust", + command: "node", + args: [FAKE_LSP], + rootPath: process.cwd(), + }, + onDiagnostics: vi.fn(), + requestTimeoutMs: 200, + initializeTimeoutMs: 5_000, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }); + + // Initialize takes 350ms but the longer timeout allows it. + await expect(session.start()).resolves.toMatchObject({ status: "ready" }); + + await session.openDocument({ + path: "e2e/fixtures/lsp-workspace/shared.ts", + languageId: "rust", + text: "export const sharedValue = 1;\n", + }); + + // Hover takes 1000ms which exceeds requestTimeoutMs of 200ms — must + // still time out and recover so the next query can succeed. + await expect( + session.hover({ + path: "e2e/fixtures/lsp-workspace/shared.ts", + line: 1, + column: 16, + }) + ).resolves.toBeNull(); + + await session.stop(); + } finally { + if (previousInit === undefined) { + delete process.env.CODER_STUDIO_FAKE_LSP_INIT_DELAY_MS; + } else { + process.env.CODER_STUDIO_FAKE_LSP_INIT_DELAY_MS = previousInit; + } + if (previousHover === undefined) { + delete process.env.CODER_STUDIO_FAKE_LSP_HOVER_DELAY_MS; + } else { + process.env.CODER_STUDIO_FAKE_LSP_HOVER_DELAY_MS = previousHover; + } + } + }); + + it("falls back to requestTimeoutMs * 10 for initialize when initializeTimeoutMs is omitted", async () => { + const previousInit = process.env.CODER_STUDIO_FAKE_LSP_INIT_DELAY_MS; + // 350ms init delay must succeed when requestTimeoutMs is 100 (so the + // implicit init budget is 1000ms). + process.env.CODER_STUDIO_FAKE_LSP_INIT_DELAY_MS = "350"; + + try { + const session = new LspSession({ + workspaceId: "ws-1", + workspacePath: process.cwd(), + spec: { + serverKind: "rust", + command: "node", + args: [FAKE_LSP], + rootPath: process.cwd(), + }, + onDiagnostics: vi.fn(), + requestTimeoutMs: 100, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }); + + await expect(session.start()).resolves.toMatchObject({ status: "ready" }); + await session.stop(); + } finally { + if (previousInit === undefined) { + delete process.env.CODER_STUDIO_FAKE_LSP_INIT_DELAY_MS; + } else { + process.env.CODER_STUDIO_FAKE_LSP_INIT_DELAY_MS = previousInit; + } + } + }); + it("drains child stderr output without breaking startup", async () => { const previous = process.env.CODER_STUDIO_FAKE_LSP_STDERR_ON_INIT; process.env.CODER_STUDIO_FAKE_LSP_STDERR_ON_INIT = "server boot log"; diff --git a/packages/server/src/lsp/session.ts b/packages/server/src/lsp/session.ts index 26bca88e..08d49327 100644 --- a/packages/server/src/lsp/session.ts +++ b/packages/server/src/lsp/session.ts @@ -1,18 +1,23 @@ import { type ChildProcess, spawn } from "node:child_process"; import { createRequire } from "node:module"; import { pathToFileURL } from "node:url"; -import type { - LspDiagnostic, - LspDiagnosticsEvent, - LspDocumentSymbol, - LspHoverResult, - LspLocation, - LspRange, - LspServerKind, - LspSessionSummary, +import { + LSP_SEMANTIC_TOKEN_MODIFIERS, + LSP_SEMANTIC_TOKEN_TYPES, + type LspDiagnostic, + type LspDiagnosticsEvent, + type LspDocumentSymbol, + type LspHoverResult, + type LspLocation, + type LspRange, + type LspSemanticTokens, + type LspSessionSummary, } from "@coder-studio/core"; +import { shouldUseShellForCommand } from "@coder-studio/utils"; import { type MessageConnection, NotificationType, RequestType } from "vscode-jsonrpc"; import { DocumentStore } from "./document-store.js"; +import type { LspServerSpec } from "./server-factory.js"; +import { type BridgeHandle, bridgeTsserverRequests } from "./tsserver-bridge.js"; const require = createRequire(import.meta.url); type VscodeJsonrpcNode = typeof import("vscode-jsonrpc/node"); @@ -34,24 +39,90 @@ const HoverRequest = new RequestType("textDocumen const DocumentSymbolsRequest = new RequestType( "textDocument/documentSymbol" ); +const SemanticTokensRequest = new RequestType( + "textDocument/semanticTokens/full" +); const LSP_REQUEST_TIMEOUT_MESSAGE = "LSP request timed out"; +const LSP_CLIENT_CAPABILITIES = { + textDocument: { + semanticTokens: { + dynamicRegistration: false, + requests: { + range: false, + full: true, + }, + tokenTypes: LSP_SEMANTIC_TOKEN_TYPES, + tokenModifiers: LSP_SEMANTIC_TOKEN_MODIFIERS, + formats: ["relative"], + overlappingTokenSupport: false, + multilineTokenSupport: true, + serverCancelSupport: false, + augmentsSyntaxTokens: true, + }, + }, +}; + +const SEMANTIC_TOKEN_TYPE_INDEX = new Map( + LSP_SEMANTIC_TOKEN_TYPES.map((type, index) => [type, index] as [string, number]) +); +const SEMANTIC_TOKEN_MODIFIER_INDEX = new Map( + LSP_SEMANTIC_TOKEN_MODIFIERS.map((modifier, index) => [modifier, index] as [string, number]) +); +const SEMANTIC_TOKEN_TYPE_ALIASES: Record = { + namespace: "variable", + class: "type", + enum: "type", + interface: "type", + struct: "type", + typeParameter: "type", + typeAlias: "type", + builtinType: "type", + generic: "type", + lifetime: "type", + parameter: "variable", + property: "variable", + enumMember: "variable", + event: "variable", + function: "variable", + method: "variable", + macro: "variable", + decorator: "variable", + attribute: "variable", + label: "variable", + unresolvedReference: "variable", + selfKeyword: "keyword", + builtinAttribute: "keyword", + boolean: "number", + escapeSequence: "string", + formatSpecifier: "string", +}; + interface SessionDeps { workspaceId: string; workspacePath: string; - spec: { - serverKind: LspServerKind; - command: string; - args: string[]; - rootPath: string; - }; + spec: LspServerSpec; onDiagnostics: (event: LspDiagnosticsEvent) => void; + /** + * Per-request timeout for semantic queries (hover, definition, references, + * documentSymbols, …). Keep this short enough that the editor's hover + * popup doesn't show "loading" forever when the server is wedged. + */ requestTimeoutMs: number; + /** + * Timeout for the LSP `initialize` request. Some servers (notably + * rust-analyzer) need to scan Cargo workspaces and load proc-macros on + * first boot, which can routinely take 20s+ in real projects. Defaults + * to `requestTimeoutMs * 10` so existing callers don't regress. + */ + initializeTimeoutMs?: number; + platform?: NodeJS.Platform; logger: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void; }; + spawnProcess?: typeof spawn; } interface TextDocumentParams { @@ -106,17 +177,44 @@ interface SymbolInformationLike { location: LocationLike; } +interface SemanticTokensLegendLike { + tokenTypes: string[]; + tokenModifiers: string[]; +} + +interface SemanticTokensProviderLike { + legend?: { + tokenTypes?: unknown; + tokenModifiers?: unknown; + }; + full?: boolean | Record; +} + +interface SemanticTokensLike { + resultId?: string; + data?: unknown; +} + interface RangeLike { start: { line: number; character: number }; end: { line: number; character: number }; } +interface ChildLink { + child: ChildProcess; + connection: MessageConnection; + label: "primary" | "companion"; +} + export class LspSession { private readonly documents: DocumentStore; private child: ChildProcess | null = null; private connection: MessageConnection | null = null; + private companion: ChildLink | null = null; + private bridgeHandle: BridgeHandle | null = null; private startPromise: Promise | null = null; private summary: LspSessionSummary; + private semanticTokensLegend: SemanticTokensLegendLike | null = null; constructor(private readonly deps: SessionDeps) { this.documents = new DocumentStore(deps.workspacePath); @@ -131,6 +229,7 @@ export class LspSession { references: false, hover: false, documentSymbols: false, + semanticTokens: false, diagnostics: true, }, }; @@ -163,9 +262,13 @@ export class LspSession { } private async startConnection(): Promise { - const child = spawn(this.deps.spec.command, this.deps.spec.args, { + const platform = this.deps.platform ?? process.platform; + const spawnProcess = this.deps.spawnProcess ?? spawn; + const child = spawnProcess(this.deps.spec.command, this.deps.spec.args, { cwd: this.deps.spec.rootPath, stdio: ["pipe", "pipe", "pipe"], + shell: shouldUseShellForCommand(this.deps.spec.command, platform), + windowsHide: true, }); this.child = child; @@ -173,20 +276,7 @@ export class LspSession { throw new Error("Failed to start LSP process stdio"); } - child.stderr?.on("data", (chunk) => { - const message = chunk.toString().trim(); - if (!message) { - return; - } - - this.deps.logger.warn( - { - serverKind: this.deps.spec.serverKind, - stderr: message, - }, - "lsp child stderr" - ); - }); + this.attachStderr(child, "primary"); child.once("exit", () => { this.handleChildTermination(child); @@ -220,56 +310,91 @@ export class LspSession { this.connection.listen(); - try { - const initializeResult = await this.withTimeout( - this.connection.sendRequest("initialize", { - processId: process.pid, - rootUri: pathToFileURL(this.deps.spec.rootPath).toString(), - capabilities: {}, - }) + let companion: ChildLink | null = null; + if (this.deps.spec.companion) { + try { + companion = this.startCompanion(spawnProcess, platform); + } catch (error) { + child.kill("SIGTERM"); + throw error; + } + this.companion = companion; + } + + if (companion && this.deps.spec.bridges?.tsserverRequest) { + this.bridgeHandle = bridgeTsserverRequests( + { + primary: this.connection, + companion: companion.connection, + }, + { + timeoutMs: this.deps.requestTimeoutMs, + logger: this.deps.logger, + } ); + } + + try { + const initTimeoutMs = this.deps.initializeTimeoutMs ?? this.deps.requestTimeoutMs * 10; + const [initializeResult] = await Promise.all([ + this.withTimeout( + this.connection.sendRequest("initialize", { + processId: process.pid, + rootUri: pathToFileURL(this.deps.spec.rootPath).toString(), + capabilities: LSP_CLIENT_CAPABILITIES, + initializationOptions: this.deps.spec.initializationOptions, + }), + initTimeoutMs + ), + companion + ? this.withTimeout( + companion.connection.sendRequest("initialize", { + processId: process.pid, + rootUri: pathToFileURL(this.deps.spec.rootPath).toString(), + capabilities: LSP_CLIENT_CAPABILITIES, + initializationOptions: this.deps.spec.companion?.initializationOptions, + }), + initTimeoutMs + ) + : Promise.resolve(null), + ]); this.sendNotification("initialized", {}); + if (companion) { + this.sendNotificationOn(companion.connection, "initialized", {}); + } for (const doc of this.documents.listReplayable()) { - this.sendNotification("textDocument/didOpen", { + const didOpenParams = { textDocument: { uri: doc.uri, languageId: doc.languageId, version: doc.version, text: doc.text, }, - }); + }; + this.sendNotification("textDocument/didOpen", didOpenParams); + if (companion) { + this.sendNotificationOn(companion.connection, "textDocument/didOpen", didOpenParams); + } } + const capabilities = + (initializeResult as { capabilities?: Record }).capabilities ?? {}; + const semanticTokensLegend = toSemanticTokensLegend(capabilities.semanticTokensProvider); + this.semanticTokensLegend = semanticTokensLegend; + this.summary = { ...this.summary, status: "ready", capabilities: { - definition: Boolean( - (initializeResult as { capabilities?: Record }).capabilities - ?.definitionProvider - ), - declaration: Boolean( - (initializeResult as { capabilities?: Record }).capabilities - ?.declarationProvider - ), - typeDefinition: Boolean( - (initializeResult as { capabilities?: Record }).capabilities - ?.typeDefinitionProvider - ), - references: Boolean( - (initializeResult as { capabilities?: Record }).capabilities - ?.referencesProvider - ), - hover: Boolean( - (initializeResult as { capabilities?: Record }).capabilities - ?.hoverProvider - ), - documentSymbols: Boolean( - (initializeResult as { capabilities?: Record }).capabilities - ?.documentSymbolProvider - ), + definition: Boolean(capabilities.definitionProvider), + declaration: Boolean(capabilities.declarationProvider), + typeDefinition: Boolean(capabilities.typeDefinitionProvider), + references: Boolean(capabilities.referencesProvider), + hover: Boolean(capabilities.hoverProvider), + documentSymbols: Boolean(capabilities.documentSymbolProvider), + semanticTokens: Boolean(semanticTokensLegend), diagnostics: true, }, }; @@ -284,14 +409,15 @@ export class LspSession { async openDocument(input: { path: string; languageId: string; text: string }): Promise { await this.start(); const doc = this.documents.open(input); - this.sendNotification("textDocument/didOpen", { + const params = { textDocument: { uri: doc.uri, languageId: doc.languageId, version: doc.version, text: doc.text, }, - }); + }; + this.broadcastNotification("textDocument/didOpen", params); return doc.version; } @@ -302,7 +428,7 @@ export class LspSession { await this.start(); const doc = this.documents.change(path, text); - this.sendNotification("textDocument/didChange", { + this.broadcastNotification("textDocument/didChange", { textDocument: { uri: doc.uri, version: doc.version, @@ -323,7 +449,7 @@ export class LspSession { return; } - this.sendNotification("textDocument/didClose", { + this.broadcastNotification("textDocument/didClose", { textDocument: { uri: doc.uri }, }); this.documents.close(path); @@ -334,18 +460,7 @@ export class LspSession { line: number; column: number; }): Promise { - if (!this.documents.get(input.path)) { - return null; - } - - try { - await this.start(); - return (await this.requestLocations(DefinitionRequest, input)) ?? []; - } catch (error) { - this.recoverFromRequestFailure(error); - this.deps.logger.warn({ error }, "lsp definition request failed"); - return []; - } + return this.locationsAcrossConnections(DefinitionRequest, "definition", input); } async references(input: { @@ -353,18 +468,7 @@ export class LspSession { line: number; column: number; }): Promise { - if (!this.documents.get(input.path)) { - return null; - } - - try { - await this.start(); - return (await this.requestLocations(ReferencesRequest, input)) ?? []; - } catch (error) { - this.recoverFromRequestFailure(error); - this.deps.logger.warn({ error }, "lsp references request failed"); - return []; - } + return this.locationsAcrossConnections(ReferencesRequest, "references", input); } async declaration(input: { @@ -372,18 +476,7 @@ export class LspSession { line: number; column: number; }): Promise { - if (!this.documents.get(input.path)) { - return null; - } - - try { - await this.start(); - return (await this.requestLocations(DeclarationRequest, input)) ?? []; - } catch (error) { - this.recoverFromRequestFailure(error); - this.deps.logger.warn({ error }, "lsp declaration request failed"); - return []; - } + return this.locationsAcrossConnections(DeclarationRequest, "declaration", input); } async typeDefinition(input: { @@ -391,18 +484,7 @@ export class LspSession { line: number; column: number; }): Promise { - if (!this.documents.get(input.path)) { - return null; - } - - try { - await this.start(); - return (await this.requestLocations(TypeDefinitionRequest, input)) ?? []; - } catch (error) { - this.recoverFromRequestFailure(error); - this.deps.logger.warn({ error }, "lsp type definition request failed"); - return []; - } + return this.locationsAcrossConnections(TypeDefinitionRequest, "type definition", input); } async hover(input: { @@ -421,28 +503,80 @@ export class LspSession { return null; } - const result = await this.withTimeout( - this.connection.sendRequest(HoverRequest, { - textDocument: { uri: doc.uri }, - position: { line: input.line - 1, character: input.column - 1 }, - }) + // Volar 3.x intentionally delegates TS-semantic hover to a TypeScript + // server that has @vue/typescript-plugin loaded. Volar itself only + // returns Vue-specific hovers (template / directives / SFC structure). + // Fan out to both ends when a companion is configured, then merge. + const params = { + textDocument: { uri: doc.uri }, + position: { line: input.line - 1, character: input.column - 1 }, + }; + + const targets: Array<{ + label: "primary" | "companion"; + connection: MessageConnection; + }> = [{ label: "primary", connection: this.connection }]; + if (this.companion) { + targets.push({ label: "companion", connection: this.companion.connection }); + } + + const results = await Promise.allSettled( + targets.map(({ connection }) => + this.withTimeout(connection.sendRequest(HoverRequest, params)) + ) ); - if (!result) { - return null; + const hovers: Array<{ contents: string[]; range?: LspRange }> = []; + let timedOut = false; + results.forEach((res, idx) => { + if (res.status === "rejected") { + if (res.reason instanceof LspRequestTimeoutError) { + timedOut = true; + } + this.deps.logger.warn( + { error: res.reason, source: targets[idx]?.label }, + "lsp hover request failed" + ); + return; + } + const value = res.value; + if (!value) { + return; + } + const contents = toHoverContents((value as { contents?: unknown }).contents); + const rawRange = + typeof value === "object" && + value !== null && + "range" in value && + (value as { range?: LocationLike["range"] }).range + ? (value as { range: LocationLike["range"] }).range + : null; + hovers.push({ + contents, + range: rawRange ? toSharedRange(rawRange) : undefined, + }); + }); + + if (timedOut) { + // Stay defensive: if any leg timed out we already restarted the session + // via recoverFromRequestFailure for that leg. + this.recoverFromRequestFailure(new LspRequestTimeoutError()); } - return { - contents: toHoverContents((result as { contents?: unknown }).contents), - range: - typeof result === "object" && - result !== null && - "range" in result && - (result as { range?: LocationLike["range"] }).range - ? toSharedRange((result as { range: LocationLike["range"] }).range) - : undefined, - version: doc.version, - }; + if (hovers.length === 0) { + return null; + } + // Prefer the companion (TS-server) range when present since it's + // expressed against the SFC source positions, then fall back to whatever + // we have. + const mergedContents = hovers + .flatMap((entry) => entry.contents) + .filter((value) => value.length > 0); + const range = hovers.find((entry) => entry.range)?.range; + if (mergedContents.length === 0) { + return null; + } + return { contents: mergedContents, range, version: doc.version }; } catch (error) { this.recoverFromRequestFailure(error); this.deps.logger.warn({ error }, "lsp hover request failed"); @@ -480,10 +614,38 @@ export class LspSession { } } + async semanticTokens(input: { path: string }): Promise { + const doc = this.documents.get(input.path); + if (!doc) { + return null; + } + + try { + await this.start(); + if (!this.connection || !this.semanticTokensLegend) { + return { data: [] }; + } + + const result = await this.withTimeout( + this.connection.sendRequest(SemanticTokensRequest, { + textDocument: { uri: doc.uri }, + }) + ); + + return normalizeSemanticTokens(result, this.semanticTokensLegend); + } catch (error) { + this.recoverFromRequestFailure(error); + this.deps.logger.warn({ error }, "lsp semantic tokens request failed"); + return { data: [] }; + } + } + async stop(): Promise { const child = this.child; + const companionChild = this.companion?.child ?? null; this.resetConnectionState(); child?.kill("SIGTERM"); + companionChild?.kill("SIGTERM"); } getSummary(): LspSessionSummary { @@ -501,36 +663,85 @@ export class LspSession { return this.documents.listReplayable(); } - private async requestLocations( + private async locationsAcrossConnections( type: RequestType, + label: string, input: { path: string; line: number; column: number } ): Promise { + if (!this.documents.get(input.path)) { + return null; + } + + try { + await this.start(); + } catch (error) { + this.deps.logger.warn({ error, label }, `lsp ${label} start failed`); + return []; + } + const doc = this.documents.get(input.path); if (!doc || !this.connection) { - return null; + return []; } - const result = await this.withTimeout( - this.connection.sendRequest(type, { - textDocument: { uri: doc.uri }, - position: { line: input.line - 1, character: input.column - 1 }, - }) + const params = { + textDocument: { uri: doc.uri }, + position: { line: input.line - 1, character: input.column - 1 }, + }; + + const targets: Array<{ + label: "primary" | "companion"; + connection: MessageConnection; + }> = [{ label: "primary", connection: this.connection }]; + if (this.companion) { + targets.push({ label: "companion", connection: this.companion.connection }); + } + + const results = await Promise.allSettled( + targets.map(({ connection }) => this.withTimeout(connection.sendRequest(type, params))) ); - return normalizeLocations(result, this.documents); + const merged: LspLocation[] = []; + const seen = new Set(); + let timedOut = false; + results.forEach((res, idx) => { + if (res.status === "rejected") { + if (res.reason instanceof LspRequestTimeoutError) { + timedOut = true; + } + this.deps.logger.warn( + { error: res.reason, label, source: targets[idx]?.label }, + `lsp ${label} request failed` + ); + return; + } + const locations = normalizeLocations(res.value, this.documents) ?? []; + for (const location of locations) { + const key = `${location.path}:${location.range.startLine}:${location.range.startColumn}:${location.range.endLine}:${location.range.endColumn}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + merged.push(location); + } + }); + + if (timedOut) { + this.recoverFromRequestFailure(new LspRequestTimeoutError()); + } + + return merged; } - private async withTimeout(promise: Promise): Promise { + private async withTimeout(promise: Promise, overrideMs?: number): Promise { let timer: NodeJS.Timeout | undefined; + const timeoutMs = overrideMs ?? this.deps.requestTimeoutMs; try { return await Promise.race([ promise, new Promise((_, reject) => { - timer = setTimeout( - () => reject(new LspRequestTimeoutError()), - this.deps.requestTimeoutMs - ); + timer = setTimeout(() => reject(new LspRequestTimeoutError()), timeoutMs); }), ]); } finally { @@ -546,13 +757,79 @@ export class LspSession { return; } + this.sendNotificationOn(connection, method, params); + } + + private sendNotificationOn(connection: MessageConnection, method: string, params: unknown): void { void connection.sendNotification(method, params).catch((error) => { this.deps.logger.warn({ error, method }, "lsp notification send failed"); }); } + private broadcastNotification(method: string, params: unknown): void { + this.sendNotification(method, params); + const companion = this.companion; + if (companion) { + this.sendNotificationOn(companion.connection, method, params); + } + } + + private attachStderr(child: ChildProcess, label: ChildLink["label"]): void { + child.stderr?.on("data", (chunk) => { + const message = chunk.toString().trim(); + if (!message) { + return; + } + + this.deps.logger.warn( + { + serverKind: this.deps.spec.serverKind, + companion: label === "companion" ? true : undefined, + stderr: message, + }, + "lsp child stderr" + ); + }); + } + + private startCompanion(spawnProcess: typeof spawn, platform: NodeJS.Platform): ChildLink { + const companionSpec = this.deps.spec.companion; + if (!companionSpec) { + throw new Error("startCompanion called without companion spec"); + } + + const companionChild = spawnProcess(companionSpec.command, companionSpec.args, { + cwd: this.deps.spec.rootPath, + stdio: ["pipe", "pipe", "pipe"], + shell: shouldUseShellForCommand(companionSpec.command, platform), + windowsHide: true, + }); + + if (!companionChild.stdin || !companionChild.stdout) { + throw new Error("Failed to start LSP companion stdio"); + } + + this.attachStderr(companionChild, "companion"); + + companionChild.once("exit", () => { + this.handleChildTermination(companionChild); + }); + companionChild.once("error", (error) => { + this.deps.logger.error({ err: error }, "lsp companion process error"); + this.handleChildTermination(companionChild); + }); + + const companionConnection = createMessageConnection( + new StreamMessageReader(companionChild.stdout), + new StreamMessageWriter(companionChild.stdin) + ); + companionConnection.listen(); + + return { child: companionChild, connection: companionConnection, label: "companion" }; + } + private handleChildTermination(child: ChildProcess): void { - if (this.child !== child) { + if (this.child !== child && this.companion?.child !== child) { return; } @@ -560,8 +837,14 @@ export class LspSession { } private resetConnectionState(): void { + this.bridgeHandle?.dispose(); + this.bridgeHandle = null; this.connection = null; this.child = null; + this.semanticTokensLegend = null; + const companion = this.companion; + this.companion = null; + companion?.child.kill("SIGTERM"); this.summary = { ...this.summary, status: "stopped", @@ -574,8 +857,10 @@ export class LspSession { } const child = this.child; - child?.kill("SIGTERM"); + const companionChild = this.companion?.child ?? null; this.resetConnectionState(); + child?.kill("SIGTERM"); + companionChild?.kill("SIGTERM"); } } @@ -702,6 +987,95 @@ function toSharedSymbolEntry(input: unknown): LspDocumentSymbol | null { return null; } +function toSemanticTokensLegend(input: unknown): SemanticTokensLegendLike | null { + if (typeof input !== "object" || input === null) { + return null; + } + + const provider = input as SemanticTokensProviderLike; + if (!supportsFullSemanticTokens(provider.full)) { + return null; + } + + const tokenTypes = Array.isArray(provider.legend?.tokenTypes) + ? provider.legend.tokenTypes.filter((value): value is string => typeof value === "string") + : []; + if (tokenTypes.length === 0) { + return null; + } + + const tokenModifiers = Array.isArray(provider.legend?.tokenModifiers) + ? provider.legend.tokenModifiers.filter((value): value is string => typeof value === "string") + : []; + + return { tokenTypes, tokenModifiers }; +} + +function supportsFullSemanticTokens(value: unknown): boolean { + return value === true || (typeof value === "object" && value !== null); +} + +function normalizeSemanticTokens( + input: unknown, + legend: SemanticTokensLegendLike +): LspSemanticTokens { + if (typeof input !== "object" || input === null) { + return { data: [] }; + } + + const result = input as SemanticTokensLike; + const rawData = + Array.isArray(result.data) || result.data instanceof Uint32Array ? result.data : []; + const data: number[] = []; + + for (let index = 0; index + 4 < rawData.length; index += 5) { + const deltaLine = toNonNegativeInteger(rawData[index]) ?? 0; + const deltaStart = toNonNegativeInteger(rawData[index + 1]) ?? 0; + const length = toNonNegativeInteger(rawData[index + 2]) ?? 0; + const sourceTokenType = legend.tokenTypes[toNonNegativeInteger(rawData[index + 3]) ?? -1]; + const tokenType = toCanonicalSemanticTokenType(sourceTokenType); + const tokenModifiers = toCanonicalSemanticTokenModifiers( + toNonNegativeInteger(rawData[index + 4]) ?? 0, + legend + ); + + data.push(deltaLine, deltaStart, length, tokenType, tokenModifiers); + } + + return typeof result.resultId === "string" ? { resultId: result.resultId, data } : { data }; +} + +function toCanonicalSemanticTokenType(sourceType: string | undefined): number { + const targetType = sourceType + ? (SEMANTIC_TOKEN_TYPE_ALIASES[sourceType] ?? sourceType) + : "variable"; + return SEMANTIC_TOKEN_TYPE_INDEX.get(targetType) ?? SEMANTIC_TOKEN_TYPE_INDEX.get("variable")!; +} + +function toCanonicalSemanticTokenModifiers( + sourceBitset: number, + legend: SemanticTokensLegendLike +): number { + let bitset = 0; + + for (let index = 0; index < legend.tokenModifiers.length; index += 1) { + if (Math.floor(sourceBitset / 2 ** index) % 2 !== 1) { + continue; + } + + const targetIndex = SEMANTIC_TOKEN_MODIFIER_INDEX.get(legend.tokenModifiers[index]!); + if (targetIndex !== undefined) { + bitset += 2 ** targetIndex; + } + } + + return bitset; +} + +function toNonNegativeInteger(input: unknown): number | null { + return typeof input === "number" && Number.isInteger(input) && input >= 0 ? input : null; +} + function isRangeLike(input: unknown): input is RangeLike { return typeof input === "object" && input !== null && "start" in input && "end" in input; } diff --git a/packages/server/src/lsp/session.windows.test.ts b/packages/server/src/lsp/session.windows.test.ts new file mode 100644 index 00000000..3154224b --- /dev/null +++ b/packages/server/src/lsp/session.windows.test.ts @@ -0,0 +1,127 @@ +import { EventEmitter } from "node:events"; +import { PassThrough, Readable, Writable } from "node:stream"; +import { describe, expect, it, vi } from "vitest"; +import { LspSession } from "./session.js"; + +function createSpawnedChildMock() { + const child = new EventEmitter() as EventEmitter & { + stdin: null; + stdout: null; + stderr: null; + kill: ReturnType; + }; + child.stdin = null; + child.stdout = null; + child.stderr = null; + child.kill = vi.fn(); + return child; +} + +function createSpawnedChildWithStreams() { + const child = new EventEmitter() as EventEmitter & { + stdin: Writable; + stdout: Readable; + stderr: Readable; + kill: ReturnType; + }; + child.stdin = new PassThrough(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.kill = vi.fn(); + return child; +} + +describe("LspSession Windows launch", () => { + it("routes managed Vue cmd shims through a shell on Windows", async () => { + const spawnProcess = vi.fn(() => createSpawnedChildMock()); + const session = new LspSession({ + workspaceId: "ws-1", + workspacePath: "C:\\repo", + spec: { + serverKind: "vue", + command: "C:\\tools\\vue-language-server.cmd", + args: ["--stdio"], + rootPath: "C:\\repo", + }, + onDiagnostics: vi.fn(), + requestTimeoutMs: 1000, + platform: "win32", + spawnProcess, + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + }); + + await expect(session.start()).rejects.toThrow("Failed to start LSP process stdio"); + expect(spawnProcess).toHaveBeenCalledWith( + "C:\\tools\\vue-language-server.cmd", + ["--stdio"], + expect.objectContaining({ + cwd: "C:\\repo", + stdio: ["pipe", "pipe", "pipe"], + shell: true, + windowsHide: true, + }) + ); + }); + + it("spawns the companion typescript server alongside Volar when a companion spec is present", async () => { + const spawnProcess = vi.fn(() => createSpawnedChildWithStreams()); + const session = new LspSession({ + workspaceId: "ws-1", + workspacePath: "C:\\repo", + spec: { + serverKind: "vue", + command: "C:\\tools\\vue-language-server.cmd", + args: ["--stdio"], + rootPath: "C:\\repo", + companion: { + command: "C:\\node\\node.exe", + args: ["C:\\tools\\typescript-language-server\\lib\\cli.mjs", "--stdio"], + initializationOptions: { + plugins: [ + { + name: "@vue/typescript-plugin", + location: "C:\\tools\\@vue\\language-server", + languages: ["vue"], + }, + ], + }, + }, + bridges: { tsserverRequest: true }, + }, + onDiagnostics: vi.fn(), + requestTimeoutMs: 100, + platform: "win32", + spawnProcess, + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + }); + + // Both children get spawned with working pipes; neither will respond to + // `initialize`, so `start` rejects via the request timeout. The point of + // the test is that *both* spawn calls happened and the companion used the + // node + stdio launch shape. + await expect(session.start()).rejects.toThrow(); + expect(spawnProcess).toHaveBeenCalledTimes(2); + expect(spawnProcess).toHaveBeenNthCalledWith( + 1, + "C:\\tools\\vue-language-server.cmd", + ["--stdio"], + expect.objectContaining({ shell: true, cwd: "C:\\repo" }) + ); + expect(spawnProcess).toHaveBeenNthCalledWith( + 2, + "C:\\node\\node.exe", + ["C:\\tools\\typescript-language-server\\lib\\cli.mjs", "--stdio"], + expect.objectContaining({ cwd: "C:\\repo", stdio: ["pipe", "pipe", "pipe"] }) + ); + + await session.stop(); + }); +}); diff --git a/packages/server/src/lsp/tsserver-bridge.test.ts b/packages/server/src/lsp/tsserver-bridge.test.ts new file mode 100644 index 00000000..25101d5e --- /dev/null +++ b/packages/server/src/lsp/tsserver-bridge.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Disposable } from "vscode-jsonrpc"; +import { bridgeTsserverRequests, unwrapTsserverResponse } from "./tsserver-bridge.js"; + +type NotificationHandler = (payload: unknown) => void; + +interface FakePrimary { + onNotification: ReturnType; + sendNotification: ReturnType; + emit: (method: string, payload: unknown) => void; + notifications: Array<{ method: string; payload: unknown }>; +} + +function createFakePrimary(): FakePrimary { + const handlers = new Map(); + const notifications: FakePrimary["notifications"] = []; + const fake: FakePrimary = { + onNotification: vi.fn((method: string, handler: NotificationHandler): Disposable => { + handlers.set(method, handler); + return { dispose: () => handlers.delete(method) }; + }), + sendNotification: vi.fn(async (method: string, payload: unknown) => { + notifications.push({ method, payload }); + }), + emit(method, payload) { + handlers.get(method)?.(payload); + }, + notifications, + }; + return fake; +} + +describe("bridgeTsserverRequests", () => { + it("forwards tsserver/request payloads, unwraps the tsserver body, and replies with tsserver/response", async () => { + const primary = createFakePrimary(); + // typescript-language-server returns the raw tsserver wire response; we + // must strip the wrapper before forwarding so Volar sees the inner body. + const sendRequest = vi.fn(async () => ({ + seq: 0, + type: "response", + command: "quickinfo", + request_seq: 4, + success: true, + body: { displayString: "const sharedValue: number" }, + })); + const handle = bridgeTsserverRequests( + { primary, companion: { sendRequest } }, + { timeoutMs: 1000, logger: { warn: vi.fn() } } + ); + + primary.emit("tsserver/request", [42, "quickinfo", { file: "App.vue", line: 1, offset: 1 }]); + + await vi.waitFor(() => { + expect(primary.notifications).toHaveLength(1); + }); + + expect(sendRequest).toHaveBeenCalledWith("workspace/executeCommand", { + command: "typescript.tsserverRequest", + arguments: ["quickinfo", { file: "App.vue", line: 1, offset: 1 }], + }); + expect(primary.notifications[0]).toEqual({ + method: "tsserver/response", + payload: [42, { displayString: "const sharedValue: number" }], + }); + handle.dispose(); + }); + + it("returns null to Volar when the tsserver wrapper reports success=false", async () => { + const primary = createFakePrimary(); + const sendRequest = vi.fn(async () => ({ + seq: 0, + type: "response", + command: "quickinfo", + request_seq: 4, + success: false, + message: "Cannot find file", + })); + bridgeTsserverRequests( + { primary, companion: { sendRequest } }, + { timeoutMs: 1000, logger: { warn: vi.fn() } } + ); + + primary.emit("tsserver/request", [1, "quickinfo", {}]); + + await vi.waitFor(() => { + expect(primary.notifications).toHaveLength(1); + }); + expect(primary.notifications[0]).toEqual({ + method: "tsserver/response", + payload: [1, null], + }); + }); + + it("replies with null when the companion rejects, so vue stops waiting", async () => { + const primary = createFakePrimary(); + const sendRequest = vi.fn(async () => { + throw new Error("companion exploded"); + }); + const warn = vi.fn(); + bridgeTsserverRequests( + { primary, companion: { sendRequest } }, + { timeoutMs: 1000, logger: { warn } } + ); + + primary.emit("tsserver/request", [7, "quickinfo", {}]); + + await vi.waitFor(() => { + expect(primary.notifications).toHaveLength(1); + }); + expect(primary.notifications[0]).toEqual({ + method: "tsserver/response", + payload: [7, null], + }); + expect(warn).toHaveBeenCalled(); + }); + + it("times out instead of letting volar hang forever", async () => { + const primary = createFakePrimary(); + // Never resolves; bridge must time out. + const sendRequest = vi.fn(() => new Promise(() => {})); + const warn = vi.fn(); + let scheduledHandler: (() => void) | null = null; + const scheduler = { + setTimeout: vi.fn((handler: () => void) => { + scheduledHandler = handler; + return Symbol("timer"); + }), + clearTimeout: vi.fn(), + }; + bridgeTsserverRequests( + { primary, companion: { sendRequest } }, + { timeoutMs: 25, logger: { warn }, scheduler } + ); + + primary.emit("tsserver/request", [1, "definition", {}]); + expect(scheduler.setTimeout).toHaveBeenCalled(); + + // Fire the timeout deterministically. + scheduledHandler?.(); + + await vi.waitFor(() => { + expect(primary.notifications).toHaveLength(1); + }); + expect(primary.notifications[0]).toEqual({ + method: "tsserver/response", + payload: [1, null], + }); + expect(warn).toHaveBeenCalled(); + }); + + it("ignores malformed payloads instead of crashing", async () => { + const primary = createFakePrimary(); + const sendRequest = vi.fn(); + const warn = vi.fn(); + bridgeTsserverRequests( + { primary, companion: { sendRequest } }, + { timeoutMs: 1000, logger: { warn } } + ); + + primary.emit("tsserver/request", "garbage"); + primary.emit("tsserver/request", [1]); // missing command + primary.emit("tsserver/request", [null, "x"]); // bad id type + + expect(sendRequest).not.toHaveBeenCalled(); + expect(primary.notifications).toHaveLength(0); + expect(warn).toHaveBeenCalledTimes(3); + }); + + it("passes through plain (non-wrapped) results without modification", async () => { + // Some commands or pre-v4.4 servers may return the body directly; tolerate + // that by leaving the value alone. + const primary = createFakePrimary(); + const sendRequest = vi.fn(async () => ({ displayString: "string" })); + bridgeTsserverRequests( + { primary, companion: { sendRequest } }, + { timeoutMs: 1000, logger: { warn: vi.fn() } } + ); + + primary.emit("tsserver/request", [3, "quickinfo", {}]); + await vi.waitFor(() => { + expect(primary.notifications).toHaveLength(1); + }); + expect(primary.notifications[0]).toEqual({ + method: "tsserver/response", + payload: [3, { displayString: "string" }], + }); + }); + + it("stops forwarding after dispose, even if responses arrive late", async () => { + const primary = createFakePrimary(); + let resolveCompanion: ((value: unknown) => void) | null = null; + const sendRequest = vi.fn( + () => + new Promise((resolve) => { + resolveCompanion = resolve; + }) + ); + const handle = bridgeTsserverRequests( + { primary, companion: { sendRequest } }, + { timeoutMs: 1000, logger: { warn: vi.fn() } } + ); + + primary.emit("tsserver/request", [99, "x", {}]); + handle.dispose(); + resolveCompanion?.({ body: "late" }); + + // Give the microtask queue a tick. + await Promise.resolve(); + await Promise.resolve(); + + expect(primary.notifications).toHaveLength(0); + }); +}); + +describe("unwrapTsserverResponse", () => { + it("returns the inner body for a successful tsserver wire response", () => { + expect( + unwrapTsserverResponse({ + seq: 0, + type: "response", + command: "quickinfo", + request_seq: 4, + success: true, + body: { displayString: "string" }, + }) + ).toEqual({ displayString: "string" }); + }); + + it("returns null when the tsserver response is unsuccessful", () => { + expect( + unwrapTsserverResponse({ + seq: 0, + type: "response", + command: "quickinfo", + success: false, + message: "no file", + }) + ).toBeNull(); + }); + + it("returns null for null / undefined / missing-body responses", () => { + expect(unwrapTsserverResponse(null)).toBeNull(); + expect(unwrapTsserverResponse(undefined)).toBeNull(); + expect( + unwrapTsserverResponse({ seq: 0, type: "response", command: "x", success: true }) + ).toBeNull(); + }); + + it("passes through plain results unchanged", () => { + expect(unwrapTsserverResponse({ displayString: "string" })).toEqual({ + displayString: "string", + }); + expect(unwrapTsserverResponse("just a string")).toBe("just a string"); + }); +}); diff --git a/packages/server/src/lsp/tsserver-bridge.ts b/packages/server/src/lsp/tsserver-bridge.ts new file mode 100644 index 00000000..b78585a1 --- /dev/null +++ b/packages/server/src/lsp/tsserver-bridge.ts @@ -0,0 +1,217 @@ +/** + * Forwards Volar's `tsserver/request` notifications to a paired TypeScript + * language server (running `@vue/typescript-plugin`) and relays the response + * back via `tsserver/response`. + * + * Volar 3.x removed its embedded TypeScript service; every semantic operation + * (hover, definition, quick info, …) is now resolved by sending a + * `tsserver/request` notification to the LSP client and awaiting a matching + * `tsserver/response`. The TypeScript Language Server exposes the + * `typescript.tsserverRequest` workspace command (>= v4.4) that takes a raw + * tsserver command and arguments and returns the tsserver response body. + * + * Volar protocol shapes: + * primary -> client (notification): tsserver/request: [id, command, args] + * client -> primary (notification): tsserver/response: [id, body] + * + * Client -> companion translation: + * workspace/executeCommand({ + * command: "typescript.tsserverRequest", + * arguments: [command, args], + * }) + */ + +import type { MessageConnection } from "vscode-jsonrpc"; + +const EXECUTE_COMMAND_METHOD = "workspace/executeCommand"; +const TSSERVER_REQUEST_NOTIFICATION = "tsserver/request"; +const TSSERVER_RESPONSE_NOTIFICATION = "tsserver/response"; +const TSSERVER_BRIDGE_COMMAND = "typescript.tsserverRequest"; + +export interface TsserverBridgeLogger { + warn: (...args: unknown[]) => void; +} + +export interface BridgeTsserverRequestOptions { + /** Maximum time to wait for the companion to respond before failing fast. */ + timeoutMs: number; + logger: TsserverBridgeLogger; + /** Test seam — defaults to a real `setTimeout`/`clearTimeout` pair. */ + scheduler?: { + setTimeout: (handler: () => void, ms: number) => unknown; + clearTimeout: (handle: unknown) => void; + }; +} + +export interface BridgeConnections { + /** Volar (or any LSP server that emits `tsserver/request`). */ + primary: Pick; + /** TypeScript Language Server with `@vue/typescript-plugin` loaded. */ + companion: Pick; +} + +export interface BridgeHandle { + /** Stop forwarding new notifications. Already in-flight ones still settle. */ + dispose: () => void; +} + +interface ParsedRequest { + id: number | string; + command: string; + args: unknown; +} + +function parseRequestPayload(payload: unknown): ParsedRequest | null { + if (!Array.isArray(payload) || payload.length < 2) { + return null; + } + const [id, command, args] = payload as [unknown, unknown, unknown]; + if ((typeof id !== "number" && typeof id !== "string") || typeof command !== "string") { + return null; + } + return { id, command, args }; +} + +export function bridgeTsserverRequests( + connections: BridgeConnections, + options: BridgeTsserverRequestOptions +): BridgeHandle { + const scheduler = options.scheduler ?? { + setTimeout: (handler, ms) => setTimeout(handler, ms), + clearTimeout: (handle) => clearTimeout(handle as ReturnType), + }; + let disposed = false; + + const disposable = connections.primary.onNotification( + TSSERVER_REQUEST_NOTIFICATION, + (payload: unknown) => { + if (disposed) { + return; + } + const parsed = parseRequestPayload(payload); + if (!parsed) { + options.logger.warn( + { payload }, + "ignoring malformed tsserver/request notification from vue language server" + ); + return; + } + + void forward(parsed); + } + ); + + async function forward(parsed: ParsedRequest): Promise { + try { + const raw = await withTimeout( + connections.companion.sendRequest(EXECUTE_COMMAND_METHOD, { + command: TSSERVER_BRIDGE_COMMAND, + arguments: [parsed.command, parsed.args], + }), + options.timeoutMs, + scheduler + ); + if (disposed) { + return; + } + sendResponse(parsed.id, unwrapTsserverResponse(raw)); + } catch (error) { + if (disposed) { + return; + } + options.logger.warn( + { err: error, tsCommand: parsed.command }, + "tsserver/request bridge failed; returning null to vue language server" + ); + sendResponse(parsed.id, null); + } + } + + function sendResponse(id: ParsedRequest["id"], body: unknown): void { + try { + void connections.primary + .sendNotification(TSSERVER_RESPONSE_NOTIFICATION, [id, body]) + .catch?.((error: unknown) => { + options.logger.warn( + { err: error, id }, + "failed to deliver tsserver/response notification" + ); + }); + } catch (error) { + options.logger.warn({ err: error, id }, "tsserver/response delivery threw synchronously"); + } + } + + return { + dispose() { + if (disposed) { + return; + } + disposed = true; + // vscode-jsonrpc `Disposable` interface — defensive in case the underlying + // connection mock returns void instead. + (disposable as { dispose?: () => void } | undefined)?.dispose?.(); + }, + }; +} + +/** + * `typescript.tsserverRequest` returns the raw tsserver wire response — + * `{ seq, type, command, request_seq, success, body }` — but Volar expects to + * receive the inner `body` directly (see `getQuickInfoAtPosition`, + * `_vue:projectInfo` consumers, etc. in `@vue/language-server`). Strip the + * wrapper here; downgrade `success: false` responses to `null` so Volar + * resolves the in-flight promise instead of trying to dereference an error + * envelope. + */ +export function unwrapTsserverResponse(raw: unknown): unknown { + if (raw === null || raw === undefined) { + return null; + } + if (typeof raw !== "object") { + return raw; + } + const wrapper = raw as { success?: boolean; body?: unknown; type?: unknown }; + // Only unwrap when this *looks* like a tsserver response envelope. + if (!("body" in wrapper) && wrapper.type !== "response") { + return raw; + } + if (wrapper.success === false) { + return null; + } + return wrapper.body ?? null; +} + +async function withTimeout( + promise: Promise | T, + ms: number, + scheduler: Required["scheduler"] +): Promise { + if (!(promise instanceof Promise)) { + return promise; + } + let timer: unknown; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = scheduler.setTimeout(() => { + reject(new TsserverBridgeTimeoutError(ms)); + }, ms); + }), + ]); + } finally { + if (timer !== undefined) { + scheduler.clearTimeout(timer); + } + } +} + +export class TsserverBridgeTimeoutError extends Error { + readonly timeoutMs: number; + constructor(timeoutMs: number) { + super(`tsserver/request bridge timed out after ${timeoutMs}ms`); + this.name = "TsserverBridgeTimeoutError"; + this.timeoutMs = timeoutMs; + } +} diff --git a/packages/server/src/lsp/vue-spec.test.ts b/packages/server/src/lsp/vue-spec.test.ts new file mode 100644 index 00000000..857490e6 --- /dev/null +++ b/packages/server/src/lsp/vue-spec.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { + buildVueSpecParts, + inferVueLanguageServerLocation, + parseVueBridgeMode, +} from "./vue-spec.js"; + +describe("inferVueLanguageServerLocation", () => { + it("derives the install root for a POSIX shim", () => { + const location = inferVueLanguageServerLocation( + "/tmp/.coder-studio/lsp-tools/vue/3.3.2/node_modules/.bin/vue-language-server" + ); + // Use path.sep-tolerant assertion: regardless of host, the trailing two + // segments should be @vue / language-server. + expect(location?.replace(/\\/g, "/")).toBe( + "/tmp/.coder-studio/lsp-tools/vue/3.3.2/node_modules/@vue/language-server" + ); + }); + + it("derives the install root for a Windows cmd shim", () => { + const location = inferVueLanguageServerLocation( + "C:\\state\\lsp-tools\\vue\\3.3.2\\node_modules\\.bin\\vue-language-server.cmd" + ); + expect(location?.replace(/\\/g, "/")).toBe( + "C:/state/lsp-tools/vue/3.3.2/node_modules/@vue/language-server" + ); + }); + + it("returns null when the executable is not under node_modules/.bin", () => { + expect(inferVueLanguageServerLocation("vue-language-server")).toBeNull(); + expect(inferVueLanguageServerLocation("/opt/vue-language-server")).toBeNull(); + }); +}); + +describe("buildVueSpecParts", () => { + it("wires Volar's initializationOptions.tsdk to the typescript sibling of the plugin", () => { + const parts = buildVueSpecParts({ + vueCommand: "vue-language-server", + vueArgs: ["--stdio"], + vueLanguageServerLocation: "/install/node_modules/@vue/language-server", + typescriptCommand: "/bundled/node", + typescriptArgs: ["/bundled/lib/cli.mjs", "--stdio"], + }); + + expect(parts.initializationOptions).toEqual({ + typescript: { tsdk: expect.stringMatching(/node_modules.typescript.lib$/) }, + }); + }); + + it("returns a tsserver/request bridge companion with the vue typescript plugin", () => { + const parts = buildVueSpecParts({ + vueCommand: "vue-language-server", + vueArgs: ["--stdio"], + vueLanguageServerLocation: "/install/node_modules/@vue/language-server", + typescriptCommand: "node", + typescriptArgs: ["/bundled/lib/cli.mjs", "--stdio"], + }); + + expect(parts.bridges).toEqual({ tsserverRequest: true }); + expect(parts.companion).toMatchObject({ + command: "node", + args: ["/bundled/lib/cli.mjs", "--stdio"], + initializationOptions: { + plugins: [ + { + name: "@vue/typescript-plugin", + location: "/install/node_modules/@vue/language-server", + languages: ["vue"], + }, + ], + }, + }); + }); + + it("omits the companion when bridge mode is off so volar runs alone", () => { + const parts = buildVueSpecParts({ + vueCommand: "vue-language-server", + vueArgs: ["--stdio"], + vueLanguageServerLocation: "/install/node_modules/@vue/language-server", + typescriptCommand: "node", + typescriptArgs: ["/bundled/lib/cli.mjs", "--stdio"], + bridgeMode: "off", + }); + + expect(parts.bridges).toBeUndefined(); + expect(parts.companion).toBeUndefined(); + }); +}); + +describe("parseVueBridgeMode", () => { + it("defaults to auto", () => { + expect(parseVueBridgeMode(undefined)).toBe("auto"); + expect(parseVueBridgeMode("")).toBe("auto"); + expect(parseVueBridgeMode("on")).toBe("auto"); + expect(parseVueBridgeMode("AUTO")).toBe("auto"); + }); + + it("treats 'off' as the only way to disable the bridge", () => { + expect(parseVueBridgeMode("off")).toBe("off"); + expect(parseVueBridgeMode("OFF")).toBe("off"); + }); +}); diff --git a/packages/server/src/lsp/vue-spec.ts b/packages/server/src/lsp/vue-spec.ts new file mode 100644 index 00000000..4d401d23 --- /dev/null +++ b/packages/server/src/lsp/vue-spec.ts @@ -0,0 +1,138 @@ +/** + * Build the `LspServerSpec` for a Vue session. + * + * Volar 3.x dropped its embedded TypeScript service, so a working setup + * requires: + * + * 1. The Vue Language Server (Volar) itself, talking LSP on stdio. + * 2. A paired TypeScript Language Server with `@vue/typescript-plugin` + * loaded as a tsserver plugin. The two communicate via `tsserver/request` + * and `tsserver/response` notifications that our `LspSession` bridge + * forwards to the TS server's `workspace/executeCommand + * typescript.tsserverRequest`. + * + * If the bridge cannot be wired up (no TypeScript server resolvable, or the + * `CODER_STUDIO_VUE_TSSERVER_BRIDGE` switch is `off`), the function returns + * the bare Volar spec. The user will still see the editor open .vue files, + * but semantic features will not return until the bridge is in place. + */ + +import path from "node:path"; +import type { LspCompanionSpec, LspServerSpec } from "./server-factory.js"; + +export type VueBridgeMode = "auto" | "off"; + +export interface VueSpecInputs { + /** Resolved Volar command (path to `vue-language-server[.cmd]` or wrapper). */ + vueCommand: string; + vueArgs: string[]; + /** + * Path to the directory that hosts both `@vue/language-server` and + * `@vue/typescript-plugin` under its `node_modules`. We pass this to the + * TypeScript Language Server as the `location` of the Vue tsserver plugin + * so it can resolve the plugin package next to Volar itself. + */ + vueLanguageServerLocation: string; + /** Resolved TypeScript language server command (already includes --stdio). */ + typescriptCommand: string; + typescriptArgs: string[]; + /** + * `auto` (default) wires up the bridge when both ends are available; + * `off` disables it entirely so the operator can rule out bridge bugs. + */ + bridgeMode?: VueBridgeMode; +} + +export interface VueSpecParts { + initializationOptions: unknown; + companion: LspCompanionSpec | undefined; + bridges: LspServerSpec["bridges"]; +} + +/** + * Walk the on-disk shape of a typical npm-managed Vue install + * (`/node_modules/.bin/vue-language-server(.cmd)`) up to the directory + * that contains `node_modules/@vue/language-server`. Returns `null` when the + * caller passes us something unexpected (e.g. an `--stdio` wrapper) so we can + * fail closed rather than hand the TS server a bogus plugin location. + */ +export function inferVueLanguageServerLocation(vueExecutablePath: string): string | null { + // Tolerate both POSIX and Windows separators regardless of the host platform + // so that the helper is testable from either side. + const normalized = vueExecutablePath.replace(/\\/g, "/"); + if (!normalized.toLowerCase().includes("/node_modules/.bin/")) { + return null; + } + + const pathApi = getPathApi(vueExecutablePath); + const binDir = pathApi.dirname(vueExecutablePath); // /node_modules/.bin + const nodeModulesDir = pathApi.dirname(binDir); // /node_modules + return pathApi.join(nodeModulesDir, "@vue", "language-server"); +} + +export function buildVueSpecParts(inputs: VueSpecInputs): VueSpecParts { + const bridgeMode: VueBridgeMode = inputs.bridgeMode ?? "auto"; + + // We always send Volar a `typescript.tsdk` initialization option so it + // doesn't fall back to a bundled-with-VSCode resolution that won't exist + // outside that environment. Volar's bin entry also auto-`require`s the + // typescript module installed alongside it, but being explicit is cheap. + const initializationOptions: Record = { + typescript: { + tsdk: deriveTsdk(inputs.vueLanguageServerLocation), + }, + }; + + if (bridgeMode === "off") { + return { + initializationOptions, + companion: undefined, + bridges: undefined, + }; + } + + const companion: LspCompanionSpec = { + command: inputs.typescriptCommand, + args: inputs.typescriptArgs, + initializationOptions: { + plugins: [ + { + name: "@vue/typescript-plugin", + location: inputs.vueLanguageServerLocation, + languages: ["vue"], + }, + ], + }, + }; + + return { + initializationOptions, + companion, + bridges: { tsserverRequest: true }, + }; +} + +export function parseVueBridgeMode(value: string | undefined): VueBridgeMode { + if (value === undefined || value === null || value === "") { + return "auto"; + } + return value.toLowerCase() === "off" ? "off" : "auto"; +} + +function deriveTsdk(vueLanguageServerLocation: string): string { + // typescript is installed at the sibling of @vue/language-server: + // /node_modules/@vue/language-server <- location + // /node_modules/typescript/lib <- tsdk + const pathApi = getPathApi(vueLanguageServerLocation); + const vueAtDir = pathApi.dirname(vueLanguageServerLocation); // /node_modules/@vue + const nodeModulesDir = pathApi.dirname(vueAtDir); // /node_modules + return pathApi.join(nodeModulesDir, "typescript", "lib"); +} + +function getPathApi(value: string) { + return isWindowsStylePath(value) ? path.win32 : path.posix; +} + +function isWindowsStylePath(value: string) { + return /^[A-Za-z]:[\\/]/.test(value) || value.includes("\\"); +} diff --git a/packages/server/src/monitoring/aggregation.ts b/packages/server/src/monitoring/aggregation.ts new file mode 100644 index 00000000..c0912269 --- /dev/null +++ b/packages/server/src/monitoring/aggregation.ts @@ -0,0 +1,330 @@ +import { + createEmptyMonitoringResponse, + deriveMonitoringMode, + type MonitoringEntitySummary, + type MonitoringHostSummary, + type MonitoringResponse, + type MonitoringSettings, + type MonitoringSnapshot, +} from "@coder-studio/core"; +import type { ManagedProcessRoot, ProcessStatRow } from "./types.js"; + +function createTrend( + current: number | null, + previous: number | null +): MonitoringEntitySummary["trend"] { + if (current == null || previous == null) { + return "unknown"; + } + if (current > previous + 1) { + return "rising"; + } + if (current < previous - 1) { + return "falling"; + } + return "steady"; +} + +function buildIndexes(rows: ProcessStatRow[]) { + const byPid = new Map(); + const childrenByPpid = new Map(); + + for (const row of rows) { + byPid.set(row.pid, row); + const children = childrenByPpid.get(row.ppid) ?? []; + children.push(row); + childrenByPpid.set(row.ppid, children); + } + + return { byPid, childrenByPpid }; +} + +function collectTree(rootPid: number, indexes: ReturnType): ProcessStatRow[] { + const root = indexes.byPid.get(rootPid); + if (!root) { + return []; + } + + const result: ProcessStatRow[] = []; + const stack = [root]; + const seen = new Set(); + + while (stack.length > 0) { + const current = stack.pop(); + if (!current || seen.has(current.pid)) { + continue; + } + seen.add(current.pid); + result.push(current); + + for (const child of indexes.childrenByPpid.get(current.pid) ?? []) { + stack.push(child); + } + } + + return result; +} + +function summarizeRows(rows: ProcessStatRow[]) { + return rows.reduce( + (acc, row) => ({ + cpuPercent: acc.cpuPercent + (row.cpuPercent ?? 0), + memoryBytes: acc.memoryBytes + (row.rssBytes ?? 0), + processCount: acc.processCount + 1, + uptimeSec: Math.max(acc.uptimeSec, row.elapsedSec ?? 0), + }), + { cpuPercent: 0, memoryBytes: 0, processCount: 0, uptimeSec: 0 } + ); +} + +function sortByCpu(items: T[]): T[] { + return items.sort((left, right) => (right.cpuPercent ?? 0) - (left.cpuPercent ?? 0)); +} + +export function buildMonitoringSnapshot(input: { + settings: MonitoringSettings; + sampledAt: number; + host: MonitoringHostSummary | null; + roots: ManagedProcessRoot[]; + workspaceLabels?: Record; + processRows: ProcessStatRow[] | null; + previousSnapshot: MonitoringSnapshot | null; + failureReason?: string; +}): MonitoringResponse { + const empty = createEmptyMonitoringResponse(input.settings); + const mode = deriveMonitoringMode(input.settings); + + if (!input.settings.enabled) { + return { + ...empty, + settings: input.settings, + snapshot: { + ...empty.snapshot, + sampledAt: input.sampledAt, + mode, + }, + }; + } + + if (!input.processRows) { + return { + ...empty, + settings: input.settings, + capabilities: { + ...empty.capabilities, + loadAverageAvailable: input.host?.loadAverage !== null, + }, + snapshot: { + ...empty.snapshot, + sampledAt: input.sampledAt, + mode, + host: input.host, + }, + telemetry: { + durationMs: 0, + processRowCount: 0, + subprocessGroupCount: 0, + historyTrimmed: false, + degraded: true, + failureReason: input.failureReason, + }, + }; + } + + const indexes = buildIndexes(input.processRows); + const previousEntities = new Map( + [ + ...(input.previousSnapshot?.workspaces ?? []), + ...(input.previousSnapshot?.sessions ?? []), + ...(input.previousSnapshot?.subprocessGroups ?? []), + ...(input.previousSnapshot?.backgroundGroups ?? []), + ].map((entity) => [entity.id, entity.cpuPercent ?? null]) + ); + + const workspaceMap = new Map(); + const sessionMap = new Map(); + const subprocessGroups: MonitoringEntitySummary[] = []; + const backgroundGroups: MonitoringEntitySummary[] = []; + const rootSubtreePidSets = new Map>(); + let totalManagedCpuPercent = 0; + let totalManagedMemoryBytes = 0; + let managedProcessCount = 0; + + for (const root of input.roots) { + rootSubtreePidSets.set( + root.ownerId, + new Set(collectTree(root.rootPid, indexes).map((row) => row.pid)) + ); + } + + const rootRowsByOwner = new Map(); + + for (const root of input.roots) { + const currentRootPidSet = rootSubtreePidSets.get(root.ownerId) ?? new Set(); + const overlappingSubtreePids = new Set(); + for (const candidate of input.roots) { + if (candidate.ownerId === root.ownerId) { + continue; + } + + const candidatePidSet = rootSubtreePidSets.get(candidate.ownerId); + if (!candidatePidSet || !currentRootPidSet.has(candidate.rootPid)) { + continue; + } + + for (const pid of candidatePidSet) { + overlappingSubtreePids.add(pid); + } + } + + const treeRows = collectTree(root.rootPid, indexes).filter((row) => { + return row.pid === root.rootPid || !overlappingSubtreePids.has(row.pid); + }); + if (treeRows.length === 0) { + continue; + } + + rootRowsByOwner.set(root.ownerId, treeRows); + const summary = summarizeRows(treeRows); + totalManagedCpuPercent += summary.cpuPercent; + totalManagedMemoryBytes += summary.memoryBytes; + managedProcessCount += summary.processCount; + + if (!root.workspaceId) { + backgroundGroups.push({ + id: `background:${root.ownerId}`, + kind: "background_group", + label: root.label, + cpuPercent: summary.cpuPercent, + memoryBytes: summary.memoryBytes, + processCount: summary.processCount, + uptimeSec: summary.uptimeSec, + trend: createTrend( + summary.cpuPercent, + previousEntities.get(`background:${root.ownerId}`) ?? null + ), + }); + continue; + } + + const workspaceId = `workspace:${root.workspaceId}`; + const workspace = + workspaceMap.get(workspaceId) ?? + ({ + id: workspaceId, + kind: "workspace", + workspaceId: root.workspaceId, + label: input.workspaceLabels?.[root.workspaceId] ?? root.workspaceId, + cpuPercent: 0, + memoryBytes: 0, + processCount: 0, + uptimeSec: 0, + trend: "unknown", + childCount: 0, + } satisfies MonitoringEntitySummary); + + workspace.cpuPercent = (workspace.cpuPercent ?? 0) + summary.cpuPercent; + workspace.memoryBytes = (workspace.memoryBytes ?? 0) + summary.memoryBytes; + workspace.processCount += summary.processCount; + workspace.uptimeSec = Math.max(workspace.uptimeSec ?? 0, summary.uptimeSec); + workspace.childCount = (workspace.childCount ?? 0) + 1; + workspace.trend = createTrend(workspace.cpuPercent, previousEntities.get(workspaceId) ?? null); + workspaceMap.set(workspaceId, workspace); + + if (root.sessionId) { + const sessionId = `session:${root.sessionId}`; + sessionMap.set(sessionId, { + id: sessionId, + kind: "session", + parentId: workspaceId, + workspaceId: root.workspaceId, + sessionId: root.sessionId, + terminalId: root.terminalId, + label: root.label, + cpuPercent: summary.cpuPercent, + memoryBytes: summary.memoryBytes, + processCount: summary.processCount, + uptimeSec: summary.uptimeSec, + trend: createTrend(summary.cpuPercent, previousEntities.get(sessionId) ?? null), + childCount: Math.max(0, treeRows.length - 1), + }); + + if (input.settings.subprocessDrilldownEnabled) { + for (const child of treeRows.filter((row) => row.pid !== root.rootPid)) { + const id = `subprocess:${root.sessionId}:${child.pid}`; + subprocessGroups.push({ + id, + kind: "subprocess_group", + parentId: sessionId, + workspaceId: root.workspaceId, + sessionId: root.sessionId, + terminalId: root.terminalId, + label: child.command ?? child.executable ?? `pid ${child.pid}`, + cpuPercent: child.cpuPercent, + memoryBytes: child.rssBytes, + processCount: 1, + uptimeSec: child.elapsedSec ?? null, + trend: createTrend(child.cpuPercent, previousEntities.get(id) ?? null), + }); + } + } + } + } + + const serverRoot = input.roots.find((root) => root.kind === "server"); + const serverSummary = summarizeRows( + serverRoot ? (rootRowsByOwner.get(serverRoot.ownerId) ?? []) : [] + ); + const hostCpu = input.host?.cpuPercent ?? null; + const hostMemory = input.host?.memoryTotalBytes ?? null; + + return { + ...empty, + settings: input.settings, + capabilities: { + loadAverageAvailable: input.host?.loadAverage !== null, + processMetricsAvailable: true, + subprocessHistoryLimited: false, + }, + snapshot: { + sampledAt: input.sampledAt, + mode, + host: input.host, + runtime: input.settings.runtimeSummaryEnabled + ? { + serverCpuPercent: serverSummary.cpuPercent || null, + serverMemoryBytes: serverSummary.memoryBytes || null, + totalManagedCpuPercent, + totalManagedMemoryBytes, + managedProcessCount, + cpuShareOfHostPercent: + hostCpu != null && hostCpu > 0 + ? Number(((totalManagedCpuPercent / hostCpu) * 100).toFixed(2)) + : null, + memoryShareOfHostPercent: + hostMemory != null && hostMemory > 0 + ? Number(((totalManagedMemoryBytes / hostMemory) * 100).toFixed(2)) + : null, + } + : null, + workspaces: input.settings.workspaceAttributionEnabled + ? sortByCpu([...workspaceMap.values()]) + : [], + sessions: input.settings.workspaceAttributionEnabled + ? sortByCpu([...sessionMap.values()]) + : [], + subprocessGroups: input.settings.subprocessDrilldownEnabled + ? sortByCpu(subprocessGroups) + : [], + backgroundGroups: sortByCpu(backgroundGroups), + }, + telemetry: { + durationMs: 0, + processRowCount: input.processRows.length, + subprocessGroupCount: subprocessGroups.length, + historyTrimmed: false, + degraded: false, + failureReason: input.failureReason, + }, + }; +} diff --git a/packages/server/src/monitoring/history-store.ts b/packages/server/src/monitoring/history-store.ts new file mode 100644 index 00000000..2c675f63 --- /dev/null +++ b/packages/server/src/monitoring/history-store.ts @@ -0,0 +1,214 @@ +import type { + MonitoringHistoryBundle, + MonitoringSeriesBundle, + MonitoringSeriesPoint, + MonitoringSnapshot, +} from "@coder-studio/core"; + +const DEFAULT_RETENTION_MS = 30 * 60 * 1000; +const MAX_SUBPROCESS_HISTORY_GROUPS = 24; + +function trimPoints( + points: MonitoringSeriesPoint[], + minSampledAt: number +): { points: MonitoringSeriesPoint[]; trimmed: boolean } { + const nextPoints = points.filter((point) => point.sampledAt >= minSampledAt); + return { + points: nextPoints, + trimmed: nextPoints.length !== points.length, + }; +} + +function appendPoint( + bundle: MonitoringSeriesBundle, + point: MonitoringSeriesPoint, + minSampledAt: number +): boolean { + const result = trimPoints([...bundle.points, point], minSampledAt); + bundle.points = result.points; + return result.trimmed; +} + +function pruneEntityHistory( + history: Record, + activeIds: Set, + minSampledAt: number +): boolean { + let trimmed = false; + + for (const [id, bundle] of Object.entries(history)) { + if (!activeIds.has(id)) { + delete history[id]; + trimmed = true; + continue; + } + + const result = trimPoints(bundle.points, minSampledAt); + bundle.points = result.points; + trimmed = result.trimmed || trimmed; + } + + return trimmed; +} + +export class MonitoringHistoryStore { + private readonly history: MonitoringHistoryBundle = { + host: { points: [] }, + runtime: null, + workspaces: {}, + sessions: {}, + subprocessGroups: {}, + }; + + constructor(private readonly deps: { retentionMs?: number } = {}) {} + + clear(): void { + this.history.host = { points: [] }; + this.history.runtime = null; + this.history.workspaces = {}; + this.history.sessions = {}; + this.history.subprocessGroups = {}; + } + + record(snapshot: MonitoringSnapshot): { trimmed: boolean; subprocessHistoryLimited: boolean } { + const minSampledAt = snapshot.sampledAt - (this.deps.retentionMs ?? DEFAULT_RETENTION_MS); + let trimmed = false; + let subprocessHistoryLimited = false; + + if (snapshot.host) { + trimmed = + appendPoint( + this.history.host, + { + sampledAt: snapshot.sampledAt, + cpuPercent: snapshot.host.cpuPercent, + memoryBytes: snapshot.host.memoryUsedBytes, + }, + minSampledAt + ) || trimmed; + } else if (this.history.host.points.length > 0) { + this.history.host = { points: [] }; + trimmed = true; + } + + if (snapshot.runtime) { + this.history.runtime ??= { points: [] }; + trimmed = + appendPoint( + this.history.runtime, + { + sampledAt: snapshot.sampledAt, + cpuPercent: snapshot.runtime.totalManagedCpuPercent, + memoryBytes: snapshot.runtime.totalManagedMemoryBytes, + processCount: snapshot.runtime.managedProcessCount, + }, + minSampledAt + ) || trimmed; + } + + for (const entity of snapshot.workspaces) { + const bundle = (this.history.workspaces[entity.id] ??= { points: [] }); + trimmed = + appendPoint( + bundle, + { + sampledAt: snapshot.sampledAt, + cpuPercent: entity.cpuPercent, + memoryBytes: entity.memoryBytes, + processCount: entity.processCount, + }, + minSampledAt + ) || trimmed; + } + + for (const entity of snapshot.sessions) { + const bundle = (this.history.sessions[entity.id] ??= { points: [] }); + trimmed = + appendPoint( + bundle, + { + sampledAt: snapshot.sampledAt, + cpuPercent: entity.cpuPercent, + memoryBytes: entity.memoryBytes, + processCount: entity.processCount, + }, + minSampledAt + ) || trimmed; + } + + trimmed = + pruneEntityHistory( + this.history.workspaces, + new Set(snapshot.workspaces.map((entity) => entity.id)), + minSampledAt + ) || trimmed; + trimmed = + pruneEntityHistory( + this.history.sessions, + new Set(snapshot.sessions.map((entity) => entity.id)), + minSampledAt + ) || trimmed; + + const hottestSubprocessIds = [...snapshot.subprocessGroups] + .sort((left, right) => (right.cpuPercent ?? 0) - (left.cpuPercent ?? 0)) + .slice(0, MAX_SUBPROCESS_HISTORY_GROUPS) + .map((entity) => entity.id); + const allowedSubprocessIds = new Set(hottestSubprocessIds); + + for (const entity of snapshot.subprocessGroups) { + if (!allowedSubprocessIds.has(entity.id)) { + trimmed = true; + subprocessHistoryLimited = true; + continue; + } + + const bundle = (this.history.subprocessGroups[entity.id] ??= { points: [] }); + trimmed = + appendPoint( + bundle, + { + sampledAt: snapshot.sampledAt, + cpuPercent: entity.cpuPercent, + memoryBytes: entity.memoryBytes, + processCount: entity.processCount, + }, + minSampledAt + ) || trimmed; + } + + for (const id of Object.keys(this.history.subprocessGroups)) { + if (!allowedSubprocessIds.has(id)) { + delete this.history.subprocessGroups[id]; + trimmed = true; + subprocessHistoryLimited = true; + } + } + + return { trimmed, subprocessHistoryLimited }; + } + + snapshot(): MonitoringHistoryBundle { + return { + host: { points: [...this.history.host.points] }, + runtime: this.history.runtime ? { points: [...this.history.runtime.points] } : null, + workspaces: Object.fromEntries( + Object.entries(this.history.workspaces).map(([id, bundle]) => [ + id, + { points: [...bundle.points] }, + ]) + ), + sessions: Object.fromEntries( + Object.entries(this.history.sessions).map(([id, bundle]) => [ + id, + { points: [...bundle.points] }, + ]) + ), + subprocessGroups: Object.fromEntries( + Object.entries(this.history.subprocessGroups).map(([id, bundle]) => [ + id, + { points: [...bundle.points] }, + ]) + ), + }; + } +} diff --git a/packages/server/src/monitoring/host-collector.ts b/packages/server/src/monitoring/host-collector.ts new file mode 100644 index 00000000..83bf90d6 --- /dev/null +++ b/packages/server/src/monitoring/host-collector.ts @@ -0,0 +1,85 @@ +import os from "node:os"; +import type { MonitoringHostSummary } from "@coder-studio/core"; + +type CpuInfo = ReturnType[number]; +type CpuTimes = Pick; + +function sumCpuTimes(cpus: CpuInfo[]): CpuTimes { + return cpus.reduce( + (acc, cpu) => ({ + user: acc.user + cpu.times.user, + nice: acc.nice + cpu.times.nice, + sys: acc.sys + cpu.times.sys, + idle: acc.idle + cpu.times.idle, + irq: acc.irq + cpu.times.irq, + }), + { user: 0, nice: 0, sys: 0, idle: 0, irq: 0 } + ); +} + +export class HostCollector { + private previousCpu: CpuTimes | null = null; + + constructor( + private readonly deps: { + platform?: NodeJS.Platform; + cpus?: () => CpuInfo[]; + totalmem?: () => number; + freemem?: () => number; + uptime?: () => number; + loadavg?: () => number[]; + } = {} + ) {} + + collect(overrides: { cpus?: CpuInfo[] } = {}): MonitoringHostSummary { + const cpus = overrides.cpus ?? this.deps.cpus?.() ?? os.cpus(); + const currentCpu = sumCpuTimes(cpus); + const previousCpu = this.previousCpu; + this.previousCpu = currentCpu; + + let cpuPercent: number | null = null; + if (previousCpu) { + const busyDelta = + currentCpu.user + + currentCpu.nice + + currentCpu.sys + + currentCpu.irq - + (previousCpu.user + previousCpu.nice + previousCpu.sys + previousCpu.irq); + const idleDelta = currentCpu.idle - previousCpu.idle; + + if (busyDelta + idleDelta > 0) { + cpuPercent = Number(((busyDelta / (busyDelta + idleDelta)) * 100).toFixed(2)); + } + } + + const memoryTotalBytes = this.deps.totalmem?.() ?? os.totalmem(); + const memoryAvailableBytes = this.deps.freemem?.() ?? os.freemem(); + const memoryUsedBytes = memoryTotalBytes - memoryAvailableBytes; + const uptimeSec = this.deps.uptime?.() ?? os.uptime(); + const platform = this.deps.platform ?? process.platform; + const loadAverage = + platform === "win32" + ? null + : ((this.deps.loadavg?.() ?? os.loadavg()).slice(0, 3) as [number, number, number]); + + const memoryRatio = memoryTotalBytes > 0 ? memoryUsedBytes / memoryTotalBytes : null; + const pressure = + cpuPercent == null || memoryRatio == null + ? "unknown" + : cpuPercent >= 90 || memoryRatio >= 0.9 + ? "hot" + : cpuPercent >= 70 || memoryRatio >= 0.75 + ? "elevated" + : "normal"; + + return { + cpuPercent, + memoryUsedBytes, + memoryTotalBytes, + memoryAvailableBytes, + loadAverage, + uptimeSec, + pressure, + }; + } +} diff --git a/packages/server/src/monitoring/managed-process-registry.ts b/packages/server/src/monitoring/managed-process-registry.ts new file mode 100644 index 00000000..5877a39d --- /dev/null +++ b/packages/server/src/monitoring/managed-process-registry.ts @@ -0,0 +1,91 @@ +import type { ManagedProcessRoot } from "./types.js"; + +interface ManagedProcessRegistryOptions { + now: () => number; +} + +interface TerminalRootInput { + terminalId: string; + workspaceId: string; + pid?: number; + kind: "agent" | "shell"; + title: string; +} + +interface TerminalBindingInput { + sessionId: string; + providerId?: string; + label: string; +} + +export class ManagedProcessRegistry { + private readonly now: () => number; + private readonly roots = new Map(); + + constructor(options: ManagedProcessRegistryOptions) { + this.now = options.now; + } + + registerServerProcess(pid: number): void { + const ownerId = `server:${pid}`; + if (this.roots.has(ownerId)) { + return; + } + + this.roots.set(ownerId, { + ownerId, + rootPid: pid, + kind: "server", + label: "Coder Studio server", + startedAt: this.now(), + }); + } + + upsertTerminalRoot(input: TerminalRootInput): void { + if (!input.pid || input.pid <= 0) { + return; + } + + const ownerId = `terminal:${input.terminalId}`; + const existing = this.roots.get(ownerId); + + this.roots.set(ownerId, { + ownerId, + rootPid: input.pid, + kind: "terminal", + label: input.title, + workspaceId: input.workspaceId, + terminalId: input.terminalId, + startedAt: existing?.startedAt ?? this.now(), + sessionId: existing?.sessionId, + providerId: existing?.providerId, + }); + } + + bindSessionToTerminal(terminalId: string, binding: TerminalBindingInput): void { + const ownerId = `terminal:${terminalId}`; + const existing = this.roots.get(ownerId); + if (!existing) { + return; + } + + this.roots.set(ownerId, { + ...existing, + sessionId: binding.sessionId, + providerId: binding.providerId, + label: binding.label, + }); + } + + registerBackgroundRoot(root: ManagedProcessRoot): void { + this.roots.set(root.ownerId, root); + } + + unregisterByOwner(ownerId: string): void { + this.roots.delete(ownerId); + } + + listRoots(): ManagedProcessRoot[] { + return Array.from(this.roots.values()).sort((a, b) => a.startedAt - b.startedAt); + } +} diff --git a/packages/server/src/monitoring/process-table/darwin.ts b/packages/server/src/monitoring/process-table/darwin.ts new file mode 100644 index 00000000..d016e096 --- /dev/null +++ b/packages/server/src/monitoring/process-table/darwin.ts @@ -0,0 +1,38 @@ +import type { CommandRunner } from "../../provider-runtime/command-runner.js"; +import type { ProcessStatRow } from "../types.js"; + +const DARWIN_PS_ARGS = ["-Ao", "pid=,ppid=,%cpu=,rss=,etimes=,comm=,args="]; + +export function parseDarwinPsRows(stdout: string): ProcessStatRow[] { + const rows: ProcessStatRow[] = []; + + for (const line of stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean)) { + const match = line.match(/^(\d+)\s+(\d+)\s+([0-9.]+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/); + if (!match) { + continue; + } + + const [, pid, ppid, cpuPercent, rssKb, elapsedSec, executable, command] = match; + rows.push({ + pid: Number(pid), + ppid: Number(ppid), + cpuPercent: Number(cpuPercent), + rssBytes: Number(rssKb) * 1024, + elapsedSec: Number(elapsedSec), + executable, + command, + }); + } + + return rows; +} + +export async function collectDarwinProcessRows( + runCommand: CommandRunner +): Promise { + const result = await runCommand("ps", DARWIN_PS_ARGS); + return parseDarwinPsRows(result.stdout); +} diff --git a/packages/server/src/monitoring/process-table/index.ts b/packages/server/src/monitoring/process-table/index.ts new file mode 100644 index 00000000..99191c2c --- /dev/null +++ b/packages/server/src/monitoring/process-table/index.ts @@ -0,0 +1,24 @@ +import { type CommandRunner, runCommandAsString } from "../../provider-runtime/command-runner.js"; +import type { ProcessStatRow } from "../types.js"; +import { collectDarwinProcessRows, parseDarwinPsRows } from "./darwin.js"; +import { collectLinuxProcessRows, parseLinuxPsRows } from "./linux.js"; +import { collectWindowsProcessRows, parseWindowsProcessRows } from "./win32.js"; + +export { parseDarwinPsRows, parseLinuxPsRows, parseWindowsProcessRows }; + +export interface ProcessTableCollector { + collect(): Promise; +} + +export function createProcessTableCollector( + platform: NodeJS.Platform = process.platform, + runCommand: CommandRunner = runCommandAsString +): ProcessTableCollector { + if (platform === "darwin") { + return { collect: () => collectDarwinProcessRows(runCommand) }; + } + if (platform === "linux") { + return { collect: () => collectLinuxProcessRows(runCommand) }; + } + return { collect: () => collectWindowsProcessRows(runCommand) }; +} diff --git a/packages/server/src/monitoring/process-table/linux.ts b/packages/server/src/monitoring/process-table/linux.ts new file mode 100644 index 00000000..0432dc6d --- /dev/null +++ b/packages/server/src/monitoring/process-table/linux.ts @@ -0,0 +1,38 @@ +import type { CommandRunner } from "../../provider-runtime/command-runner.js"; +import type { ProcessStatRow } from "../types.js"; + +const LINUX_PS_ARGS = ["-eo", "pid=,ppid=,%cpu=,rss=,etimes=,comm=,args="]; + +export function parseLinuxPsRows(stdout: string): ProcessStatRow[] { + const rows: ProcessStatRow[] = []; + + for (const line of stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean)) { + const match = line.match(/^(\d+)\s+(\d+)\s+([0-9.]+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/); + if (!match) { + continue; + } + + const [, pid, ppid, cpuPercent, rssKb, elapsedSec, executable, command] = match; + rows.push({ + pid: Number(pid), + ppid: Number(ppid), + cpuPercent: Number(cpuPercent), + rssBytes: Number(rssKb) * 1024, + elapsedSec: Number(elapsedSec), + executable, + command, + }); + } + + return rows; +} + +export async function collectLinuxProcessRows( + runCommand: CommandRunner +): Promise { + const result = await runCommand("ps", LINUX_PS_ARGS); + return parseLinuxPsRows(result.stdout); +} diff --git a/packages/server/src/monitoring/process-table/win32.ts b/packages/server/src/monitoring/process-table/win32.ts new file mode 100644 index 00000000..3db4996b --- /dev/null +++ b/packages/server/src/monitoring/process-table/win32.ts @@ -0,0 +1,53 @@ +import type { CommandRunner } from "../../provider-runtime/command-runner.js"; +import type { ProcessStatRow } from "../types.js"; + +const WINDOWS_SCRIPT = [ + "$payload = Get-CimInstance Win32_Process | ForEach-Object {", + " [pscustomobject]@{", + " Id = $_.ProcessId;", + " ParentProcessId = $_.ParentProcessId;", + " CpuPercent = $null;", + " WorkingSet64 = $null;", + " ElapsedSec = $null;", + " Path = $_.ExecutablePath;", + " CommandLine = $_.CommandLine;", + " }", + "};", + "$payload | ConvertTo-Json -Compress", +].join(" "); + +export function parseWindowsProcessRows(rows: unknown[]): ProcessStatRow[] { + const normalizedRows: ProcessStatRow[] = []; + + for (const row of rows) { + if (!row || typeof row !== "object") { + continue; + } + + const candidate = row as Record; + if (typeof candidate.Id !== "number" || typeof candidate.ParentProcessId !== "number") { + continue; + } + + normalizedRows.push({ + pid: candidate.Id, + ppid: candidate.ParentProcessId, + cpuPercent: typeof candidate.CpuPercent === "number" ? candidate.CpuPercent : null, + rssBytes: typeof candidate.WorkingSet64 === "number" ? candidate.WorkingSet64 : null, + elapsedSec: typeof candidate.ElapsedSec === "number" ? candidate.ElapsedSec : undefined, + executable: typeof candidate.Path === "string" ? candidate.Path : undefined, + command: typeof candidate.CommandLine === "string" ? candidate.CommandLine : undefined, + }); + } + + return normalizedRows; +} + +export async function collectWindowsProcessRows( + runCommand: CommandRunner +): Promise { + const result = await runCommand("powershell", ["-NoProfile", "-Command", WINDOWS_SCRIPT]); + const parsed = JSON.parse(result.stdout) as unknown; + const rows = Array.isArray(parsed) ? parsed : parsed ? [parsed] : []; + return parseWindowsProcessRows(rows); +} diff --git a/packages/server/src/monitoring/service.ts b/packages/server/src/monitoring/service.ts new file mode 100644 index 00000000..4ab5f9ab --- /dev/null +++ b/packages/server/src/monitoring/service.ts @@ -0,0 +1,246 @@ +import { basename } from "node:path"; +import { + createEmptyMonitoringResponse, + deriveMonitoringMode, + type MonitoringResponse, + resolveMonitoringSettings, + type Session, + type Terminal, + Topics, + type Workspace, +} from "@coder-studio/core"; +import { buildMonitoringSnapshot } from "./aggregation.js"; +import { MonitoringHistoryStore } from "./history-store.js"; +import type { HostCollector } from "./host-collector.js"; +import { ManagedProcessRegistry } from "./managed-process-registry.js"; +import type { ProcessTableCollector } from "./process-table/index.js"; + +interface ActiveTerminalLike { + toDTO(): Terminal; + spec?: { + workspaceId: string; + kind: "agent" | "shell"; + title?: string; + }; +} + +export class MonitoringService { + private timer: ReturnType | null = null; + private latest = createEmptyMonitoringResponse(); + private latestSampledSnapshot = this.latest.snapshot; + private readonly history = new MonitoringHistoryStore(); + + constructor( + private readonly deps: { + broadcaster: { broadcast(topic: string, payload: unknown): void }; + settingsRepo: { get(key: string): T | undefined }; + registry: ManagedProcessRegistry; + sessionMgr: { + getAll(): Session[]; + findSessionIdByTerminal(terminalId: string): string | undefined; + }; + workspaceMgr?: { + get(workspaceId: string): Pick | undefined; + }; + terminalMgr: { + getAll(): ActiveTerminalLike[]; + }; + hostCollector: Pick; + processCollector: Pick; + setInterval?: typeof global.setInterval; + clearInterval?: typeof global.clearInterval; + now?: () => number; + } + ) {} + + start(): void { + this.deps.registry.registerServerProcess(process.pid); + this.reloadFromSettings(); + } + + stop(): void { + if (!this.timer) { + return; + } + + (this.deps.clearInterval ?? clearInterval)(this.timer); + this.timer = null; + } + + getResponse(): MonitoringResponse { + return this.latest; + } + + async recheck(): Promise { + const settings = resolveMonitoringSettings(this.deps.settingsRepo); + if (!settings.enabled) { + this.latest = { + ...createEmptyMonitoringResponse(settings), + settings, + snapshot: { + ...createEmptyMonitoringResponse(settings).snapshot, + sampledAt: this.now(), + mode: deriveMonitoringMode(settings), + }, + }; + return this.latest; + } + + await this.sampleOnce(settings); + return this.latest; + } + + reloadFromSettings(): void { + this.stop(); + const settings = resolveMonitoringSettings(this.deps.settingsRepo); + const empty = createEmptyMonitoringResponse(settings); + if (!settings.enabled) { + this.history.clear(); + this.latestSampledSnapshot = empty.snapshot; + this.latest = { + ...empty, + settings, + snapshot: { + ...empty.snapshot, + sampledAt: this.now(), + mode: deriveMonitoringMode(settings), + }, + }; + return; + } + + this.latest = { + ...empty, + settings, + snapshot: { + ...empty.snapshot, + sampledAt: this.now(), + mode: deriveMonitoringMode(settings), + }, + }; + + const intervalHandle = (this.deps.setInterval ?? setInterval)(() => { + void this.sampleOnce(); + }, settings.sampleIntervalMs); + intervalHandle.unref?.(); + this.timer = intervalHandle; + } + + private now(): number { + return this.deps.now?.() ?? Date.now(); + } + + private syncManagedTerminalRoots(): void { + const sessions = this.deps.sessionMgr.getAll(); + const sessionsByTerminal = new Map(sessions.map((session) => [session.terminalId, session])); + const activeTerminals = this.deps.terminalMgr.getAll(); + const activeOwnerIds = new Set(); + + for (const activeTerminal of activeTerminals) { + const terminal = activeTerminal.toDTO(); + activeOwnerIds.add(`terminal:${terminal.id}`); + this.deps.registry.upsertTerminalRoot({ + terminalId: terminal.id, + workspaceId: terminal.workspaceId, + pid: terminal.pid, + kind: terminal.kind, + title: terminal.title, + }); + + const session = + sessionsByTerminal.get(terminal.id) ?? + (() => { + const sessionId = this.deps.sessionMgr.findSessionIdByTerminal(terminal.id); + return sessionId ? sessions.find((candidate) => candidate.id === sessionId) : undefined; + })(); + + if (!session) { + continue; + } + + this.deps.registry.bindSessionToTerminal(terminal.id, { + sessionId: session.id, + providerId: session.providerId, + label: session.title ?? terminal.title, + }); + } + + for (const root of this.deps.registry.listRoots()) { + if (root.kind !== "terminal") { + continue; + } + if (activeOwnerIds.has(root.ownerId)) { + continue; + } + this.deps.registry.unregisterByOwner(root.ownerId); + } + } + + private getWorkspaceLabels(roots: ReturnType) { + const labels: Record = {}; + for (const root of roots) { + if (!root.workspaceId || labels[root.workspaceId]) { + continue; + } + + const workspace = this.deps.workspaceMgr?.get(root.workspaceId); + const label = workspace?.name?.trim() || (workspace?.path ? basename(workspace.path) : ""); + if (label) { + labels[root.workspaceId] = label; + } + } + return labels; + } + + private async sampleOnce( + settings = resolveMonitoringSettings(this.deps.settingsRepo) + ): Promise { + const startedAt = this.now(); + this.syncManagedTerminalRoots(); + + const host = settings.hostMetricsEnabled ? this.deps.hostCollector.collect() : null; + + let processRows: Awaited> | null = null; + let failureReason: string | undefined; + if (settings.runtimeSummaryEnabled) { + try { + processRows = await this.deps.processCollector.collect(); + } catch (error) { + failureReason = error instanceof Error ? error.message : String(error); + } + } + + const roots = this.deps.registry.listRoots(); + const response = buildMonitoringSnapshot({ + settings, + sampledAt: startedAt, + host, + roots, + workspaceLabels: this.getWorkspaceLabels(roots), + processRows, + previousSnapshot: + this.latestSampledSnapshot.sampledAt > 0 ? this.latestSampledSnapshot : null, + failureReason, + }); + + const historyState = this.history.record(response.snapshot); + this.latestSampledSnapshot = response.snapshot; + this.latest = { + ...response, + history: this.history.snapshot(), + capabilities: { + ...response.capabilities, + subprocessHistoryLimited: historyState.subprocessHistoryLimited, + }, + telemetry: response.telemetry + ? { + ...response.telemetry, + durationMs: this.now() - startedAt, + historyTrimmed: historyState.trimmed, + } + : null, + }; + + this.deps.broadcaster.broadcast(Topics.monitoringSnapshotUpdated, this.latest); + } +} diff --git a/packages/server/src/monitoring/types.ts b/packages/server/src/monitoring/types.ts new file mode 100644 index 00000000..27498525 --- /dev/null +++ b/packages/server/src/monitoring/types.ts @@ -0,0 +1,29 @@ +export interface ManagedProcessRoot { + ownerId: string; + rootPid: number; + kind: "server" | "terminal" | "session_helper" | "lsp" | "installer" | "background"; + label: string; + workspaceId?: string; + sessionId?: string; + terminalId?: string; + providerId?: string; + startedAt: number; +} + +export interface MonitoringCollectorTelemetry { + processRowCount: number; + subprocessGroupCount: number; + historyTrimmed: boolean; + degraded: boolean; + failureReason?: string; +} + +export interface ProcessStatRow { + pid: number; + ppid: number; + cpuPercent: number | null; + rssBytes: number | null; + elapsedSec?: number; + command?: string; + executable?: string; +} diff --git a/packages/server/src/preview/html-resource-rewriter.ts b/packages/server/src/preview/html-resource-rewriter.ts new file mode 100644 index 00000000..9a619737 --- /dev/null +++ b/packages/server/src/preview/html-resource-rewriter.ts @@ -0,0 +1,358 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { isPathInsideRoot } from "../fs/path-safety.js"; + +const PREVIEW_RESOURCE_TAGS = new Set([ + "embed", + "iframe", + "image", + "img", + "input", + "link", + "source", + "track", + "audio", + "video", + "script", +]); + +const PREVIEW_RESOURCE_ATTRS = new Set(["href", "poster", "src", "xlink:href"]); + +const STYLE_BLOCK_PATTERN = /]*)>([\s\S]*?)<\/style>/gi; +const PREVIEW_TAG_PATTERN = /<\s*([A-Za-z][\w:-]*)([^<>]*?)>/g; +const PREVIEW_ATTR_PATTERN = + /(\s+)(srcset|src|href|poster|xlink:href|style)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/gi; +const CSS_URL_PATTERN = /url\(\s*(?:"([^"]*)"|'([^']*)'|([^'")]*?))\s*\)/gi; + +interface PreviewRewriteInput { + sessionId: string; + workspaceRootPath: string; + baseWorkspacePath?: string; +} + +export function encodePathSegments(inputPath: string): string { + return inputPath + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/"); +} + +export function rewritePreviewHtmlResourceUrls( + html: string, + input: { sessionId: string; workspaceRootPath: string; entryPath: string } +): string { + const rewriteInput = { + sessionId: input.sessionId, + workspaceRootPath: input.workspaceRootPath, + baseWorkspacePath: input.entryPath, + }; + const htmlWithStyleBlocks = html.replace( + STYLE_BLOCK_PATTERN, + (match, rawAttributes: string, css: string) => { + const rewrittenCss = rewritePreviewCssResourceUrls(css, rewriteInput); + return rewrittenCss === css ? match : `${rewrittenCss}`; + } + ); + + return htmlWithStyleBlocks.replace( + PREVIEW_TAG_PATTERN, + (match, rawTagName: string, rawAttributes: string) => { + const canRewriteResourceAttrs = PREVIEW_RESOURCE_TAGS.has(rawTagName.toLowerCase()); + let changed = false; + const nextAttributes = rawAttributes.replace( + PREVIEW_ATTR_PATTERN, + ( + attrMatch, + leadingWhitespace: string, + rawAttrName: string, + doubleQuotedValue: string | undefined, + singleQuotedValue: string | undefined, + bareValue: string | undefined + ) => { + const attrName = rawAttrName.toLowerCase(); + const originalValue = doubleQuotedValue ?? singleQuotedValue ?? bareValue ?? ""; + let rewrittenValue = originalValue; + + if (attrName === "style") { + rewrittenValue = rewritePreviewCssResourceUrls(originalValue, rewriteInput); + } else if (canRewriteResourceAttrs && attrName === "srcset") { + rewrittenValue = rewritePreviewSrcset(originalValue, rewriteInput); + } else if (canRewriteResourceAttrs && PREVIEW_RESOURCE_ATTRS.has(attrName)) { + rewrittenValue = rewritePreviewResourceUrl(originalValue, rewriteInput); + } + + if (rewrittenValue === originalValue) { + return attrMatch; + } + + changed = true; + + if (doubleQuotedValue !== undefined) { + return `${leadingWhitespace}${rawAttrName}="${escapeHtmlAttribute(rewrittenValue, '"')}"`; + } + + if (singleQuotedValue !== undefined) { + return `${leadingWhitespace}${rawAttrName}='${escapeHtmlAttribute(rewrittenValue, "'")}'`; + } + + return `${leadingWhitespace}${rawAttrName}=${rewrittenValue}`; + } + ); + + return changed ? `<${rawTagName}${nextAttributes}>` : match; + } + ); +} + +export function rewritePreviewCssResourceUrls(css: string, input: PreviewRewriteInput): string { + return css.replace( + CSS_URL_PATTERN, + ( + match, + doubleQuotedValue: string | undefined, + singleQuotedValue: string | undefined, + bareValue: string | undefined + ) => { + const originalValue = doubleQuotedValue ?? singleQuotedValue ?? bareValue ?? ""; + const rewrittenValue = rewritePreviewResourceUrl(originalValue.trim(), input); + + if (rewrittenValue === originalValue.trim()) { + return match; + } + + if (doubleQuotedValue !== undefined) { + return `url("${escapeCssQuotedUrl(rewrittenValue, '"')}")`; + } + + if (singleQuotedValue !== undefined) { + return `url('${escapeCssQuotedUrl(rewrittenValue, "'")}')`; + } + + return `url(${rewrittenValue})`; + } + ); +} + +function rewritePreviewSrcset(srcset: string, input: PreviewRewriteInput): string { + return splitSrcsetCandidates(srcset) + .map((candidate) => rewritePreviewSrcsetCandidate(candidate, input)) + .join(","); +} + +function rewritePreviewSrcsetCandidate(candidate: string, input: PreviewRewriteInput): string { + const leadingWhitespace = candidate.match(/^\s*/)?.[0] ?? ""; + const trimmedCandidate = candidate.trim(); + + if (!trimmedCandidate) { + return candidate; + } + + const urlMatch = /^(\S+)(.*)$/.exec(trimmedCandidate); + if (!urlMatch) { + return candidate; + } + + const rawUrl = urlMatch[1]; + const descriptor = urlMatch[2] ?? ""; + if (!rawUrl) { + return candidate; + } + + return `${leadingWhitespace}${rewritePreviewResourceUrl(rawUrl, input)}${descriptor}`; +} + +function splitSrcsetCandidates(srcset: string): string[] { + const candidates: string[] = []; + let startIndex = 0; + let urlStarted = false; + let seenWhitespaceAfterUrl = false; + let isDataCandidate = false; + + for (let index = 0; index < srcset.length; index += 1) { + const current = srcset[index] ?? ""; + + if (!urlStarted) { + if (/\s/.test(current)) { + continue; + } + + urlStarted = true; + isDataCandidate = srcset.slice(index, index + 5).toLowerCase() === "data:"; + } else if (/\s/.test(current)) { + seenWhitespaceAfterUrl = true; + } + + if (current !== ",") { + continue; + } + + if (isDataCandidate && !seenWhitespaceAfterUrl) { + continue; + } + + candidates.push(srcset.slice(startIndex, index)); + startIndex = index + 1; + urlStarted = false; + seenWhitespaceAfterUrl = false; + isDataCandidate = false; + } + + candidates.push(srcset.slice(startIndex)); + return candidates; +} + +function rewritePreviewResourceUrl(rawValue: string, input: PreviewRewriteInput): string { + const { pathPart, suffix } = splitUrlSuffix(rawValue); + const trimmedValue = pathPart.trim(); + + if (!trimmedValue || trimmedValue.startsWith("//")) { + return rawValue; + } + + if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(trimmedValue)) { + if (!trimmedValue.toLowerCase().startsWith("file:")) { + return rawValue; + } + + try { + const absolutePath = fileURLToPath(trimmedValue); + const workspaceRelativePath = resolveWorkspaceRelativePath( + input.workspaceRootPath, + absolutePath + ); + + if (!workspaceRelativePath) { + return rawValue; + } + + return `${createPreviewAssetUrl(input.sessionId, workspaceRelativePath)}${suffix}`; + } catch { + return rawValue; + } + } + + const decodedPath = decodePathSegments(trimmedValue.replaceAll("\\", "/")); + + if (decodedPath.startsWith("/")) { + const workspaceRelativePath = + resolveWorkspaceRelativePath(input.workspaceRootPath, decodedPath) ?? + normalizeWorkspaceRelativePath(decodedPath.slice(1)); + + if (!workspaceRelativePath) { + return rawValue; + } + + return `${createPreviewAssetUrl(input.sessionId, workspaceRelativePath)}${suffix}`; + } + + if (path.win32.isAbsolute(decodedPath)) { + const workspaceRelativePath = resolveWorkspaceRelativePath( + input.workspaceRootPath, + decodedPath + ); + if (!workspaceRelativePath) { + return rawValue; + } + + return `${createPreviewAssetUrl(input.sessionId, workspaceRelativePath)}${suffix}`; + } + + if (input.baseWorkspacePath) { + const workspaceRelativePath = resolveRelativeWorkspacePath( + input.baseWorkspacePath, + decodedPath + ); + + if (workspaceRelativePath) { + return `${createPreviewAssetUrl(input.sessionId, workspaceRelativePath)}${suffix}`; + } + } + + return rawValue; +} + +function resolveRelativeWorkspacePath( + baseWorkspacePath: string, + relativePath: string +): string | null { + return normalizeWorkspaceRelativePath( + path.posix.join(path.posix.dirname(baseWorkspacePath.replaceAll("\\", "/")), relativePath) + ); +} + +function createPreviewAssetUrl(sessionId: string, workspaceRelativePath: string): string { + return `/api/preview/session/${sessionId}/${encodePathSegments(workspaceRelativePath)}`; +} + +function normalizeWorkspaceRelativePath(rawPath: string): string | null { + const normalized = path.posix.normalize(rawPath.replaceAll("\\", "/").replace(/^\/+/, "")); + + if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../")) { + return null; + } + + return normalized; +} + +function resolveWorkspaceRelativePath( + workspaceRootPath: string, + absolutePath: string +): string | null { + const absoluteWorkspaceRoot = path.resolve(workspaceRootPath); + const absoluteTargetPath = path.resolve(absolutePath); + + if (!isPathInsideRoot(absoluteWorkspaceRoot, absoluteTargetPath)) { + return null; + } + + const workspaceRelativePath = absoluteTargetPath + .slice(absoluteWorkspaceRoot.length) + .replace(/^[/\\]/, "") + .replaceAll("\\", "/"); + + return normalizeWorkspaceRelativePath(workspaceRelativePath); +} + +function splitUrlSuffix(value: string): { pathPart: string; suffix: string } { + const queryIndex = value.indexOf("?"); + const hashIndex = value.indexOf("#"); + + let suffixIndex = value.length; + if (queryIndex !== -1 && hashIndex !== -1) { + suffixIndex = Math.min(queryIndex, hashIndex); + } else if (queryIndex !== -1) { + suffixIndex = queryIndex; + } else if (hashIndex !== -1) { + suffixIndex = hashIndex; + } + + return { + pathPart: value.slice(0, suffixIndex), + suffix: value.slice(suffixIndex), + }; +} + +function decodePathSegments(inputPath: string): string { + return inputPath + .split("/") + .map((segment) => { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } + }) + .join("/"); +} + +function escapeHtmlAttribute(value: string, quote: '"' | "'"): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll(quote, quote === '"' ? """ : "'"); +} + +function escapeCssQuotedUrl(value: string, quote: '"' | "'"): string { + return value.replaceAll("\\", "\\\\").replaceAll(quote, `\\${quote}`); +} diff --git a/packages/server/src/provider-runtime/command-check.ts b/packages/server/src/provider-runtime/command-check.ts index 1623eb40..ba1dbc8f 100644 --- a/packages/server/src/provider-runtime/command-check.ts +++ b/packages/server/src/provider-runtime/command-check.ts @@ -1,21 +1,60 @@ export type CommandAvailabilityCheck = (command: string) => Promise; +import { existsSync as fsExistsSync } from "node:fs"; +import path from "node:path"; import { type CommandRunner, runCommandAsString } from "./command-runner.js"; export interface CommandCheckDeps { platform?: NodeJS.Platform; runCommand?: CommandRunner; + existsSync?: (file: string) => boolean; + pathExt?: string; } export function getCommandLookupExecutable(platform: NodeJS.Platform): "where" | "which" { return platform === "win32" ? "where" : "which"; } +const DEFAULT_PATHEXT = ".COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC"; + +function isAbsoluteForPlatform(value: string, platform: NodeJS.Platform): boolean { + return platform === "win32" ? path.win32.isAbsolute(value) : path.posix.isAbsolute(value); +} + +function parsePathExt(pathExt: string): string[] { + return pathExt + .split(";") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + export async function checkCommandAvailable( command: string, deps: CommandCheckDeps = {} ): Promise { const platform = deps.platform ?? process.platform; + const existsSync = deps.existsSync ?? fsExistsSync; + + // Absolute paths can't be passed to `where`/`which`. On Windows in particular, + // `where.exe` parses the first ':' as a `path:pattern` separator, so any + // `C:\...` argument is rejected with "invalid pattern" — which made every + // managed LSP install fail at the verify step. Resolve such inputs by direct + // filesystem existence checks (mirroring `where`'s PATHEXT fallback on win32). + if (isAbsoluteForPlatform(command, platform)) { + if (existsSync(command)) { + return true; + } + if (platform === "win32" && path.win32.extname(command).length === 0) { + const pathExt = deps.pathExt ?? process.env.PATHEXT ?? DEFAULT_PATHEXT; + for (const ext of parsePathExt(pathExt)) { + if (existsSync(command + ext)) { + return true; + } + } + } + return false; + } + const runCommand = deps.runCommand ?? runCommandAsString; const lookup = getCommandLookupExecutable(platform); diff --git a/packages/server/src/routes/file-asset.test.ts b/packages/server/src/routes/file-asset.test.ts index 2fea5b4c..2969e29d 100644 --- a/packages/server/src/routes/file-asset.test.ts +++ b/packages/server/src/routes/file-asset.test.ts @@ -1,10 +1,13 @@ +import { execFile } from "child_process"; import Fastify, { type FastifyInstance } from "fastify"; import { mkdir, rm, symlink, writeFile } from "fs/promises"; import { tmpdir } from "os"; import { join } from "path"; +import { promisify } from "util"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { registerFileAssetRoutes } from "./file-asset.js"; +const execFileAsync = promisify(execFile); const PNG_BYTES = Buffer.from( "89504E470D0A1A0A0000000D4948445200000001000000010806000000" + "1F15C4890000000A49444154789C63000100000005000157CFC4A30000" + @@ -58,9 +61,6 @@ describe("/api/file", () => { }); it("streams an image from HEAD when revision is provided", async () => { - const execFileAsync = (await import("util")).promisify( - (await import("child_process")).execFile - ); await execFileAsync("git", ["init"], { cwd: testDir }); await execFileAsync("git", ["config", "user.name", "Test"], { cwd: testDir }); await execFileAsync("git", ["config", "user.email", "test@example.com"], { cwd: testDir }); @@ -83,6 +83,26 @@ describe("/api/file", () => { expect(res.rawPayload.equals(PNG_BYTES)).toBe(true); }); + it("streams an image from a strict commit sha revision", async () => { + await execFileAsync("git", ["init"], { cwd: testDir }); + await execFileAsync("git", ["config", "user.name", "Test"], { cwd: testDir }); + await execFileAsync("git", ["config", "user.email", "test@example.com"], { cwd: testDir }); + await writeFile(join(testDir, "pixel.png"), PNG_BYTES); + await execFileAsync("git", ["add", "."], { cwd: testDir }); + await execFileAsync("git", ["commit", "-m", "Add pixel"], { cwd: testDir }); + const sha = (await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: testDir })).stdout.trim(); + app = await buildApp(testDir); + + const res = await app.inject({ + method: "GET", + url: `/api/file?workspaceId=ws-1&path=pixel.png&revision=${sha}`, + }); + + expect(res.statusCode).toBe(200); + expect(res.headers["content-type"]).toBe("image/png"); + expect(res.rawPayload.equals(PNG_BYTES)).toBe(true); + }); + it("returns 400 when workspaceId or path is missing", async () => { app = await buildApp(testDir); diff --git a/packages/server/src/routes/preview.test.ts b/packages/server/src/routes/preview.test.ts index 6c58da7b..c4ef1e3c 100644 --- a/packages/server/src/routes/preview.test.ts +++ b/packages/server/src/routes/preview.test.ts @@ -1,6 +1,7 @@ import { mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { pathToFileURL } from "node:url"; import Fastify from "fastify"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { PreviewSessionStore } from "../preview/session-store.js"; @@ -14,6 +15,13 @@ describe("/api/preview/session", () => { root = join(tmpdir(), `preview-route-${Date.now()}-${Math.random().toString(36).slice(2)}`); await mkdir(join(root, "examples", "demo"), { recursive: true }); await writeFile(join(root, "examples", "demo", "style.css"), "body { color: red; }"); + await writeFile( + join(root, "examples", "demo", "pixel.png"), + Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=", + "base64" + ) + ); app = Fastify({ logger: false }); registerPreviewRoutes(app, { @@ -71,6 +79,91 @@ describe("/api/preview/session", () => { expect(assetRes.body).toContain("color: red"); }); + it("rewrites local HTML image sources through the preview asset route", async () => { + const fileUrl = pathToFileURL(join(root, "examples", "demo", "pixel.png")).href; + const createRes = await app.inject({ + method: "POST", + url: "/api/preview/session", + payload: { + workspaceId: "ws-1", + entryPath: "examples/demo/index.html", + kind: "html", + content: ``, + }, + }); + + const { id, previewUrl } = createRes.json(); + const assetUrl = `/api/preview/session/${id}/examples/demo/pixel.png`; + const entryRes = await app.inject({ method: "GET", url: previewUrl }); + const assetRes = await app.inject({ method: "GET", url: assetUrl }); + + expect(entryRes.statusCode).toBe(200); + expect(entryRes.body).toContain(`id="root" src="${assetUrl}"`); + expect(entryRes.body).toContain(`id="file" src="${assetUrl}"`); + expect(entryRes.body).toContain('id="remote" src="https://example.com/pixel.png"'); + expect(entryRes.body).toContain('id="data" src="data:image/png;base64,abc"'); + expect(assetRes.statusCode).toBe(200); + expect(assetRes.headers["content-type"]).toContain("image/png"); + }); + + it("rewrites local srcset and inline CSS references through the preview asset route", async () => { + const createRes = await app.inject({ + method: "POST", + url: "/api/preview/session", + payload: { + workspaceId: "ws-1", + entryPath: "examples/demo/index.html", + kind: "html", + content: + '
', + }, + }); + + const { id, previewUrl } = createRes.json(); + const assetUrl = `/api/preview/session/${id}/examples/demo/pixel.png`; + const entryRes = await app.inject({ method: "GET", url: previewUrl }); + + expect(entryRes.statusCode).toBe(200); + expect(entryRes.body).toContain(`srcset="${assetUrl} 1x, ${assetUrl} 2x`); + expect(entryRes.body).toContain("https://example.com/remote.png 3x"); + expect(entryRes.body).toContain(`background-image: url("${assetUrl}?v=1#hero")`); + expect(entryRes.body).toContain(`background: url('${assetUrl}')`); + expect(entryRes.body).toContain(`mask-image: url(${assetUrl}#mask)`); + expect(entryRes.body).toContain('url("https://example.com/remote.png")'); + }); + + it("rewrites local url() references inside external CSS assets", async () => { + await writeFile( + join(root, "examples", "demo", "style.css"), + '.hero { background-image: url("/examples/demo/pixel.png?v=2#hero"); } .relative { mask-image: url(./pixel.png#mask); } .remote { background-image: url("https://example.com/remote.png"); }' + ); + + const createRes = await app.inject({ + method: "POST", + url: "/api/preview/session", + payload: { + workspaceId: "ws-1", + entryPath: "examples/demo/index.html", + kind: "html", + content: + 'demo', + }, + }); + + const { id } = createRes.json(); + const assetUrl = `/api/preview/session/${id}/examples/demo/pixel.png`; + const cssRes = await app.inject({ + method: "GET", + url: `/api/preview/session/${id}/examples/demo/style.css`, + }); + + expect(cssRes.statusCode).toBe(200); + expect(cssRes.headers["content-type"]).toContain("text/css"); + expect(cssRes.body).toContain(`background-image: url("${assetUrl}?v=2#hero")`); + expect(cssRes.body).toContain(`mask-image: url(${assetUrl}#mask)`); + expect(cssRes.body).toContain('url("https://example.com/remote.png")'); + }); + it("rejects invalid preview session payloads", async () => { const createRes = await app.inject({ method: "POST", diff --git a/packages/server/src/routes/preview.ts b/packages/server/src/routes/preview.ts index 636929d9..f4d797bb 100644 --- a/packages/server/src/routes/preview.ts +++ b/packages/server/src/routes/preview.ts @@ -1,6 +1,11 @@ import { posix } from "node:path"; import type { FastifyInstance } from "fastify"; import { z } from "zod"; +import { + encodePathSegments, + rewritePreviewCssResourceUrls, + rewritePreviewHtmlResourceUrls, +} from "../preview/html-resource-rewriter.js"; import { renderMarkdownDocument } from "../preview/render-markdown.js"; import { loadPreviewResource, resolvePreviewResourcePath } from "../preview/resource-loader.js"; import { PreviewSessionStore } from "../preview/session-store.js"; @@ -26,13 +31,6 @@ function getPreviewContentSecurityPolicy(): string { return "default-src 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; script-src 'none'; base-uri 'none'; form-action 'none'"; } -function encodePathSegments(path: string): string { - return path - .split("/") - .map((segment) => encodeURIComponent(segment)) - .join("/"); -} - function resolvePreviewAssetWorkspacePath(entryPath: string, rawPath: string): string { const normalizedRawPath = rawPath.replaceAll("\\", "/"); const relativeAssetPath = posix.relative(posix.dirname(entryPath), normalizedRawPath); @@ -123,13 +121,18 @@ export function registerPreviewRoutes( } if ((rawPath ?? "") === session.entryPath) { - const html = + const rawHtml = session.kind === "markdown" ? renderMarkdownDocument({ markdown: session.content, title: session.entryPath, }) : session.content; + const html = rewritePreviewHtmlResourceUrls(rawHtml, { + entryPath: session.entryPath, + sessionId: session.id, + workspaceRootPath: workspace.path, + }); const contentSecurityPolicy = getPreviewContentSecurityPolicy(); const response = reply @@ -147,13 +150,24 @@ export function registerPreviewRoutes( try { const resourcePath = resolvePreviewAssetWorkspacePath(session.entryPath, rawPath); const resource = await loadPreviewResource(workspace.path, resourcePath); + const bytes = + resource.mime === "text/css" + ? Buffer.from( + rewritePreviewCssResourceUrls(resource.bytes.toString("utf-8"), { + baseWorkspacePath: resource.workspaceRelativePath, + sessionId: session.id, + workspaceRootPath: workspace.path, + }), + "utf-8" + ) + : resource.bytes; return reply .header("Content-Type", resource.mime) - .header("Content-Length", String(resource.size)) + .header("Content-Length", String(bytes.byteLength)) .header("Cache-Control", "no-store") .header("X-Content-Type-Options", "nosniff") - .send(resource.bytes); + .send(bytes); } catch (error) { const code = (error as { code?: string })?.code ?? (error as Error).message; if (code === "path_escape") { diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index cbf3044a..0f4ba4a6 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -30,6 +30,10 @@ import { LspToolInstallManager } from "./lsp-tools/install-manager.js"; import { LspToolManager } from "./lsp-tools/manager.js"; import { FileManifestStore } from "./lsp-tools/manifest-store.js"; import { resolveLspToolRoot } from "./lsp-tools/tool-root.js"; +import { HostCollector } from "./monitoring/host-collector.js"; +import { ManagedProcessRegistry } from "./monitoring/managed-process-registry.js"; +import { createProcessTableCollector } from "./monitoring/process-table/index.js"; +import { MonitoringService } from "./monitoring/service.js"; import { runCommandAsString } from "./provider-runtime/command-runner.js"; import { buildCustomProviderDefinition } from "./provider-runtime/custom-provider.js"; import { createE2EProviderMockOverrides } from "./provider-runtime/e2e-provider-mock.js"; @@ -50,6 +54,7 @@ import { UpdateStateRepo } from "./storage/repositories/update-state-repo.js"; import { WorkspaceRepo } from "./storage/repositories/workspace-repo.js"; import { SupervisorManager } from "./supervisor/manager.js"; import * as targetStore from "./supervisor/target-store.js"; +import { SystemDependencyInstallManager } from "./system-deps/install-manager.js"; import { TerminalManager } from "./terminal/manager.js"; import { NodePtyHost } from "./terminal/pty-host.js"; import { UpdateService } from "./update/update-service.js"; @@ -95,6 +100,9 @@ export async function createServer( let workspaceMgr: WorkspaceManager; let commandContext: CommandContext; let lspMgr: LspManager | null = null; + const managedProcessRegistry = new ManagedProcessRegistry({ + now: () => Date.now(), + }); const terminalRepo = new TerminalRepo({ filePath: join(stateRoot, "state", "terminals.json"), @@ -176,6 +184,7 @@ export async function createServer( let supervisorMgr: SupervisorManager | undefined; let updateService: UpdateService | undefined; + let monitoringService: MonitoringService | undefined; workspaceMgr = new WorkspaceManager({ workspaceRepo, @@ -251,7 +260,15 @@ export async function createServer( workspaceMgr: { get: (workspaceId) => workspaceMgr.get(workspaceId) }, eventBus, logger: app.log, - requestTimeoutMs: 2000, + // Semantic queries (hover/definition/references/...) should fail fast so + // the editor's "Loading..." popup doesn't linger. 8s is comfortable for + // any LSP that's actually responsive. + requestTimeoutMs: 8_000, + // The one-off `initialize` request is a different beast — rust-analyzer + // can take 20-30s to scan a Cargo workspace and load proc-macros on + // first boot, and the Vue companion can be slow to start tsserver too. + // 60s is generous but caps the wait when the server is truly dead. + initializeTimeoutMs: 60_000, idleTtlMs: 60_000, restartLimit: 2, lspToolMgr, @@ -282,12 +299,19 @@ export async function createServer( const providerRuntimeDeps: RuntimeStatusDeps = providerMockOverrides ? { commandExists: providerMockOverrides.commandExists, + runCommand: providerMockOverrides.runCommand, } : {}; const providerInstallMgr = new ProviderInstallManager(activeProviderRegistry, { ...providerRuntimeDeps, runCommand: providerMockOverrides?.runCommand ?? runCommandAsString, }); + const systemDependencyInstallMgr = new SystemDependencyInstallManager({ + ...providerRuntimeDeps, + runCommand: providerMockOverrides?.runCommand ?? runCommandAsString, + ptyHost: createPtyHost(), + broadcaster: wsHub, + }); updateService = new UpdateService({ settingsRepo, @@ -307,6 +331,17 @@ export async function createServer( }); updateService.start(); + monitoringService = new MonitoringService({ + broadcaster: wsHub, + settingsRepo, + registry: managedProcessRegistry, + sessionMgr, + workspaceMgr, + terminalMgr, + hostCollector: new HostCollector(), + processCollector: createProcessTableCollector(), + }); + commandContext = { workspaceMgr, sessionMgr, @@ -321,6 +356,7 @@ export async function createServer( autoFetch, providerRuntimeDeps, providerInstallMgr, + systemDependencyInstallMgr, activationMgr, config, lspMgr, @@ -336,9 +372,11 @@ export async function createServer( sessionMgr.setProviderRegistry(providers); supervisorMgr?.setProviderRegistry(providers); }, + monitoringService, }; wsHub.setCommandContext(commandContext); + monitoringService.start(); await app.listen({ host: config.host, @@ -381,6 +419,7 @@ export async function createServer( await lspMgr?.disposeAll(); autoFetch.stop(); supervisorMgr.stop(); + monitoringService?.stop(); updateService?.stop(); terminalMgr.shutdown(); wsHub.destroy(); diff --git a/packages/server/src/storage/repositories/terminal-repo.ts b/packages/server/src/storage/repositories/terminal-repo.ts index abf23e00..a672ae2d 100644 --- a/packages/server/src/storage/repositories/terminal-repo.ts +++ b/packages/server/src/storage/repositories/terminal-repo.ts @@ -8,6 +8,7 @@ export interface NewTerminal { id: string; workspaceId: string; kind: "agent" | "shell"; + pid?: number; cwd: string; argv: string[]; env?: Record; @@ -39,6 +40,7 @@ function isTerminal(value: unknown): value is Terminal { typeof value.id === "string" && typeof value.workspaceId === "string" && (value.kind === "agent" || value.kind === "shell") && + (value.pid === undefined || typeof value.pid === "number") && typeof value.cwd === "string" && Array.isArray(value.argv) && typeof value.cols === "number" && @@ -156,6 +158,7 @@ export class TerminalRepo { id: terminal.id, workspaceId: terminal.workspaceId, kind: terminal.kind, + pid: terminal.pid, cwd: terminal.cwd, argv: terminal.argv, cols: terminal.cols, @@ -177,6 +180,7 @@ export class TerminalRepo { id: terminal.id, workspaceId: terminal.workspaceId, kind: terminal.kind, + pid: terminal.pid, cwd: terminal.cwd, argv: terminal.argv, env: terminal.env, diff --git a/packages/server/src/system-deps/definitions.ts b/packages/server/src/system-deps/definitions.ts new file mode 100644 index 00000000..8d66c3d6 --- /dev/null +++ b/packages/server/src/system-deps/definitions.ts @@ -0,0 +1,31 @@ +import type { SystemDependencyId, SystemDependencyPackageManager } from "@coder-studio/core"; + +export interface SystemDependencyDefinition { + dependencyId: SystemDependencyId; + versionCommand: { file: string; args: string[] }; + docsUrl: string; + manualGuideKeys: string[]; +} + +export const SYSTEM_DEPENDENCY_DEFINITIONS: Record = + { + git: { + dependencyId: "git", + versionCommand: { file: "git", args: ["--version"] }, + docsUrl: "https://git-scm.com/downloads", + manualGuideKeys: ["system_deps.install.git.manual"], + }, + node: { + dependencyId: "node", + versionCommand: { file: "node", args: ["--version"] }, + docsUrl: "https://nodejs.org/en/download", + manualGuideKeys: ["system_deps.install.node.manual"], + }, + }; + +export const PACKAGE_MANAGER_ORDER: Partial< + Record +> = { + darwin: ["brew"], + linux: ["apt-get", "dnf", "yum", "pacman", "zypper"], +}; diff --git a/packages/server/src/system-deps/install-manager.ts b/packages/server/src/system-deps/install-manager.ts new file mode 100644 index 00000000..ddfbe570 --- /dev/null +++ b/packages/server/src/system-deps/install-manager.ts @@ -0,0 +1,702 @@ +import { randomUUID } from "node:crypto"; +import process from "node:process"; +import type { + SystemDependencyId, + SystemDependencyInstallFailure, + SystemDependencyInstallJobSnapshot, + SystemDependencyInstallStepSnapshot, + SystemDependencyPackageManager, +} from "@coder-studio/core"; +import { Topics } from "@coder-studio/core"; +import type { RuntimeStatusDeps } from "../provider-runtime/runtime-status.js"; +import type { PtyHost, PtyProcess } from "../terminal/types.js"; +import type { Broadcaster } from "../ws/hub.js"; +import { SYSTEM_DEPENDENCY_DEFINITIONS } from "./definitions.js"; +import { detectSystemDependencyInteraction } from "./interaction-detector.js"; +import { buildSystemDependencyRuntimeStatus } from "./runtime-status.js"; + +const EXCERPT_LIMIT = 400; + +interface InstallSession { + process: PtyProcess; + seq: number; + routeClientId?: string; +} + +export interface SystemDependencyInstallManagerDeps extends RuntimeStatusDeps { + ptyHost: PtyHost; + broadcaster: Pick; +} + +interface InFlightStart { + ownerId?: string; + promise: Promise; +} + +interface PtyExitEvent { + exitCode: number; + signal?: number; + reason?: "exit" | "pty_disconnected"; +} + +export class SystemDependencyInstallManager { + private readonly jobs = new Map(); + private readonly jobOwnerIds = new Map(); + private readonly activeJobIdsByDependencyId = new Map(); + private readonly inFlightStartsByDependencyId = new Map(); + private readonly sessions = new Map(); + + constructor(private readonly deps: SystemDependencyInstallManagerDeps) {} + + async start( + dependencyId: SystemDependencyId, + ownerId?: string, + routeClientId?: string + ): Promise { + const activeJob = this.getActiveJob(dependencyId); + if (activeJob) { + if (!this.canAccessJob(activeJob.jobId, ownerId)) { + throw { + code: "system_dependency_install_in_progress", + message: `Install already in progress for ${dependencyId}`, + }; + } + this.rebindSessionRouteClient(activeJob.jobId, routeClientId); + return cloneJobSnapshot(activeJob); + } + + const inFlightStart = this.inFlightStartsByDependencyId.get(dependencyId); + if (inFlightStart) { + if (!this.matchesOwner(inFlightStart.ownerId, ownerId)) { + throw { + code: "system_dependency_install_in_progress", + message: `Install already in progress for ${dependencyId}`, + }; + } + const job = await inFlightStart.promise; + this.rebindSessionRouteClient(job.jobId, routeClientId); + return cloneJobSnapshot(job); + } + + const startPromise = this.prepareAndStart(dependencyId, ownerId, routeClientId); + this.inFlightStartsByDependencyId.set(dependencyId, { + ownerId, + promise: startPromise, + }); + + try { + return cloneJobSnapshot(await startPromise); + } finally { + if (this.inFlightStartsByDependencyId.get(dependencyId)?.promise === startPromise) { + this.inFlightStartsByDependencyId.delete(dependencyId); + } + } + } + + get( + jobId: string, + ownerId?: string, + routeClientId?: string + ): SystemDependencyInstallJobSnapshot | undefined { + if (!this.canAccessJob(jobId, ownerId)) { + return undefined; + } + + this.rebindSessionRouteClient(jobId, routeClientId); + const job = this.jobs.get(jobId); + return job ? cloneJobSnapshot(job) : undefined; + } + + async submitInput( + jobId: string, + ownerId: string | undefined, + text: string, + routeClientId?: string + ): Promise { + const job = this.getOwnedJob(jobId, ownerId); + const session = this.sessions.get(jobId); + if (!job || !session) { + throw { + code: "system_dependency_install_job_not_found", + message: `Install job not found: ${jobId}`, + }; + } + + this.rebindSessionRouteClient(jobId, routeClientId); + job.status = "running"; + job.interaction = { kind: "none", echo: false }; + session.process.write(text); + + return cloneJobSnapshot(job); + } + + async cancel( + jobId: string, + ownerId?: string, + routeClientId?: string + ): Promise { + const job = this.getOwnedJob(jobId, ownerId); + if (!job) { + throw { + code: "system_dependency_install_job_not_found", + message: `Install job not found: ${jobId}`, + }; + } + + if (job.status === "succeeded" || job.status === "failed" || job.status === "cancelled") { + return cloneJobSnapshot(job); + } + + this.rebindSessionRouteClient(jobId, routeClientId); + const session = this.sessions.get(jobId); + const installStep = this.getCurrentStep(job); + if (installStep) { + installStep.status = "failed"; + installStep.finishedAt = Date.now(); + installStep.exitCode = 130; + } + + job.status = "cancelled"; + job.interaction = { kind: "none", echo: false }; + job.failure = this.createFailure(job, { + code: "user_cancelled", + message: `Install cancelled for ${job.dependencyId}`, + exitCode: 130, + }); + + this.activeJobIdsByDependencyId.delete(job.dependencyId); + + if (session) { + await session.process.kill("SIGTERM"); + this.sessions.delete(jobId); + } + + return cloneJobSnapshot(job); + } + + private getActiveJob( + dependencyId: SystemDependencyId + ): SystemDependencyInstallJobSnapshot | undefined { + const jobId = this.activeJobIdsByDependencyId.get(dependencyId); + if (!jobId) { + return undefined; + } + + const job = this.jobs.get(jobId); + if (!job) { + this.activeJobIdsByDependencyId.delete(dependencyId); + return undefined; + } + + if (job.status === "running" || job.status === "waiting_input" || job.status === "queued") { + return job; + } + + this.activeJobIdsByDependencyId.delete(dependencyId); + return undefined; + } + + private async prepareAndStart( + dependencyId: SystemDependencyId, + ownerId?: string, + routeClientId?: string + ): Promise { + const runtime = await buildSystemDependencyRuntimeStatus(this.deps); + const entry = runtime.dependencies[dependencyId]; + + if (entry.available) { + const readyJob: SystemDependencyInstallJobSnapshot = { + jobId: randomUUID(), + dependencyId, + status: "succeeded", + packageManager: entry.packageManager, + steps: [], + interaction: { kind: "none", echo: false }, + }; + this.storeJob(readyJob, ownerId); + return readyJob; + } + + if (!entry.autoInstallSupported || !entry.packageManager) { + const failedJob = this.createUnsupportedJob( + dependencyId, + entry.installReadiness === "unsupported_platform" + ? "unsupported_platform" + : "unsupported_package_manager", + entry.packageManager + ); + this.storeJob(failedJob, ownerId); + return failedJob; + } + + return this.spawnInstallJob(dependencyId, entry.packageManager, ownerId, routeClientId); + } + + private createUnsupportedJob( + dependencyId: SystemDependencyId, + code: "unsupported_platform" | "unsupported_package_manager", + packageManager: SystemDependencyPackageManager | undefined + ): SystemDependencyInstallJobSnapshot { + const stepId = `install-${dependencyId}`; + const command = packageManager ?? dependencyId; + + return { + jobId: randomUUID(), + dependencyId, + status: "failed", + packageManager, + currentStepId: stepId, + steps: [ + { + id: stepId, + titleKey: `system_deps.install.step.install.${dependencyId}`, + kind: "install", + command, + args: [], + status: "failed", + finishedAt: Date.now(), + }, + ], + interaction: { kind: "none", echo: false }, + failure: { + code, + dependencyId, + failedStepId: stepId, + message: `Cannot auto-install ${dependencyId}`, + command, + args: [], + packageManager, + manualGuideKeys: SYSTEM_DEPENDENCY_DEFINITIONS[dependencyId].manualGuideKeys, + docUrl: SYSTEM_DEPENDENCY_DEFINITIONS[dependencyId].docsUrl, + }, + }; + } + + private spawnInstallJob( + dependencyId: SystemDependencyId, + packageManager: SystemDependencyPackageManager, + ownerId?: string, + routeClientId?: string + ): SystemDependencyInstallJobSnapshot { + const shellCommand = getInstallShellCommand(packageManager, dependencyId); + const env = getPtyEnv(); + const stepId = `install-${dependencyId}`; + + try { + const ptyProcess = this.deps.ptyHost.spawn(["/bin/sh", "-lc", shellCommand], { + cwd: process.cwd(), + env, + cols: 120, + rows: 30, + }); + + const job: SystemDependencyInstallJobSnapshot = { + jobId: randomUUID(), + dependencyId, + status: "running", + packageManager, + currentStepId: stepId, + steps: [ + { + id: stepId, + titleKey: `system_deps.install.step.install.${dependencyId}`, + kind: "install", + command: "/bin/sh", + args: ["-lc", shellCommand], + status: "running", + startedAt: Date.now(), + }, + { + id: `verify-${dependencyId}`, + titleKey: `system_deps.install.step.verify.${dependencyId}`, + kind: "verify", + command: SYSTEM_DEPENDENCY_DEFINITIONS[dependencyId].versionCommand.file, + args: SYSTEM_DEPENDENCY_DEFINITIONS[dependencyId].versionCommand.args, + status: "pending", + }, + ], + interaction: { kind: "none", echo: false }, + }; + + this.storeJob(job, ownerId); + this.activeJobIdsByDependencyId.set(dependencyId, job.jobId); + this.sessions.set(job.jobId, { process: ptyProcess, seq: 0, routeClientId }); + + ptyProcess.onData((chunk) => { + this.handleOutput(job.jobId, chunk); + }); + ptyProcess.onExit((event) => { + void this.handleExit(job.jobId, event as PtyExitEvent); + }); + + return job; + } catch (error) { + const details = toErrorDetails(error); + const failedJob: SystemDependencyInstallJobSnapshot = { + jobId: randomUUID(), + dependencyId, + status: "failed", + packageManager, + currentStepId: stepId, + steps: [ + { + id: stepId, + titleKey: `system_deps.install.step.install.${dependencyId}`, + kind: "install", + command: "/bin/sh", + args: ["-lc", shellCommand], + status: "failed", + finishedAt: Date.now(), + stdoutExcerpt: excerpt(details.stdout), + stderrExcerpt: excerpt(details.stderr || details.message), + }, + ], + interaction: { kind: "none", echo: false }, + failure: { + code: classifySpawnFailure(details), + dependencyId, + failedStepId: stepId, + message: details.message, + command: "/bin/sh", + args: ["-lc", shellCommand], + packageManager, + manualGuideKeys: SYSTEM_DEPENDENCY_DEFINITIONS[dependencyId].manualGuideKeys, + docUrl: SYSTEM_DEPENDENCY_DEFINITIONS[dependencyId].docsUrl, + stdoutExcerpt: excerpt(details.stdout), + stderrExcerpt: excerpt(details.stderr || details.message), + }, + }; + this.storeJob(failedJob, ownerId); + return failedJob; + } + } + + private handleOutput(jobId: string, chunk: string): void { + const job = this.jobs.get(jobId); + const session = this.sessions.get(jobId); + if (!job || !session) { + return; + } + + session.seq += 1; + if (session.routeClientId) { + this.deps.broadcaster.sendToClient(session.routeClientId, { + kind: "event", + topic: Topics.systemDependencyInstallOutput(jobId), + seq: session.seq, + timestamp: Date.now(), + data: { + jobId, + chunk, + seq: session.seq, + }, + }); + } + + const interaction = detectSystemDependencyInteraction(chunk); + if (interaction.kind !== "none") { + job.status = "waiting_input"; + job.interaction = interaction; + } + + const installStep = job.steps[0]; + if (installStep) { + installStep.stdoutExcerpt = excerpt(chunk); + } + } + + private async handleExit(jobId: string, event: PtyExitEvent): Promise { + const job = this.jobs.get(jobId); + if (!job) { + return; + } + const exitCode = event.exitCode; + + const installStep = job.steps[0]; + if (installStep && installStep.finishedAt === undefined) { + installStep.finishedAt = Date.now(); + installStep.exitCode = exitCode; + if (job.status !== "cancelled") { + installStep.status = exitCode === 0 ? "succeeded" : "failed"; + } + } + + this.sessions.delete(jobId); + + if (job.status === "cancelled") { + this.activeJobIdsByDependencyId.delete(job.dependencyId); + return; + } + + if (exitCode !== 0) { + job.status = "failed"; + job.interaction = { kind: "none", echo: false }; + job.failure = this.createFailure(job, { + code: this.classifyFailureCode(job, event), + message: `Install failed for ${job.dependencyId}`, + exitCode, + }); + this.activeJobIdsByDependencyId.delete(job.dependencyId); + return; + } + + const verifyStep = job.steps[1]; + if (verifyStep) { + job.currentStepId = verifyStep.id; + verifyStep.status = "running"; + verifyStep.startedAt = Date.now(); + } + + const runtime = await buildSystemDependencyRuntimeStatus(this.deps); + const entry = runtime.dependencies[job.dependencyId]; + const latestJob = this.jobs.get(jobId); + + if (!latestJob || latestJob.status === "cancelled") { + this.activeJobIdsByDependencyId.delete(job.dependencyId); + return; + } + + if (verifyStep) { + verifyStep.finishedAt = Date.now(); + verifyStep.stdoutExcerpt = entry.version; + verifyStep.status = entry.available ? "succeeded" : "failed"; + } + + if (!entry.available) { + job.status = "failed"; + job.interaction = { kind: "none", echo: false }; + job.failure = this.createFailure(job, { + code: "verification_failed", + message: `Verification failed for ${job.dependencyId}`, + }); + this.activeJobIdsByDependencyId.delete(job.dependencyId); + return; + } + + job.status = "succeeded"; + job.interaction = { kind: "none", echo: false }; + this.activeJobIdsByDependencyId.delete(job.dependencyId); + } + + private storeJob( + job: SystemDependencyInstallJobSnapshot, + ownerId?: string + ): SystemDependencyInstallJobSnapshot { + this.jobs.set(job.jobId, job); + if (ownerId) { + this.jobOwnerIds.set(job.jobId, ownerId); + } + return job; + } + + private getOwnedJob( + jobId: string, + ownerId?: string + ): SystemDependencyInstallJobSnapshot | undefined { + if (!this.canAccessJob(jobId, ownerId)) { + return undefined; + } + + return this.jobs.get(jobId); + } + + private canAccessJob(jobId: string, ownerId?: string): boolean { + const owner = this.jobOwnerIds.get(jobId); + if (!owner) { + return true; + } + + return owner === ownerId; + } + + private matchesOwner(ownerA?: string, ownerB?: string): boolean { + if (!ownerA && !ownerB) { + return true; + } + + return ownerA === ownerB; + } + + private rebindSessionRouteClient(jobId: string, routeClientId?: string): void { + if (!routeClientId) { + return; + } + + const session = this.sessions.get(jobId); + if (session) { + session.routeClientId = routeClientId; + } + } + + private getCurrentStep( + job: SystemDependencyInstallJobSnapshot + ): SystemDependencyInstallStepSnapshot | undefined { + if (job.currentStepId) { + return job.steps.find((step) => step.id === job.currentStepId); + } + + return job.steps.at(-1); + } + + private createFailure( + job: SystemDependencyInstallJobSnapshot, + input: { + code: SystemDependencyInstallFailure["code"]; + message: string; + exitCode?: number; + } + ): SystemDependencyInstallFailure { + const step = this.getCurrentStep(job); + return { + code: input.code, + dependencyId: job.dependencyId, + failedStepId: step?.id ?? `install-${job.dependencyId}`, + message: input.message, + command: step?.command ?? job.dependencyId, + args: step?.args ?? [], + exitCode: input.exitCode, + packageManager: job.packageManager, + manualGuideKeys: SYSTEM_DEPENDENCY_DEFINITIONS[job.dependencyId].manualGuideKeys, + docUrl: SYSTEM_DEPENDENCY_DEFINITIONS[job.dependencyId].docsUrl, + stdoutExcerpt: step?.stdoutExcerpt, + stderrExcerpt: step?.stderrExcerpt, + }; + } + + private classifyFailureCode( + job: SystemDependencyInstallJobSnapshot, + event: PtyExitEvent + ): SystemDependencyInstallFailure["code"] { + if (event.reason === "pty_disconnected") { + return "pty_disconnected"; + } + + const step = this.getCurrentStep(job); + const haystack = `${step?.stdoutExcerpt ?? ""}\n${step?.stderrExcerpt ?? ""}`.toLowerCase(); + + if ( + haystack.includes("permission denied") || + haystack.includes("eacces") || + haystack.includes("eperm") || + haystack.includes("incorrect password") + ) { + return "permission_denied"; + } + + if ( + haystack.includes("not found") || + haystack.includes("is not recognized") || + haystack.includes("enoent") + ) { + return "command_not_found"; + } + + return "command_failed"; + } +} + +function getInstallShellCommand( + packageManager: SystemDependencyPackageManager, + dependencyId: SystemDependencyId +): string { + const packageName = dependencyId === "git" ? "git" : "node"; + + switch (packageManager) { + case "brew": + return `brew install ${packageName}`; + case "apt-get": + return dependencyId === "git" + ? "sudo apt-get update && sudo apt-get install -y git" + : "sudo apt-get update && sudo apt-get install -y nodejs npm"; + case "dnf": + return `sudo dnf install -y ${dependencyId === "git" ? "git" : "nodejs"}`; + case "yum": + return `sudo yum install -y ${dependencyId === "git" ? "git" : "nodejs"}`; + case "pacman": + return dependencyId === "git" + ? "sudo pacman -Sy --noconfirm git" + : "sudo pacman -Sy --noconfirm nodejs npm"; + case "zypper": + return `sudo zypper --non-interactive install ${dependencyId === "git" ? "git" : "nodejs"}`; + } +} + +function getPtyEnv(): Record { + const env: Record = {}; + + for (const [key, value] of Object.entries(process.env)) { + if (value != null) { + env[key] = value; + } + } + + env.TERM = "xterm-256color"; + env.COLORTERM = "truecolor"; + env.FORCE_COLOR = "3"; + + return env; +} + +function excerpt(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + + return value.length <= EXCERPT_LIMIT ? value : value.slice(-EXCERPT_LIMIT); +} + +function cloneJobSnapshot( + job: SystemDependencyInstallJobSnapshot +): SystemDependencyInstallJobSnapshot { + return structuredClone(job); +} + +function toErrorDetails(error: unknown): { + code?: string; + message: string; + stdout?: string; + stderr?: string; +} { + const candidate = error as { + code?: string; + message?: string; + stdout?: string; + stderr?: string; + }; + + return { + code: candidate.code, + message: candidate.message ?? "Unknown system dependency install error", + stdout: candidate.stdout, + stderr: candidate.stderr, + }; +} + +function classifySpawnFailure(details: { + code?: string; + message: string; + stdout?: string; + stderr?: string; +}): SystemDependencyInstallFailure["code"] { + const haystack = `${details.code ?? ""}\n${details.message}\n${details.stderr ?? ""}\n${ + details.stdout ?? "" + }`.toLowerCase(); + + if ( + haystack.includes("permission denied") || + haystack.includes("eacces") || + haystack.includes("eperm") + ) { + return "permission_denied"; + } + + if ( + haystack.includes("not found") || + haystack.includes("is not recognized") || + haystack.includes("enoent") + ) { + return "command_not_found"; + } + + return "command_failed"; +} diff --git a/packages/server/src/system-deps/interaction-detector.ts b/packages/server/src/system-deps/interaction-detector.ts new file mode 100644 index 00000000..f42beb7e --- /dev/null +++ b/packages/server/src/system-deps/interaction-detector.ts @@ -0,0 +1,31 @@ +import type { SystemDependencyInstallInteraction } from "@coder-studio/core"; + +const SUDO_PASSWORD_PATTERNS = [/\[sudo\] password for .*:$/i, /^password:$/i]; +const CONFIRM_PATTERNS = [/proceed\?\s*\[[^\]]+\]$/i, /continue\?\s*\[[^\]]+\]$/i]; + +export function detectSystemDependencyInteraction( + chunk: string +): SystemDependencyInstallInteraction { + const trimmed = chunk.trim(); + + if (SUDO_PASSWORD_PATTERNS.some((pattern) => pattern.test(trimmed))) { + return { + kind: "sudo_password", + promptExcerpt: trimmed, + echo: false, + }; + } + + if (CONFIRM_PATTERNS.some((pattern) => pattern.test(trimmed))) { + return { + kind: "confirm", + promptExcerpt: trimmed, + echo: true, + }; + } + + return { + kind: "none", + echo: false, + }; +} diff --git a/packages/server/src/system-deps/runtime-status.ts b/packages/server/src/system-deps/runtime-status.ts new file mode 100644 index 00000000..dbaf1eb5 --- /dev/null +++ b/packages/server/src/system-deps/runtime-status.ts @@ -0,0 +1,107 @@ +import { + SYSTEM_DEPENDENCY_IDS, + type SystemDependencyId, + type SystemDependencyPackageManager, + type SystemDependencyRuntimeEntry, + type SystemDependencyRuntimeStatusResponse, +} from "@coder-studio/core"; +import { + type CommandAvailabilityCheck, + checkCommandAvailable, +} from "../provider-runtime/command-check.js"; +import { runCommandAsString } from "../provider-runtime/command-runner.js"; +import type { RuntimeStatusDeps } from "../provider-runtime/runtime-status.js"; +import { PACKAGE_MANAGER_ORDER, SYSTEM_DEPENDENCY_DEFINITIONS } from "./definitions.js"; + +async function readVersion( + dependencyId: SystemDependencyId, + deps: RuntimeStatusDeps +): Promise { + const definition = SYSTEM_DEPENDENCY_DEFINITIONS[dependencyId]; + const runner = deps.runCommand ?? runCommandAsString; + + try { + const { stdout } = await runner( + definition.versionCommand.file, + definition.versionCommand.args, + { + windowsHide: true, + } + ); + const version = stdout.trim(); + return version.length > 0 ? version : undefined; + } catch { + return undefined; + } +} + +function getCommandExists(deps: RuntimeStatusDeps): CommandAvailabilityCheck { + return deps.commandExists ?? ((command: string) => checkCommandAvailable(command, deps)); +} + +async function detectPackageManager( + platform: NodeJS.Platform, + commandExists: CommandAvailabilityCheck +): Promise { + const candidates = PACKAGE_MANAGER_ORDER[platform] ?? []; + + for (const candidate of candidates) { + if (await commandExists(candidate)) { + return candidate; + } + } + + return undefined; +} + +async function buildDependencyEntry( + dependencyId: SystemDependencyId, + deps: RuntimeStatusDeps, + platform: NodeJS.Platform, + commandExists: CommandAvailabilityCheck, + packageManager: SystemDependencyPackageManager | undefined +): Promise { + const definition = SYSTEM_DEPENDENCY_DEFINITIONS[dependencyId]; + const available = await commandExists(definition.versionCommand.file); + const version = available ? await readVersion(dependencyId, deps) : undefined; + + return { + dependencyId, + available, + version, + autoInstallSupported: !available && Boolean(packageManager), + installReadiness: available + ? "ready" + : packageManager + ? "ready" + : platform === "darwin" || platform === "linux" + ? "unsupported_package_manager" + : "unsupported_platform", + packageManager, + manualGuideKeys: definition.manualGuideKeys, + docUrl: definition.docsUrl, + }; +} + +export async function buildSystemDependencyRuntimeStatus( + deps: RuntimeStatusDeps = {} +): Promise { + const platform = deps.platform ?? process.platform; + const commandExists = getCommandExists(deps); + const packageManager = await detectPackageManager(platform, commandExists); + const dependencyEntries = await Promise.all( + SYSTEM_DEPENDENCY_IDS.map( + async (dependencyId) => + [ + dependencyId, + await buildDependencyEntry(dependencyId, deps, platform, commandExists, packageManager), + ] as const + ) + ); + const dependencies = Object.fromEntries(dependencyEntries) as Record< + SystemDependencyId, + SystemDependencyRuntimeEntry + >; + + return { dependencies }; +} diff --git a/packages/server/src/terminal/active-terminal.test.ts b/packages/server/src/terminal/active-terminal.test.ts index 570c73d7..fa1f8cce 100644 --- a/packages/server/src/terminal/active-terminal.test.ts +++ b/packages/server/src/terminal/active-terminal.test.ts @@ -7,6 +7,7 @@ import type { PtyProcess, TerminalSpec } from "./types"; describe("ActiveTerminal", () => { const mockPty: PtyProcess = { + pid: 43210, onData: () => {}, onExit: () => {}, write: () => {}, @@ -52,6 +53,7 @@ describe("ActiveTerminal", () => { id, workspaceId: spec.workspaceId, kind: spec.kind, + pid: 43210, title: spec.title, cwd: spec.cwd, argv: spec.argv, diff --git a/packages/server/src/terminal/active-terminal.ts b/packages/server/src/terminal/active-terminal.ts index 830bc03c..e1d8078c 100644 --- a/packages/server/src/terminal/active-terminal.ts +++ b/packages/server/src/terminal/active-terminal.ts @@ -37,10 +37,13 @@ export class ActiveTerminal { * Convert to DTO (Data Transfer Object) for external use */ toDTO(): Terminal { + const pid = this.pty.pid > 0 ? this.pty.pid : undefined; + return { id: this.id, workspaceId: this.spec.workspaceId, kind: this.spec.kind, + pid, title: this.spec.title ?? this.spec.argv.join(" "), cwd: this.spec.cwd, argv: this.spec.argv, diff --git a/packages/server/src/terminal/manager.test.ts b/packages/server/src/terminal/manager.test.ts index c07c6897..ef9bb5b9 100644 --- a/packages/server/src/terminal/manager.test.ts +++ b/packages/server/src/terminal/manager.test.ts @@ -18,6 +18,7 @@ describe("TerminalManager", () => { beforeEach(() => { // Create mock PTY process mockPty = { + pid: 43210, onData: vi.fn(), onExit: vi.fn(), write: vi.fn(), @@ -60,6 +61,7 @@ describe("TerminalManager", () => { expect(terminal.id).toBeDefined(); expect(terminal.workspaceId).toBe(spec.workspaceId); expect(terminal.kind).toBe(spec.kind); + expect(terminal.pid).toBe(43210); expect(terminal.argv).toEqual(spec.argv); expect(terminal.cwd).toBe(spec.cwd); expect(terminal.alive).toBe(true); diff --git a/packages/server/src/terminal/pty-host.ts b/packages/server/src/terminal/pty-host.ts index 3c0d2f20..07addc5e 100644 --- a/packages/server/src/terminal/pty-host.ts +++ b/packages/server/src/terminal/pty-host.ts @@ -225,11 +225,14 @@ export class NodePtyHost implements PtyHost { }); return { + pid: ptyProcess.pid, onData: (callback) => { ptyProcess.onData(callback); }, onExit: (callback) => { - ptyProcess.onExit(({ exitCode }: { exitCode: number }) => callback({ exitCode })); + ptyProcess.onExit(({ exitCode, signal }: { exitCode: number; signal?: number }) => + callback({ exitCode, signal, reason: "exit" }) + ); }, write: (data) => { if (Buffer.isBuffer(data)) { diff --git a/packages/server/src/terminal/types.ts b/packages/server/src/terminal/types.ts index 7f2825b8..36ab4364 100644 --- a/packages/server/src/terminal/types.ts +++ b/packages/server/src/terminal/types.ts @@ -94,8 +94,15 @@ export class TerminalSpawnError extends Error { * PTY process interface (abstraction over node-pty) */ export interface PtyProcess { + readonly pid: number; onData(callback: (data: string) => void): void; - onExit(callback: (event: { exitCode: number }) => void): void; + onExit( + callback: (event: { + exitCode: number; + signal?: number; + reason?: "exit" | "pty_disconnected"; + }) => void + ): void; write(data: Buffer | string): void; resize(cols: number, rows: number): void; kill(signal?: NodeJS.Signals): Promise; diff --git a/packages/server/src/workspace/manager.ts b/packages/server/src/workspace/manager.ts index c015b4b0..8ae58983 100644 --- a/packages/server/src/workspace/manager.ts +++ b/packages/server/src/workspace/manager.ts @@ -147,6 +147,7 @@ export class WorkspaceManager { paneLayout: { id: "root", type: "leaf", + leafKind: "draft", }, }, }; diff --git a/packages/server/src/workspace/pane-layout.ts b/packages/server/src/workspace/pane-layout.ts index 1289c3ea..b01de8fe 100644 --- a/packages/server/src/workspace/pane-layout.ts +++ b/packages/server/src/workspace/pane-layout.ts @@ -2,6 +2,14 @@ import type { WorkspacePaneNode } from "@coder-studio/core"; export type PaneDisposition = "draft" | "remove"; +function isLegacyLeaf(node: WorkspacePaneNode): boolean { + return node.type === "leaf" && node.leafKind === undefined; +} + +function createDraftLeaf(id: string, legacy = false): WorkspacePaneNode { + return legacy ? { id, type: "leaf" } : { id, type: "leaf", leafKind: "draft" }; +} + export function applyPaneDisposition( layout: WorkspacePaneNode | undefined, sessionId: string, @@ -22,11 +30,8 @@ function closePaneBySessionId(node: WorkspacePaneNode, sessionId: string): Works function replaceSessionWithDraft(node: WorkspacePaneNode, sessionId: string): WorkspacePaneNode { if (node.type === "leaf") { - if (node.sessionId === sessionId) { - return { - id: node.id, - type: "leaf", - }; + if ("sessionId" in node && node.sessionId === sessionId) { + return createDraftLeaf(node.id, isLegacyLeaf(node)); } return node; } @@ -52,12 +57,12 @@ function replaceSessionWithDraft(node: WorkspacePaneNode, sessionId: string): Wo } function removePaneBySessionId(node: WorkspacePaneNode, sessionId: string): WorkspacePaneNode { - return removeSessionPane(node, sessionId) ?? { id: node.id, type: "leaf" }; + return removeSessionPane(node, sessionId) ?? createDraftLeaf(node.id, isLegacyLeaf(node)); } function removeSessionPane(node: WorkspacePaneNode, sessionId: string): WorkspacePaneNode | null { if (node.type === "leaf") { - if (node.sessionId === sessionId) { + if ("sessionId" in node && node.sessionId === sessionId) { return null; } return node; diff --git a/packages/server/src/ws/dispatch.ts b/packages/server/src/ws/dispatch.ts index 8377980b..2ecc1a0e 100644 --- a/packages/server/src/ws/dispatch.ts +++ b/packages/server/src/ws/dispatch.ts @@ -12,6 +12,7 @@ import type { AutoFetchRuntime } from "../git/auto-fetch.js"; import type { LspManager } from "../lsp/manager.js"; import type { LspToolInstallManager } from "../lsp-tools/install-manager.js"; import type { LspToolManager } from "../lsp-tools/manager.js"; +import type { MonitoringService } from "../monitoring/service.js"; import type { ProviderInstallManager } from "../provider-runtime/install-manager.js"; import type { RuntimeStatusDeps } from "../provider-runtime/runtime-status.js"; import type { SessionManager } from "../session/manager.js"; @@ -20,6 +21,7 @@ import type { ProviderConfigRepo } from "../storage/repositories/provider-config import type { SessionMetadataRepo } from "../storage/repositories/session-metadata-repo.js"; import type { SettingsRepo } from "../storage/repositories/settings-repo.js"; import type { SupervisorManager } from "../supervisor/manager.js"; +import type { SystemDependencyInstallManager } from "../system-deps/install-manager.js"; import type { TerminalManager } from "../terminal/manager.js"; import type { UpdateService } from "../update/update-service.js"; import type { WorkspaceManager } from "../workspace/manager.js"; @@ -44,6 +46,7 @@ export interface CommandContext { autoFetch: AutoFetchRuntime; providerRuntimeDeps?: RuntimeStatusDeps; providerInstallMgr?: ProviderInstallManager; + systemDependencyInstallMgr?: SystemDependencyInstallManager; activationMgr: ActivationManager; config?: Pick; lspMgr: LspManager; @@ -53,6 +56,7 @@ export interface CommandContext { customProviderRepo?: CustomProviderRepo; sessionMetadataRepo?: SessionMetadataRepo; setProviderRegistry?: (providers: ProviderDefinition[]) => void; + monitoringService?: MonitoringService; } /** diff --git a/packages/utils/src/windows-shim-resolver.test.ts b/packages/utils/src/windows-shim-resolver.test.ts index 8d82ec51..af6616f1 100644 --- a/packages/utils/src/windows-shim-resolver.test.ts +++ b/packages/utils/src/windows-shim-resolver.test.ts @@ -102,7 +102,10 @@ describe("resolveSpawnArgv", () => { it("parses an npm-style .cmd shim and spawns node + entry directly", () => { const shimPath = "C:\\Users\\u\\AppData\\Local\\fnm\\codex.cmd"; const fs = createMockFs({ - files: { [shimPath]: STANDARD_NPM_CMD_SHIM }, + files: { + [shimPath]: STANDARD_NPM_CMD_SHIM, + "C:\\Users\\u\\AppData\\Local\\fnm\\node.exe": "", + }, }); const out = resolveSpawnArgv(["codex", "--model", "gpt-4"], { platform: "win32", @@ -121,7 +124,10 @@ describe("resolveSpawnArgv", () => { it("parses the SETLOCAL/find_dp0 variant of cmd-shim", () => { const shimPath = "C:\\bin\\codex.cmd"; const fs = createMockFs({ - files: { [shimPath]: NPM_CMD_SHIM_VARIANT }, + files: { + [shimPath]: NPM_CMD_SHIM_VARIANT, + "C:\\bin\\node.exe": "", + }, }); const out = resolveSpawnArgv(["codex"], { platform: "win32", @@ -173,6 +179,7 @@ describe("resolveSpawnArgv", () => { files: { "C:\\bin\\foo.cmd": STANDARD_NPM_CMD_SHIM, "C:\\bin\\foo.exe": "", + "C:\\bin\\node.exe": "", }, }); const out = resolveSpawnArgv(["foo.cmd"], { @@ -201,7 +208,12 @@ describe("resolveSpawnArgv", () => { it("when argv[0] is already an absolute .cmd, parses it directly", () => { const abs = "C:\\bin\\codex.cmd"; - const fs = createMockFs({ files: { [abs]: STANDARD_NPM_CMD_SHIM } }); + const fs = createMockFs({ + files: { + [abs]: STANDARD_NPM_CMD_SHIM, + "C:\\bin\\node.exe": "", + }, + }); const out = resolveSpawnArgv([abs], { platform: "win32", pathEnv: "", @@ -241,9 +253,57 @@ describe("resolveSpawnArgv", () => { expect(out).toEqual(["C:\\bin\\codex.exe"]); }); + it("falls back to node on PATH when shim's node.exe does not exist", () => { + // Real-world case: the official Node MSI installer puts node.exe in + // `C:\Program Files\nodejs`, but npm-installed CLIs live as cmd-shims + // in `%APPDATA%\npm`. The shim's hard-coded `%~dp0\node.exe` does not + // exist, so we must fall back to whatever `node` is on PATH. + const shimPath = "C:\\Users\\u\\AppData\\Roaming\\npm\\codex.cmd"; + const realNode = "C:\\Program Files\\nodejs\\node.exe"; + const fs = createMockFs({ + files: { + [shimPath]: STANDARD_NPM_CMD_SHIM, + [realNode]: "", + }, + }); + const out = resolveSpawnArgv(["codex", "--help"], { + platform: "win32", + pathEnv: "C:\\Users\\u\\AppData\\Roaming\\npm;C:\\Program Files\\nodejs", + pathExt: ".COM;.EXE;.CMD", + readFileSync: fs.readFileSync, + existsSync: fs.existsSync, + }); + expect(out[0]).toBe(realNode); + expect(out[1].toLowerCase()).toBe( + "c:\\users\\u\\appdata\\roaming\\@openai\\codex\\bin\\codex.js".toLowerCase() + ); + expect(out.slice(2)).toEqual(["--help"]); + }); + + it("falls back to cmd.exe when shim's node.exe is missing and node is not on PATH", () => { + // Worst case: no node anywhere we can find. Hand the shim to cmd.exe so + // cmd.exe can run the shim's own IF EXIST / ELSE dispatch (which may + // succeed via the environment in ways we can't see from here). + const shimPath = "C:\\Users\\u\\AppData\\Roaming\\npm\\codex.cmd"; + const fs = createMockFs({ + files: { [shimPath]: STANDARD_NPM_CMD_SHIM }, + }); + const out = resolveSpawnArgv(["codex", "arg1"], { + platform: "win32", + pathEnv: "C:\\Users\\u\\AppData\\Roaming\\npm", + pathExt: ".COM;.EXE;.CMD", + readFileSync: fs.readFileSync, + existsSync: fs.existsSync, + }); + expect(out).toEqual(["cmd.exe", "/d", "/s", "/c", shimPath, "arg1"]); + }); + it("matches PATHEXT case-insensitively", () => { const fs = createMockFs({ - files: { "C:\\bin\\codex.CMD": STANDARD_NPM_CMD_SHIM }, + files: { + "C:\\bin\\codex.CMD": STANDARD_NPM_CMD_SHIM, + "C:\\bin\\node.exe": "", + }, }); const out = resolveSpawnArgv(["codex"], { platform: "win32", diff --git a/packages/utils/src/windows-shim-resolver.ts b/packages/utils/src/windows-shim-resolver.ts index 7bfa194f..0320b858 100644 --- a/packages/utils/src/windows-shim-resolver.ts +++ b/packages/utils/src/windows-shim-resolver.ts @@ -67,7 +67,23 @@ export function resolveSpawnArgv( } const parsed = parseCmdShim(resolved, content); if (parsed) { - return [parsed.node, parsed.entry, ...restArgs]; + // The standard npm cmd-shim hard-codes `"%~dp0\node.exe"`, but that + // path only really exists when node was installed alongside the shim + // directory (fnm / nvs / nvm-windows). Users who installed Node via + // the official MSI, Chocolatey, or Scoop don't have a `node.exe` in + // `%APPDATA%\npm`, and CreateProcess fails with ENOENT. + // + // The shim itself handles this at runtime via `IF EXIST … ELSE node`. + // We mirror that logic here: verify the parsed node path, then fall + // back to a `node` on PATH, then finally to a `cmd.exe` wrapper that + // lets cmd.exe execute the shim's own IF/ELSE. + if (existsSync(parsed.node)) { + return [parsed.node, parsed.entry, ...restArgs]; + } + const nodeOnPath = resolveExecutablePath("node", pathEnv, pathExt, existsSync); + if (nodeOnPath) { + return [nodeOnPath, parsed.entry, ...restArgs]; + } } return ["cmd.exe", "/d", "/s", "/c", resolved, ...restArgs]; } diff --git a/packages/utils/src/windows-shim.test.ts b/packages/utils/src/windows-shim.test.ts index 8a21ef06..a14a32d6 100644 --- a/packages/utils/src/windows-shim.test.ts +++ b/packages/utils/src/windows-shim.test.ts @@ -15,6 +15,11 @@ describe("shouldUseShellForCommand", () => { expect(shouldUseShellForCommand("git", "win32")).toBe(false); }); + it("uses a shell for explicit Windows cmd-shim paths", () => { + expect(shouldUseShellForCommand("C:\\tools\\vue-language-server.cmd", "win32")).toBe(true); + expect(shouldUseShellForCommand("C:\\tools\\npm.BAT", "win32")).toBe(true); + }); + it("does not use a shell on POSIX platforms", () => { expect(shouldUseShellForCommand("pnpm", "linux")).toBe(false); expect(shouldUseShellForCommand("pnpm", "darwin")).toBe(false); diff --git a/packages/utils/src/windows-shim.ts b/packages/utils/src/windows-shim.ts index 82ad9fa6..eeb76b3a 100644 --- a/packages/utils/src/windows-shim.ts +++ b/packages/utils/src/windows-shim.ts @@ -8,11 +8,23 @@ * must keep shell:false to avoid breaking argument escaping. */ +import { basename, extname } from "node:path"; + const WINDOWS_CMD_SHIMS = new Set(["pnpm", "npm", "npx"]); +const WINDOWS_SHELL_EXTENSIONS = new Set([".cmd", ".bat"]); export function shouldUseShellForCommand( command: string, platform: NodeJS.Platform = process.platform ): boolean { - return platform === "win32" && WINDOWS_CMD_SHIMS.has(command.toLowerCase()); + if (platform !== "win32") { + return false; + } + + const normalizedCommand = command.toLowerCase(); + if (WINDOWS_CMD_SHIMS.has(normalizedCommand)) { + return true; + } + + return WINDOWS_SHELL_EXTENSIONS.has(extname(basename(normalizedCommand))); } diff --git a/packages/web/src/app/providers.test.tsx b/packages/web/src/app/providers.test.tsx index 121c093a..de2d44df 100644 --- a/packages/web/src/app/providers.test.tsx +++ b/packages/web/src/app/providers.test.tsx @@ -13,7 +13,11 @@ import { paneLayoutAtomFamily } from "../features/agent-panes/atoms/pane-layout" import { supervisorsAtom } from "../features/supervisor/atoms"; import { terminalMetaAtomFamily } from "../features/terminal-panel/atoms"; import { updateStateAtom } from "../features/updates/atoms"; -import { fileTreeStaleAtomFamily } from "../features/workspace/atoms"; +import { + activeFilePathAtomFamily, + fileTreeStaleAtomFamily, + openEditorPathsAtomFamily, +} from "../features/workspace/atoms"; import { resetAppProvidersSingletonsForTests, routeEventToAtom } from "./providers"; describe("routeEventToAtom", () => { @@ -161,10 +165,83 @@ describe("routeEventToAtom", () => { expect(store.get(paneLayoutAtomFamily("ws-1"))).toEqual({ id: "root", type: "leaf", + leafKind: "session", sessionId: "sess-1", }); }); + it("preserves typed pane leaf kinds from workspace meta updates", () => { + const store = createStore(); + + routeEventToAtom( + "workspace.ws-1.meta", + { + path: "/tmp/ws-1", + targetRuntime: "native", + uiState: { + leftPanelWidth: 280, + bottomPanelHeight: 200, + focusMode: false, + paneLayout: { + id: "root", + type: "split", + direction: "horizontal", + children: [ + { id: "left", type: "leaf", leafKind: "draft" }, + { id: "right", type: "leaf", leafKind: "editor" }, + ], + }, + }, + }, + store + ); + + expect(store.get(paneLayoutAtomFamily("ws-1"))).toEqual({ + id: "root", + type: "split", + direction: "horizontal", + children: [ + { id: "left", type: "leaf", leafKind: "draft" }, + { id: "right", type: "leaf", leafKind: "editor" }, + ], + }); + }); + + it("projects workspace open editor metadata into editor atoms", () => { + const store = createStore(); + + routeEventToAtom( + "workspace.ws-1.meta", + { + path: "/tmp/ws-1", + targetRuntime: "native", + uiState: { + leftPanelWidth: 280, + bottomPanelHeight: 200, + focusMode: false, + openEditorPaths: ["src/app.tsx", "README.md", "src/app.tsx", ""], + activeEditorPath: "src/app.tsx", + }, + }, + store + ); + + expect(store.get(openEditorPathsAtomFamily("ws-1"))).toEqual(["src/app.tsx", "README.md"]); + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBe("src/app.tsx"); + }); + + it("keeps local open editor metadata when a workspace meta patch omits editor fields", () => { + const store = createStore(); + store.set(openEditorPathsAtomFamily("ws-1"), ["src/current.ts"]); + store.set(activeFilePathAtomFamily("ws-1"), "src/current.ts"); + + routeEventToAtom("workspace.ws-1.meta", { path: "/tmp/ws-1", targetRuntime: "native" }, store); + routeEventToAtom("workspace.ws-1.meta", { name: "Renamed workspace" }, store); + + expect(store.get(openEditorPathsAtomFamily("ws-1"))).toEqual(["src/current.ts"]); + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBe("src/current.ts"); + }); + it("marks the file tree stale when an fs.dirty event arrives", () => { const store = createStore(); diff --git a/packages/web/src/app/providers.tsx b/packages/web/src/app/providers.tsx index 5141eda3..60c9a287 100644 --- a/packages/web/src/app/providers.tsx +++ b/packages/web/src/app/providers.tsx @@ -47,7 +47,10 @@ import { import { appearancePersonalizationAtom, authenticatedAtom, themeAtom } from "../atoms/app-ui"; import type { DispatchCommand } from "../atoms/connection"; import { activeWorkspaceIdAtom } from "../atoms/workspaces"; -import { type PaneNode, paneLayoutAtomFamily } from "../features/agent-panes/atoms/pane-layout"; +import { + normalizePaneLayout, + paneLayoutAtomFamily, +} from "../features/agent-panes/atoms/pane-layout"; import { monacoModelRegistry } from "../features/code-editor/monaco/model-registry"; import { useSessionNotifications } from "../features/notifications"; import { supervisorsAtom } from "../features/supervisor/atoms"; @@ -69,6 +72,10 @@ import { setGlobalRecoveryCoordinator, } from "../features/terminal-panel/recovery-singleton"; import { updateStateAtom } from "../features/updates/atoms"; +import { + hydrateWorkspaceEditorState, + normalizeWorkspaceEditorUiState, +} from "../features/workspace/actions/open-editor-state"; import { editorRefreshTokenAtomFamily, expandedDirsAtomFamily, @@ -80,7 +87,6 @@ import { worktreeListAtomFamily, } from "../features/workspace/atoms"; import { useActivation } from "../hooks/use-activation"; -import { useTranslation } from "../lib/i18n"; import { getThemeById, resolveStoredThemeId } from "../theme"; import type { ConnectionStatus, EventListener } from "../ws"; import { resolveWsUrl, WsClient } from "../ws"; @@ -267,7 +273,6 @@ interface AppProvidersProps { } export function AppProviders({ children }: AppProvidersProps) { - const t = useTranslation(); const [, setWsClient] = useAtom(wsClientAtom); const [theme, setTheme] = useAtom(themeAtom); const authEnabled = useAtomValue(authEnabledAtom); @@ -485,6 +490,17 @@ export function AppProviders({ children }: AppProvidersProps) { void claim(); }, [claim, connectionStatus, store]); + // Forward activation status transitions to the recovery coordinator so that + // any recovery deferred during the post-reconnect "no lease yet" window can + // resume once the client has re-claimed the activation lease. Without this + // the coordinator would either surface a spurious "terminal recovery check + // failed" notice or — after the activation-aware defer landed — stay stuck + // in loading because nothing else would re-trigger reconcile when the + // session is idle. + useEffect(() => { + getGlobalRecoveryCoordinator()?.handleActivationStatus(activationStatus); + }, [activationStatus]); + // Initialize theme from localStorage useEffect(() => { preferPersistedThemeOnFirstHydrationRef.current = @@ -1262,18 +1278,27 @@ export function routeEventToAtom(topic: string, payload: unknown, store: Store): return; } + const normalizedPatch: Partial = patch.uiState + ? { + ...patch, + uiState: normalizeWorkspaceEditorUiState(patch.uiState), + } + : patch; + store.set(workspacesAtom, (prev: Record) => ({ ...prev, [workspaceId]: { ...prev[workspaceId], - ...patch, + ...normalizedPatch, id: workspaceId, } as Workspace, })); - const paneLayout = patch.uiState?.paneLayout; - if (paneLayout) { - store.set(paneLayoutAtomFamily(workspaceId), normalizePaneLayout(paneLayout)); + const paneLayout = normalizedPatch.uiState?.paneLayout; + const normalizedPaneLayout = paneLayout ? normalizePaneLayout(paneLayout) : null; + if (normalizedPaneLayout) { + store.set(paneLayoutAtomFamily(workspaceId), normalizedPaneLayout); } + hydrateWorkspaceEditorState(store, workspaceId, normalizedPatch.uiState); store.set(workspaceOrderAtom, (prev: string[]) => { if (prev.includes(workspaceId)) { return prev; @@ -1417,13 +1442,3 @@ export function routeEventToAtom(topic: string, payload: unknown, store: Store): // Unknown topic - log for debugging console.log(`Unhandled event topic: ${topic}`, payload); } - -function normalizePaneLayout(layout: Workspace["uiState"]["paneLayout"]): PaneNode { - return { - id: layout?.id ?? "root", - type: layout?.type ?? "leaf", - sessionId: layout?.sessionId, - direction: layout?.direction, - children: layout?.children?.map((child) => normalizePaneLayout(child)), - }; -} diff --git a/packages/web/src/features/agent-panes/actions/pane-drag-types.ts b/packages/web/src/features/agent-panes/actions/pane-drag-types.ts index b18a8193..6921c259 100644 --- a/packages/web/src/features/agent-panes/actions/pane-drag-types.ts +++ b/packages/web/src/features/agent-panes/actions/pane-drag-types.ts @@ -1,6 +1,6 @@ export type PaneDropPlacement = "left" | "right" | "top" | "bottom" | "center"; -export type PaneDropTargetType = "session" | "draft"; +export type PaneDropTargetType = "session" | "draft" | "editor"; export interface PaneDropIntent { sourcePaneId: string; diff --git a/packages/web/src/features/agent-panes/actions/use-pane-actions.ts b/packages/web/src/features/agent-panes/actions/use-pane-actions.ts index a4ea6bad..65d07364 100644 --- a/packages/web/src/features/agent-panes/actions/use-pane-actions.ts +++ b/packages/web/src/features/agent-panes/actions/use-pane-actions.ts @@ -8,13 +8,16 @@ import { appendSessionToWidestColumn, assignSessionToPane, closeDraftPaneById, + closeEditorPaneById, closePaneBySessionId, - insertPaneAtEdge, - moveSessionToDraftPane, + convertDraftPaneToEditor, + enforceSingleEditorPaneInvariant, + insertPaneAtEdge as insertPaneNodeAtEdge, removePaneBySessionId, replaceSessionInPane, splitPaneByPaneId, splitPaneBySessionId, + swapPaneLeavesByPaneId, swapPaneSessionsByPaneId, } from "../pane-layout-tree"; import type { PaneDropPlacement } from "./pane-drag-types"; @@ -28,9 +31,10 @@ export function usePaneActions(workspaceId: string) { (update: PaneNode | ((current: PaneNode) => PaneNode)) => { const current = store.get(paneLayoutAtomFamily(workspaceId)); const next = typeof update === "function" ? update(current) : update; - setPaneLayout(next); - void persistUiState({ paneLayout: next }); - return next; + const normalized = enforceSingleEditorPaneInvariant(next); + setPaneLayout(normalized); + void persistUiState({ paneLayout: normalized }); + return normalized; }, [persistUiState, setPaneLayout, store, workspaceId] ); @@ -63,6 +67,13 @@ export function usePaneActions(workspaceId: string) { [applyLayout] ); + const closeEditorPane = useCallback( + (paneId: string) => { + applyLayout((current) => closeEditorPaneById(current, paneId)); + }, + [applyLayout] + ); + const removeSessionPane = useCallback( (sessionId: string) => { applyLayout((current) => removePaneBySessionId(current, sessionId)); @@ -77,11 +88,19 @@ export function usePaneActions(workspaceId: string) { [applyLayout] ); + const convertDraftPane = useCallback( + (paneId: string) => { + applyLayout((current) => convertDraftPaneToEditor(current, paneId)); + }, + [applyLayout] + ); + const replaceWithSession = useCallback( (sessionId: string) => { applyLayout({ id: "root", type: "leaf", + leafKind: "session", sessionId, }); }, @@ -122,20 +141,22 @@ export function usePaneActions(workspaceId: string) { [applyLayout] ); - const moveSessionToDraft = useCallback( + const swapPaneLeaves = useCallback( (sourcePaneId: string, targetPaneId: string) => { - applyLayout((current) => moveSessionToDraftPane(current, sourcePaneId, targetPaneId)); + applyLayout((current) => swapPaneLeavesByPaneId(current, sourcePaneId, targetPaneId)); }, [applyLayout] ); - const insertSessionPaneAtEdge = useCallback( + const insertPaneAtEdge = useCallback( ( sourcePaneId: string, targetPaneId: string, placement: Exclude ) => { - applyLayout((current) => insertPaneAtEdge(current, sourcePaneId, targetPaneId, placement)); + applyLayout((current) => + insertPaneNodeAtEdge(current, sourcePaneId, targetPaneId, placement) + ); }, [applyLayout] ); @@ -145,14 +166,16 @@ export function usePaneActions(workspaceId: string) { appendSessionToMobileColumn, assignSession, closeDraftPane, + closeEditorPane, closeSessionPane, + convertDraftPane, removeSessionPane, replaceSession, replaceWithSession, splitDraftPane, splitSessionPane, + swapPaneLeaves, swapPaneSessions, - moveSessionToDraft, - insertSessionPaneAtEdge, + insertPaneAtEdge, }; } diff --git a/packages/web/src/features/agent-panes/actions/use-pane-drag-controller.test.tsx b/packages/web/src/features/agent-panes/actions/use-pane-drag-controller.test.tsx index 8747ef9e..06fd3c7d 100644 --- a/packages/web/src/features/agent-panes/actions/use-pane-drag-controller.test.tsx +++ b/packages/web/src/features/agent-panes/actions/use-pane-drag-controller.test.tsx @@ -47,7 +47,7 @@ describe("usePaneDragController", () => { expect(result.current.state.previewPosition).toEqual({ x: 130, y: 180 }); }); - it("treats draft panes as center-only targets and dispatches a center drop intent", () => { + it("treats draft panes like other pane targets and dispatches edge drop intents", () => { const onDrop = vi.fn(); const { result } = renderHook(() => usePaneDragController({ onDrop })); @@ -60,7 +60,7 @@ describe("usePaneDragController", () => { result.current.handlePointerMove({ clientX: 310, clientY: 140 } as PointerEvent); }); - expect(result.current.state.hoverPlacement).toBe("center"); + expect(result.current.state.hoverPlacement).toBe("left"); act(() => { result.current.handlePointerUp(); @@ -69,7 +69,7 @@ describe("usePaneDragController", () => { expect(onDrop).toHaveBeenCalledWith({ sourcePaneId: "source-pane", targetPaneId: "draft-pane", - placement: "center", + placement: "left", targetType: "draft", }); expect(result.current.state.isDragging).toBe(false); diff --git a/packages/web/src/features/agent-panes/actions/use-pane-drag-controller.ts b/packages/web/src/features/agent-panes/actions/use-pane-drag-controller.ts index 136dc335..04ac8073 100644 --- a/packages/web/src/features/agent-panes/actions/use-pane-drag-controller.ts +++ b/packages/web/src/features/agent-panes/actions/use-pane-drag-controller.ts @@ -70,10 +70,6 @@ function resolvePlacement( return null; } - if (pane.type === "draft") { - return "center"; - } - const edgeX = clampEdgeBand(rect.width); const edgeY = clampEdgeBand(rect.height); diff --git a/packages/web/src/features/agent-panes/actions/use-workspace-sessions.ts b/packages/web/src/features/agent-panes/actions/use-workspace-sessions.ts index 206f235f..f8144563 100644 --- a/packages/web/src/features/agent-panes/actions/use-workspace-sessions.ts +++ b/packages/web/src/features/agent-panes/actions/use-workspace-sessions.ts @@ -8,6 +8,7 @@ import { useWorkspaceUiStatePersistence } from "../../workspace/actions/use-work import { clearLegacyPaneLayout, defaultPaneLayout, + normalizePaneLayout, type PaneNode, paneLayoutAtomFamily, readLegacyPaneLayout, @@ -174,23 +175,6 @@ export function useWorkspaceSessions( }; } -function normalizePaneLayout( - layout: Workspace["uiState"]["paneLayout"] | PaneNode | null | undefined -): PaneNode | null { - if (!layout) { - return null; - } - - return { - id: layout.id, - type: layout.type, - sessionId: layout.sessionId, - direction: layout.direction, - ratio: "ratio" in layout ? layout.ratio : undefined, - children: layout.children?.map((child) => normalizePaneLayout(child) ?? defaultPaneLayout), - }; -} - function appendMissingSessions(layout: PaneNode, sessionIds: string[]): PaneNode { let nextLayout = layout; const initialSessionIds = collectSessionIds(nextLayout); diff --git a/packages/web/src/features/agent-panes/atoms/editor-panes.ts b/packages/web/src/features/agent-panes/atoms/editor-panes.ts new file mode 100644 index 00000000..9b1e0f62 --- /dev/null +++ b/packages/web/src/features/agent-panes/atoms/editor-panes.ts @@ -0,0 +1,10 @@ +import { atom } from "jotai"; +import { atomFamily } from "jotai-family"; + +export const focusedEditorPaneIdAtomFamily = atomFamily((workspaceId: string) => + atom(null) +); + +export const activeEditorPaneIdAtomFamily = atomFamily((workspaceId: string) => + atom(null) +); diff --git a/packages/web/src/features/agent-panes/atoms/pane-layout.test.ts b/packages/web/src/features/agent-panes/atoms/pane-layout.test.ts index cc167fe4..21200992 100644 --- a/packages/web/src/features/agent-panes/atoms/pane-layout.test.ts +++ b/packages/web/src/features/agent-panes/atoms/pane-layout.test.ts @@ -1,6 +1,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { clearLegacyPaneLayout, + defaultPaneLayout, + normalizePaneLayout, readLegacyPaneLayout, readPaneRatio, writePaneRatio, @@ -32,4 +34,67 @@ describe("pane layout storage helpers", () => { expect(() => writePaneRatio("ws-1", "root", 0.5)).not.toThrow(); expect(() => clearLegacyPaneLayout("ws-1")).not.toThrow(); }); + + it("uses a typed draft leaf as the default pane layout", () => { + expect(defaultPaneLayout).toEqual({ + id: "root", + type: "leaf", + leafKind: "draft", + }); + }); + + it("normalizes a legacy session leaf into a typed session leaf", () => { + expect(normalizePaneLayout({ id: "root", type: "leaf", sessionId: "sess_1" })).toEqual({ + id: "root", + type: "leaf", + leafKind: "session", + sessionId: "sess_1", + }); + }); + + it("normalizes persisted layouts back to a single editor pane", () => { + expect( + normalizePaneLayout({ + id: "root", + type: "split", + direction: "horizontal", + children: [ + { id: "left", type: "leaf", leafKind: "editor" }, + { id: "right", type: "leaf", leafKind: "editor" }, + ], + }) + ).toEqual({ + id: "root", + type: "split", + direction: "horizontal", + children: [ + { id: "left", type: "leaf", leafKind: "editor" }, + { id: "right", type: "leaf", leafKind: "draft" }, + ], + }); + }); + + it("repairs invalid typed leaves from older persisted layouts", () => { + expect( + normalizePaneLayout({ + id: "root", + type: "split", + direction: "horizontal", + children: [ + { id: "left", type: "leaf", leafKind: "draft", sessionId: "sess-invalid" }, + { id: "center", type: "leaf", leafKind: "editor", sessionId: "sess-invalid" }, + { id: "right", type: "leaf", leafKind: "session" }, + ], + }) + ).toEqual({ + id: "root", + type: "split", + direction: "horizontal", + children: [ + { id: "left", type: "leaf", leafKind: "draft" }, + { id: "center", type: "leaf", leafKind: "editor" }, + { id: "right", type: "leaf", leafKind: "draft" }, + ], + }); + }); }); diff --git a/packages/web/src/features/agent-panes/atoms/pane-layout.ts b/packages/web/src/features/agent-panes/atoms/pane-layout.ts index d482ae2b..c84055d4 100644 --- a/packages/web/src/features/agent-panes/atoms/pane-layout.ts +++ b/packages/web/src/features/agent-panes/atoms/pane-layout.ts @@ -4,26 +4,40 @@ * Server-backed pane layout projection owned by the agent-panes feature. */ -import type { WorkspacePaneNode } from "@coder-studio/core"; +import type { WorkspacePaneLeafKind, WorkspacePaneNode } from "@coder-studio/core"; import { atom } from "jotai"; import { atomFamily } from "jotai-family"; +import { enforceSingleEditorPaneInvariant } from "../pane-layout-tree"; /** * Pane layout by workspace (agent pane splits). * The server owns pane structure; only the legacy migration path reads the * historical localStorage key. */ -export interface PaneNode extends WorkspacePaneNode { +export interface PaneLeaf { + id: string; + type: "leaf"; + leafKind?: WorkspacePaneLeafKind; + sessionId?: string; +} + +export interface PaneSplit { + id: string; + type: "split"; + direction?: "horizontal" | "vertical"; ratio?: number; children?: PaneNode[]; } +export type PaneNode = PaneLeaf | PaneSplit; + export const LEGACY_PANE_LAYOUT_STORAGE_KEY_PREFIX = "ui.paneLayout."; export const PANE_RATIO_STORAGE_KEY_PREFIX = "ui.paneRatio."; export const defaultPaneLayout: PaneNode = { id: "root", type: "leaf", + leafKind: "draft", }; export const paneLayoutAtomFamily = atomFamily((workspaceId: string) => @@ -60,6 +74,61 @@ export function readLegacyPaneLayout(workspaceId: string): PaneNode | null { } } +export function normalizePaneLayout( + layout: WorkspacePaneNode | PaneNode | null | undefined +): PaneNode | null { + const normalized = normalizePaneLayoutNode(layout); + return normalized ? enforceSingleEditorPaneInvariant(normalized) : null; +} + +function normalizePaneLayoutNode( + layout: WorkspacePaneNode | PaneNode | null | undefined +): PaneNode | null { + if (!layout) { + return null; + } + + if (layout.type === "leaf") { + if ("leafKind" in layout && layout.leafKind) { + if (layout.leafKind === "session" && layout.sessionId) { + return { + id: layout.id, + type: "leaf", + leafKind: "session", + sessionId: layout.sessionId, + }; + } + + return { + id: layout.id, + type: "leaf", + leafKind: layout.leafKind === "editor" ? "editor" : "draft", + }; + } + + return layout.sessionId + ? { + id: layout.id, + type: "leaf", + leafKind: "session", + sessionId: layout.sessionId, + } + : { + id: layout.id, + type: "leaf", + leafKind: "draft", + }; + } + + return { + id: layout.id, + type: "split", + direction: layout.direction, + ratio: "ratio" in layout ? layout.ratio : undefined, + children: layout.children?.map((child) => normalizePaneLayoutNode(child) ?? defaultPaneLayout), + }; +} + export function clearLegacyPaneLayout(workspaceId: string): void { const storage = getLocalStorage(); if (!storage) { diff --git a/packages/web/src/features/agent-panes/components/session-card.test.tsx b/packages/web/src/features/agent-panes/components/session-card.test.tsx index a3217464..31dc2688 100644 --- a/packages/web/src/features/agent-panes/components/session-card.test.tsx +++ b/packages/web/src/features/agent-panes/components/session-card.test.tsx @@ -1,8 +1,8 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { createStore, Provider } from "jotai"; import type { ReactNode } from "react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { lastViewedTargetAtom, pendingFocusSessionAtom } from "../../../atoms/app-ui"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { lastViewedTargetAtom, localeAtom, pendingFocusSessionAtom } from "../../../atoms/app-ui"; import { connectionStatusAtom, wsClientAtom } from "../../../atoms/connection"; import { sessionsAtom } from "../../../atoms/sessions"; import { @@ -40,6 +40,7 @@ function createSessionStore( sendCommand = vi.fn().mockResolvedValue(undefined) ) { const store = createStore(); + store.set(localeAtom, "en"); store.set(wsClientAtom, { sendCommand, @@ -82,10 +83,15 @@ function createSessionStore( describe("SessionCard", () => { beforeEach(() => { + window.localStorage.setItem("ui.locale", JSON.stringify("en")); vi.clearAllMocks(); paneDragEnabledMock.value = true; }); + afterEach(() => { + window.localStorage.clear(); + }); + it("renders ended sessions with a read-only terminal host", () => { const { store } = createSessionStore(); @@ -523,7 +529,7 @@ describe("SessionCard", () => { expect(headerRow).not.toBeNull(); expect(headerRow).toContainElement(screen.getByText("SESSION-56")); expect(headerRow).toContainElement(screen.getByText("Codex")); - expect(headerRow).toContainElement(screen.getByText("Idle")); + expect(headerRow).toContainElement(screen.getByText("Waiting for input")); expect(inlineMeta).not.toBeNull(); expect(headerRow).toContainElement(inlineMeta as HTMLElement); }); diff --git a/packages/web/src/features/agent-panes/index.test.tsx b/packages/web/src/features/agent-panes/index.test.tsx index dcca190e..57b320c2 100644 --- a/packages/web/src/features/agent-panes/index.test.tsx +++ b/packages/web/src/features/agent-panes/index.test.tsx @@ -7,8 +7,10 @@ import { connectionStatusAtom, wsClientAtom } from "../../atoms/connection"; import { sessionsAtom } from "../../atoms/sessions"; import { activeWorkspaceIdAtom, workspacesLoadStateAtom } from "../../atoms/workspaces"; import { seedReadyWorkspaceState } from "../../test-utils/workspace-state"; +import { activeFilePathAtomFamily, openFilesAtomFamily } from "../workspace/atoms"; import type { PaneDropIntent } from "./actions/pane-drag-types"; import type { PaneDragSourceSnapshot } from "./actions/use-pane-drag-controller"; +import { activeEditorPaneIdAtomFamily, focusedEditorPaneIdAtomFamily } from "./atoms/editor-panes"; import { LEGACY_PANE_LAYOUT_STORAGE_KEY_PREFIX, paneLayoutAtomFamily } from "./atoms/pane-layout"; import { AgentPanes } from "./index"; @@ -111,12 +113,13 @@ type MockDraftLauncherProps = { dragState?: { isActiveDropTarget: boolean; isDragging: boolean; - hoverPlacement: "center" | null; + hoverPlacement: PaneDropIntent["placement"] | null; }; workspaceId: string; paneId?: string; onAssignSession?: (paneId: string, sessionId: string) => void; onClosePane?: (paneId: string) => void; + onOpenFile?: (paneId: string, path: string) => void; onReplaceWithSession?: (sessionId: string) => void; onSplitPane?: (paneId: string, direction: "horizontal" | "vertical") => void; onPaneDrop?: (intent: PaneDropIntent) => void; @@ -152,11 +155,67 @@ vi.mock("./views/shared/draft-launcher", async () => { move-to-draft-{paneId} ) : null} + {paneId && props.onOpenFile ? ( + + ) : null} ), }; }); +const mockEditorPaneCard = vi.fn( + ({ + paneId, + dragState, + onClosePane, + onPaneDragStart, + }: { + paneId: string; + workspaceId: string; + dragState?: { + isActiveDropTarget: boolean; + isDragging: boolean; + hoverPlacement: PaneDropIntent["placement"] | null; + }; + onClosePane: (paneId: string) => void; + onPaneDragStart?: (source: PaneDragSourceSnapshot) => void; + onSplitPane: (paneId: string, direction: "horizontal" | "vertical") => void; + }) => ( +
+ {onPaneDragStart ? ( + + ) : null} + +
+ ) +); + +vi.mock("./views/shared/editor-pane-card", () => ({ + EditorPaneCard: (props: { + paneId: string; + workspaceId: string; + dragState?: { + isActiveDropTarget: boolean; + isDragging: boolean; + hoverPlacement: PaneDropIntent["placement"] | null; + }; + onClosePane: (paneId: string) => void; + onPaneDragStart?: (source: PaneDragSourceSnapshot) => void; + onSplitPane: (paneId: string, direction: "horizontal" | "vertical") => void; + }) => mockEditorPaneCard(props), +})); + vi.mock("./views/shared/pane-layout", () => ({ PaneLayout: ({ children, @@ -286,6 +345,7 @@ function setPaneRect( describe("AgentPanes", () => { beforeEach(() => { + window.localStorage.setItem("ui.locale", JSON.stringify("en")); vi.clearAllMocks(); }); @@ -551,7 +611,7 @@ describe("AgentPanes", () => { ); }); - it("moves a session into a draft pane on a center drop over a draft target", async () => { + it("swaps a session with a draft pane on a center drop over a draft target", async () => { const sendCommand = vi.fn(async (op: string, args?: Record) => { if (op === "session.list") { return [ @@ -609,9 +669,14 @@ describe("AgentPanes", () => { await waitFor(() => { expect(store.get(paneLayoutAtomFamily("ws-1"))).toEqual({ - id: "right", - type: "leaf", - sessionId: "sess_1", + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf" }, + { id: "right", type: "leaf", sessionId: "sess_1" }, + ], }); }); @@ -621,9 +686,14 @@ describe("AgentPanes", () => { workspaceId: "ws-1", uiState: expect.objectContaining({ paneLayout: { - id: "right", - type: "leaf", - sessionId: "sess_1", + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf" }, + { id: "right", type: "leaf", sessionId: "sess_1" }, + ], }, }), }), @@ -830,6 +900,103 @@ describe("AgentPanes", () => { expect(document.body).not.toHaveClass("is-dragging-pane"); }); + it("registers editor pane wrappers as drop targets and swaps with a session through pointer drag", async () => { + const { store } = createAgentPaneStore({ + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", leafKind: "session", sessionId: "sess_1" }, + { id: "right", type: "leaf", leafKind: "editor" }, + ], + }); + store.set(activeEditorPaneIdAtomFamily("ws-1"), "right"); + store.set(focusedEditorPaneIdAtomFamily("ws-1"), "right"); + + render( + + + + ); + + const editorPane = setPaneRect("right", { left: 260, top: 0, width: 220, height: 180 }); + setPaneRect("left", { left: 0, top: 0, width: 220, height: 180 }); + + fireEvent.pointerDown(screen.getByRole("button", { name: "drag-sess_1" })); + fireEvent.pointerMove(window, { clientX: 370, clientY: 90 }); + + await waitFor(() => { + expect(editorPane).toHaveAttribute("data-pane-drop-target", "true"); + expect(editorPane).toHaveAttribute("data-pane-hover-placement", "center"); + }); + + fireEvent.pointerUp(window, { clientX: 370, clientY: 90 }); + + await waitFor(() => { + expect(store.get(paneLayoutAtomFamily("ws-1"))).toEqual({ + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", leafKind: "editor" }, + { id: "right", type: "leaf", leafKind: "session", sessionId: "sess_1" }, + ], + }); + expect(store.get(activeEditorPaneIdAtomFamily("ws-1"))).toBe("left"); + }); + }); + + it("keeps editor focus attached when the editor pane is dragged over a session", async () => { + const { store } = createAgentPaneStore({ + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", leafKind: "editor" }, + { id: "right", type: "leaf", leafKind: "session", sessionId: "sess_1" }, + ], + }); + store.set(activeEditorPaneIdAtomFamily("ws-1"), "left"); + store.set(focusedEditorPaneIdAtomFamily("ws-1"), "left"); + + render( + + + + ); + + setPaneRect("left", { left: 0, top: 0, width: 220, height: 180 }); + const sessionPane = setPaneRect("right", { left: 260, top: 0, width: 220, height: 180 }); + + fireEvent.pointerDown(screen.getByRole("button", { name: "drag-left" })); + fireEvent.pointerMove(window, { clientX: 370, clientY: 90 }); + + await waitFor(() => { + expect(sessionPane).toHaveAttribute("data-pane-drop-target", "true"); + expect(sessionPane).toHaveAttribute("data-pane-hover-placement", "center"); + }); + + fireEvent.pointerUp(window, { clientX: 370, clientY: 90 }); + + await waitFor(() => { + expect(store.get(paneLayoutAtomFamily("ws-1"))).toEqual({ + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", leafKind: "session", sessionId: "sess_1" }, + { id: "right", type: "leaf", leafKind: "editor" }, + ], + }); + expect(store.get(activeEditorPaneIdAtomFamily("ws-1"))).toBe("right"); + expect(store.get(focusedEditorPaneIdAtomFamily("ws-1"))).toBe("right"); + }); + }); + it("keeps the remaining draft pane visible after closing the last session pane", async () => { const { store } = createAgentPaneStore({ id: "root", @@ -1020,8 +1187,8 @@ describe("AgentPanes", () => { direction: "horizontal", ratio: 0.5, children: [ - { id: "fallback-leaf-1", type: "leaf", sessionId: "sess_1" }, - { id: "fallback-leaf-2", type: "leaf", sessionId: "sess_2" }, + { id: "fallback-leaf-1", type: "leaf", leafKind: "session", sessionId: "sess_1" }, + { id: "fallback-leaf-2", type: "leaf", leafKind: "session", sessionId: "sess_2" }, ], }); expect(mockSessionCard).toHaveBeenCalledWith(expect.objectContaining({ sessionId: "sess_1" })); @@ -1098,14 +1265,30 @@ describe("AgentPanes", () => { expect.objectContaining({ workspaceId: "ws-1", uiState: expect.objectContaining({ - paneLayout: legacyLayout, + paneLayout: { + id: "root", + type: "split", + direction: "horizontal", + children: [ + { id: "left", type: "leaf", leafKind: "session", sessionId: "sess_1" }, + { id: "right", type: "leaf", leafKind: "session", sessionId: "sess_2" }, + ], + }, }), }), undefined ); }); - expect(store.get(paneLayoutAtomFamily("ws-1"))).toEqual(legacyLayout); + expect(store.get(paneLayoutAtomFamily("ws-1"))).toEqual({ + id: "root", + type: "split", + direction: "horizontal", + children: [ + { id: "left", type: "leaf", leafKind: "session", sessionId: "sess_1" }, + { id: "right", type: "leaf", leafKind: "session", sessionId: "sess_2" }, + ], + }); expect(window.localStorage.getItem(`${LEGACY_PANE_LAYOUT_STORAGE_KEY_PREFIX}ws-1`)).toBeNull(); }); @@ -1384,10 +1567,12 @@ describe("AgentPanes", () => { ); - expect(await screen.findByText("Install & Start")).toBeInTheDocument(); - await waitFor(() => { - expect(screen.getByText("Install & Start")).toBeInTheDocument(); + expect(sendCommand).toHaveBeenCalledWith("provider.runtimeStatus", {}, undefined); + }); + + await act(async () => { + await Promise.resolve(); }); fireEvent.click(screen.getByRole("button", { name: /Claude/i })); @@ -1466,7 +1651,11 @@ describe("AgentPanes", () => { ); await waitFor(() => { - expect(screen.getByText("Install & Start")).toBeInTheDocument(); + expect(sendCommand).toHaveBeenCalledWith("provider.runtimeStatus", {}, undefined); + }); + + await act(async () => { + await Promise.resolve(); }); fireEvent.click(screen.getByRole("button", { name: /Codex/i })); @@ -1536,7 +1725,11 @@ describe("AgentPanes", () => { ); await waitFor(() => { - expect(screen.getByText("View Install Steps")).toBeInTheDocument(); + expect(sendCommand).toHaveBeenCalledWith("provider.runtimeStatus", {}, undefined); + }); + + await act(async () => { + await Promise.resolve(); }); fireEvent.click(screen.getByRole("button", { name: /Codex/i })); @@ -1555,4 +1748,147 @@ describe("AgentPanes", () => { expect(screen.getByRole("link", { name: "Open Diagnostics" })).toBeInTheDocument(); expect(screen.getByRole("link", { name: "Open official docs" })).toBeInTheDocument(); }); + + it("renders editor leaves with the editor pane card instead of the draft launcher", async () => { + const { store } = createAgentPaneStore({ + id: "root", + type: "leaf", + leafKind: "editor", + }); + + render( + + + + ); + + expect(screen.getByTestId("editor-pane-root")).toBeInTheDocument(); + await waitFor(() => { + expect(mockSessionCard).toHaveBeenCalledTimes(2); + }); + expect(screen.getByTestId("editor-pane-root")).toBeInTheDocument(); + expect(screen.queryByTestId("draft-launcher-root")).not.toBeInTheDocument(); + }); + + it("converts a draft pane into an editor pane when a file is opened from the draft launcher", async () => { + const { store } = createAgentPaneStore({ + id: "root", + type: "leaf", + leafKind: "draft", + }); + + render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: "open-file-root" })); + + expect(store.get(paneLayoutAtomFamily("ws-1"))).toEqual({ + id: "root", + type: "leaf", + leafKind: "editor", + }); + expect(await screen.findByTestId("editor-pane-root")).toBeInTheDocument(); + }); + + it("opens files from the standalone root draft launcher by converting it into an editor pane", async () => { + const { store } = createAgentPaneStore({ + id: "root", + type: "leaf", + leafKind: "draft", + }); + store.set(sessionsAtom, {}); + + render( + + + + ); + + expect(screen.getByTestId("draft-launcher-root")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "open-file-root" })); + + expect(store.get(paneLayoutAtomFamily("ws-1"))).toEqual({ + id: "root", + type: "leaf", + leafKind: "editor", + }); + expect(await screen.findByTestId("editor-pane-root")).toBeInTheDocument(); + }); + + it("reuses the existing editor pane when another draft launcher opens a file", async () => { + const { store } = createAgentPaneStore({ + id: "root", + type: "split", + direction: "horizontal", + children: [ + { id: "left", type: "leaf", leafKind: "editor" }, + { id: "right", type: "leaf", leafKind: "draft" }, + ], + }); + + render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: "open-file-right" })); + + expect(store.get(paneLayoutAtomFamily("ws-1"))).toEqual({ + id: "root", + type: "split", + direction: "horizontal", + children: [ + { id: "left", type: "leaf", leafKind: "editor" }, + { id: "right", type: "leaf", leafKind: "draft" }, + ], + }); + expect(store.get(activeEditorPaneIdAtomFamily("ws-1"))).toBe("left"); + expect(store.get(focusedEditorPaneIdAtomFamily("ws-1"))).toBe("left"); + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBe("src/app.tsx"); + }); + + it("closes an editor pane back to draft and clears the active editor target", async () => { + const { store } = createAgentPaneStore({ + id: "root", + type: "leaf", + leafKind: "editor", + }); + store.set(activeEditorPaneIdAtomFamily("ws-1"), "root"); + store.set(focusedEditorPaneIdAtomFamily("ws-1"), "root"); + store.set(activeFilePathAtomFamily("ws-1"), "src/app.tsx"); + store.set(openFilesAtomFamily("ws-1"), { + "src/app.tsx": { + kind: "text", + path: "src/app.tsx", + content: "export const app = 1;", + savedContent: "export const app = 1;", + baseHash: "hash-app", + isDirty: false, + }, + }); + + render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: "close-editor-root" })); + + expect(store.get(paneLayoutAtomFamily("ws-1"))).toEqual({ + id: "root", + type: "leaf", + leafKind: "draft", + }); + expect(store.get(activeEditorPaneIdAtomFamily("ws-1"))).toBeNull(); + expect(store.get(focusedEditorPaneIdAtomFamily("ws-1"))).toBeNull(); + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBeNull(); + expect(store.get(openFilesAtomFamily("ws-1"))).toEqual({}); + expect(await screen.findByTestId("draft-launcher-root")).toBeInTheDocument(); + }); }); diff --git a/packages/web/src/features/agent-panes/index.tsx b/packages/web/src/features/agent-panes/index.tsx index 1042c147..6776a1cf 100644 --- a/packages/web/src/features/agent-panes/index.tsx +++ b/packages/web/src/features/agent-panes/index.tsx @@ -5,20 +5,24 @@ * Each panel contains a terminal showing agent output. */ -import { useAtomValue } from "jotai"; +import { useAtomValue, useSetAtom } from "jotai"; import { type FC, useCallback, useEffect, useRef } from "react"; import { activeWorkspaceAtom } from "../../atoms/workspaces"; import { EmptyState } from "../../components/ui"; import { useTranslation } from "../../lib/i18n"; +import { useOpenEditorsActions } from "../workspace/actions/use-open-editors-actions"; +import { useOpenWorkspaceFile } from "../workspace/actions/use-open-workspace-file"; import type { PaneDropIntent } from "./actions/pane-drag-types"; import { usePaneActions } from "./actions/use-pane-actions"; import { usePaneDragController } from "./actions/use-pane-drag-controller"; import { usePaneDragEnabled } from "./actions/use-pane-drag-enabled"; import { useSessionActions } from "./actions/use-session-actions"; import { useWorkspaceSessions } from "./actions/use-workspace-sessions"; +import { activeEditorPaneIdAtomFamily, focusedEditorPaneIdAtomFamily } from "./atoms/editor-panes"; import { type PaneNode, readPaneRatio, writePaneRatio } from "./atoms/pane-layout"; -import { collectSessionIds } from "./pane-layout-tree"; +import { collectSessionIds, paneLayoutHasEditorPaneId } from "./pane-layout-tree"; import { DraftLauncher } from "./views/shared/draft-launcher"; +import { EditorPaneCard } from "./views/shared/editor-pane-card"; import { PaneLayout } from "./views/shared/pane-layout"; import { SessionCard } from "./views/shared/session-card"; @@ -41,6 +45,14 @@ const emptyStateTitleStyle = { fontWeight: "var(--font-normal)", }; +function isDraftLeaf(node: PaneLeafNode): boolean { + return node.leafKind === "draft" || (!node.leafKind && !node.sessionId); +} + +function isEditorLeaf(node: PaneLeafNode): boolean { + return node.leafKind === "editor"; +} + export const AgentPanes: FC = ({ hydrateSessions = true }) => { const t = useTranslation(); const paneDragEnabled = usePaneDragEnabled(); @@ -50,28 +62,115 @@ export const AgentPanes: FC = ({ hydrateSessions = true }) => { }); const paneActions = usePaneActions(workspaceId); const sessionActions = useSessionActions(); - const { insertSessionPaneAtEdge, moveSessionToDraft, swapPaneSessions } = paneActions; + const { openWorkspaceFile } = useOpenWorkspaceFile(workspaceId); + const { closeAll } = useOpenEditorsActions(workspaceId, { + workspaceRootPath: workspace?.path, + }); + const setActiveEditorPaneId = useSetAtom(activeEditorPaneIdAtomFamily(workspaceId)); + const setFocusedEditorPaneId = useSetAtom(focusedEditorPaneIdAtomFamily(workspaceId)); + const { insertPaneAtEdge, swapPaneLeaves } = paneActions; const hasLayoutSessions = collectSessionIds(paneLayout).length > 0; const shouldShowStandaloneDraftLauncher = sessions.length === 0 && (hasLayoutSessions || - (paneLayout.type === "leaf" && !paneLayout.sessionId && paneLayout.id === "root")); + (paneLayout.type === "leaf" && isDraftLeaf(paneLayout) && paneLayout.id === "root")); + + const handleOpenFile = useCallback( + (paneId: string, path: string) => { + void openWorkspaceFile( + { + workspaceId, + path, + source: "file-tree", + }, + { + targetDraftPaneId: paneId, + } + ); + }, + [openWorkspaceFile, workspaceId] + ); + + const handleActivateEditorPane = useCallback( + (paneId: string) => { + setActiveEditorPaneId(paneId); + setFocusedEditorPaneId(paneId); + }, + [setActiveEditorPaneId, setFocusedEditorPaneId] + ); + + const handleBlurEditorFocus = useCallback(() => { + setFocusedEditorPaneId(null); + }, [setFocusedEditorPaneId]); + + const handleCloseEditorPane = useCallback( + (paneId: string) => { + closeAll(); + paneActions.closeEditorPane(paneId); + setActiveEditorPaneId((current) => (current === paneId ? null : current)); + setFocusedEditorPaneId((current) => (current === paneId ? null : current)); + }, + [closeAll, paneActions, setActiveEditorPaneId, setFocusedEditorPaneId] + ); + + useEffect(() => { + setActiveEditorPaneId((current) => + current && !paneLayoutHasEditorPaneId(paneLayout, current) ? null : current + ); + setFocusedEditorPaneId((current) => + current && !paneLayoutHasEditorPaneId(paneLayout, current) ? null : current + ); + }, [paneLayout, setActiveEditorPaneId, setFocusedEditorPaneId]); const handlePaneDrop = useCallback( (intent: PaneDropIntent) => { - if (intent.placement === "center") { - if (intent.targetType === "draft") { - moveSessionToDraft(intent.sourcePaneId, intent.targetPaneId); + const sourceWasEditor = paneLayoutHasEditorPaneId(paneLayout, intent.sourcePaneId); + const targetWasEditor = paneLayoutHasEditorPaneId(paneLayout, intent.targetPaneId); + const previousEditorPaneId = sourceWasEditor + ? intent.sourcePaneId + : targetWasEditor + ? intent.targetPaneId + : null; + let nextEditorPaneId = previousEditorPaneId; + const syncEditorPaneFocus = () => { + if ( + !previousEditorPaneId || + !nextEditorPaneId || + previousEditorPaneId === nextEditorPaneId + ) { return; } - swapPaneSessions(intent.sourcePaneId, intent.targetPaneId); + setActiveEditorPaneId((current) => + current === previousEditorPaneId ? nextEditorPaneId : current + ); + setFocusedEditorPaneId((current) => + current === previousEditorPaneId ? nextEditorPaneId : current + ); + }; + + if (intent.placement === "center") { + if (sourceWasEditor) { + nextEditorPaneId = intent.targetPaneId; + } else if (targetWasEditor) { + nextEditorPaneId = intent.sourcePaneId; + } + + swapPaneLeaves(intent.sourcePaneId, intent.targetPaneId); + syncEditorPaneFocus(); return; } - insertSessionPaneAtEdge(intent.sourcePaneId, intent.targetPaneId, intent.placement); + if (sourceWasEditor) { + nextEditorPaneId = intent.sourcePaneId; + } else if (targetWasEditor) { + nextEditorPaneId = intent.targetPaneId; + } + + insertPaneAtEdge(intent.sourcePaneId, intent.targetPaneId, intent.placement); + syncEditorPaneFocus(); }, - [insertSessionPaneAtEdge, moveSessionToDraft, swapPaneSessions] + [insertPaneAtEdge, paneLayout, setActiveEditorPaneId, setFocusedEditorPaneId, swapPaneLeaves] ); const dragController = usePaneDragController({ enabled: paneDragEnabled, @@ -92,8 +191,12 @@ export const AgentPanes: FC = ({ hydrateSessions = true }) => { if (shouldShowStandaloneDraftLauncher) { return ( ); } @@ -106,9 +209,13 @@ export const AgentPanes: FC = ({ hydrateSessions = true }) => { workspaceId={workspaceId} onCloseSession={paneActions.closeSessionPane} onSplitDraftPane={paneActions.splitDraftPane} + onActivateEditorPane={handleActivateEditorPane} + onBlurEditorFocus={handleBlurEditorFocus} + onCloseEditorPane={handleCloseEditorPane} onSplitSession={paneActions.splitSessionPane} onCloseDraftPane={paneActions.closeDraftPane} onAssignSession={paneActions.assignSession} + onOpenFile={handleOpenFile} dragController={dragController} onPaneDrop={handlePaneDrop} onReplaceWithSession={paneActions.replaceWithSession} @@ -123,12 +230,16 @@ interface PaneNodeRendererProps { node: PaneNode; workspaceId: string; onAssignSession: (paneId: string, sessionId: string) => void; + onActivateEditorPane: (paneId: string) => void; + onBlurEditorFocus: () => void; onCloseDraftPane: (paneId: string) => void; + onCloseEditorPane: (paneId: string) => void; onCloseSession: (sessionId: string) => void; onCloseSessionCommand: ( sessionId: string, paneDisposition?: "draft" | "remove" ) => Promise; + onOpenFile: (paneId: string, path: string) => void; onPaneDrop: (intent: PaneDropIntent) => void; onReplaceWithSession: (sessionId: string) => void; onSplitDraftPane: (paneId: string, direction: "horizontal" | "vertical") => void; @@ -137,6 +248,7 @@ interface PaneNodeRendererProps { type PaneLeafNode = PaneNode & { type: "leaf"; + leafKind?: "draft" | "session" | "editor"; sessionId?: string; }; @@ -164,12 +276,16 @@ interface PaneLeafProps { node: PaneLeafNode; workspaceId: string; onAssignSession: (paneId: string, sessionId: string) => void; + onActivateEditorPane: (paneId: string) => void; + onBlurEditorFocus: () => void; onCloseDraftPane: (paneId: string) => void; + onCloseEditorPane: (paneId: string) => void; onCloseSession: (sessionId: string) => void; onCloseSessionCommand: ( sessionId: string, paneDisposition?: "draft" | "remove" ) => Promise; + onOpenFile: (paneId: string, path: string) => void; onPaneDrop: (intent: PaneDropIntent) => void; onReplaceWithSession: (sessionId: string) => void; onSplitDraftPane: (paneId: string, direction: "horizontal" | "vertical") => void; @@ -181,9 +297,13 @@ const PaneLeaf: FC = ({ node, workspaceId, onAssignSession, + onActivateEditorPane, + onBlurEditorFocus, onCloseDraftPane, + onCloseEditorPane, onCloseSession, onCloseSessionCommand, + onOpenFile, onPaneDrop, onReplaceWithSession, onSplitDraftPane, @@ -200,16 +320,18 @@ const PaneLeaf: FC = ({ } dragController.registerPane(node.id, { - type: node.sessionId ? "session" : "draft", + type: isEditorLeaf(node) ? "editor" : node.sessionId ? "session" : "draft", element, }); return () => { dragController.registerPane(node.id, null); }; - }, [dragController, node.id, node.sessionId]); + }, [dragController, node]); + + const sessionId = node.sessionId; - if (node.sessionId) { + if (sessionId) { return (
= ({ data-pane-dragging={dragState.isDragging ? "true" : undefined} data-pane-drop-target={dragState.isActiveDropTarget ? "true" : undefined} data-pane-hover-placement={dragState.hoverPlacement ?? undefined} + onPointerDownCapture={onBlurEditorFocus} > { - onCloseSession(node.sessionId); - await onCloseSessionCommand(node.sessionId, "draft"); + onCloseSession(sessionId); + await onCloseSessionCommand(sessionId, "draft"); }} - onSplitHorizontal={() => onSplitSession(node.sessionId!, "horizontal")} - onSplitVertical={() => onSplitSession(node.sessionId!, "vertical")} + onSplitHorizontal={() => onSplitSession(sessionId, "horizontal")} + onSplitVertical={() => onSplitSession(sessionId, "vertical")} + /> +
+ ); + } + + if (isEditorLeaf(node)) { + return ( +
onActivateEditorPane(node.id)} + > +
); @@ -244,17 +390,20 @@ const PaneLeaf: FC = ({ data-pane-dragging={dragState.isDragging ? "true" : undefined} data-pane-drop-target={dragState.isActiveDropTarget ? "true" : undefined} data-pane-hover-placement={dragState.hoverPlacement ?? undefined} + onPointerDownCapture={onBlurEditorFocus} > = ({ node, workspaceId, onAssignSession, + onActivateEditorPane, + onBlurEditorFocus, onCloseDraftPane, + onCloseEditorPane, onCloseSession, onCloseSessionCommand, + onOpenFile, onPaneDrop, onReplaceWithSession, onSplitDraftPane, @@ -286,9 +439,13 @@ const PaneNodeRenderer: FC = ({ node={node} workspaceId={workspaceId} onAssignSession={onAssignSession} + onActivateEditorPane={onActivateEditorPane} + onBlurEditorFocus={onBlurEditorFocus} onCloseDraftPane={onCloseDraftPane} + onCloseEditorPane={onCloseEditorPane} onCloseSession={onCloseSession} onCloseSessionCommand={onCloseSessionCommand} + onOpenFile={onOpenFile} onPaneDrop={onPaneDrop} onReplaceWithSession={onReplaceWithSession} onSplitDraftPane={onSplitDraftPane} @@ -314,9 +471,13 @@ const PaneNodeRenderer: FC = ({ node={child} workspaceId={workspaceId} onAssignSession={onAssignSession} + onActivateEditorPane={onActivateEditorPane} + onBlurEditorFocus={onBlurEditorFocus} onCloseDraftPane={onCloseDraftPane} + onCloseEditorPane={onCloseEditorPane} onCloseSession={onCloseSession} onCloseSessionCommand={onCloseSessionCommand} + onOpenFile={onOpenFile} onPaneDrop={onPaneDrop} onReplaceWithSession={onReplaceWithSession} onSplitDraftPane={onSplitDraftPane} diff --git a/packages/web/src/features/agent-panes/pane-layout-tree.test.ts b/packages/web/src/features/agent-panes/pane-layout-tree.test.ts index 9ab6f97f..05924f0d 100644 --- a/packages/web/src/features/agent-panes/pane-layout-tree.test.ts +++ b/packages/web/src/features/agent-panes/pane-layout-tree.test.ts @@ -5,17 +5,96 @@ import { appendSessionToWidestColumn, assignSessionToPane, closeDraftPaneById, + closeEditorPaneById, closePaneBySessionId, + convertDraftPaneToEditor, createFallbackPaneLayout, + enforceSingleEditorPaneInvariant, + findEditorPaneId, insertPaneAtEdge, - moveSessionToDraftPane, removePaneBySessionId, splitPaneByPaneId, splitPaneBySessionId, + swapPaneLeavesByPaneId, swapPaneSessionsByPaneId, } from "./pane-layout-tree"; describe("pane-layout-tree", () => { + it("converts a draft leaf into an editor leaf by pane id", () => { + const layout: PaneNode = { + id: "root", + type: "leaf", + leafKind: "draft", + }; + + expect(convertDraftPaneToEditor(layout, "root")).toEqual({ + id: "root", + type: "leaf", + leafKind: "editor", + }); + }); + + it("keeps the existing editor leaf when another draft tries to convert", () => { + const layout: PaneNode = { + id: "root", + type: "split", + direction: "horizontal", + children: [ + { id: "left", type: "leaf", leafKind: "editor" }, + { id: "right", type: "leaf", leafKind: "draft" }, + ], + }; + + expect(convertDraftPaneToEditor(layout, "right")).toBe(layout); + }); + + it("collapses extra editor leaves back to drafts when enforcing the invariant", () => { + const layout: PaneNode = { + id: "root", + type: "split", + direction: "horizontal", + children: [ + { id: "left", type: "leaf", leafKind: "editor" }, + { id: "right", type: "leaf", leafKind: "editor" }, + ], + }; + + expect(enforceSingleEditorPaneInvariant(layout)).toEqual({ + id: "root", + type: "split", + direction: "horizontal", + children: [ + { id: "left", type: "leaf", leafKind: "editor" }, + { id: "right", type: "leaf", leafKind: "draft" }, + ], + }); + expect(findEditorPaneId(layout)).toBe("left"); + }); + + it("turns a closed editor leaf back into a draft leaf while preserving siblings", () => { + const layout: PaneNode = { + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", leafKind: "editor" }, + { id: "right", type: "leaf", leafKind: "session", sessionId: "sess_2" }, + ], + }; + + expect(closeEditorPaneById(layout, "left")).toEqual({ + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", leafKind: "draft" }, + { id: "right", type: "leaf", leafKind: "session", sessionId: "sess_2" }, + ], + }); + }); + it("splits a session leaf into the original session and a draft pane", () => { const layout: PaneNode = { id: "root", @@ -191,99 +270,30 @@ describe("pane-layout-tree", () => { expect(swapPaneSessionsByPaneId(layout, "left", "missing")).toBe(layout); }); - it("moves a session into a draft leaf and collapses the old source branch", () => { + it("swaps editor and session leaf contents without changing pane ids", () => { const layout: PaneNode = { id: "root", type: "split", direction: "horizontal", ratio: 0.5, children: [ - { - id: "left-split", - type: "split", - direction: "vertical", - ratio: 0.5, - children: [ - { id: "left-top", type: "leaf", sessionId: "sess_1" }, - { id: "left-bottom", type: "leaf", sessionId: "sess_2" }, - ], - }, - { id: "right", type: "leaf" }, + { id: "left", type: "leaf", leafKind: "editor" }, + { id: "right", type: "leaf", leafKind: "session", sessionId: "sess_2" }, ], }; - expect(moveSessionToDraftPane(layout, "left-bottom", "right")).toEqual({ + expect(swapPaneLeavesByPaneId(layout, "left", "right")).toEqual({ id: "root", type: "split", direction: "horizontal", ratio: 0.5, children: [ - { id: "left-top", type: "leaf", sessionId: "sess_1" }, - { id: "right", type: "leaf", sessionId: "sess_2" }, + { id: "left", type: "leaf", leafKind: "session", sessionId: "sess_2" }, + { id: "right", type: "leaf", leafKind: "editor" }, ], }); }); - it("returns the original tree when move source pane is missing", () => { - const layout: PaneNode = { - id: "root", - type: "split", - direction: "horizontal", - ratio: 0.5, - children: [ - { id: "left", type: "leaf", sessionId: "sess_1" }, - { id: "right", type: "leaf" }, - ], - }; - - expect(moveSessionToDraftPane(layout, "missing", "right")).toBe(layout); - }); - - it("returns the original tree when move target pane is missing", () => { - const layout: PaneNode = { - id: "root", - type: "split", - direction: "horizontal", - ratio: 0.5, - children: [ - { id: "left", type: "leaf", sessionId: "sess_1" }, - { id: "right", type: "leaf" }, - ], - }; - - expect(moveSessionToDraftPane(layout, "left", "missing")).toBe(layout); - }); - - it("returns the original tree when move target pane is a session leaf", () => { - const layout: PaneNode = { - id: "root", - type: "split", - direction: "horizontal", - ratio: 0.5, - children: [ - { id: "left", type: "leaf", sessionId: "sess_1" }, - { id: "right", type: "leaf", sessionId: "sess_2" }, - ], - }; - - expect(moveSessionToDraftPane(layout, "left", "right")).toBe(layout); - }); - - it("returns the original tree when move source pane is a draft leaf", () => { - const layout: PaneNode = { - id: "root", - type: "split", - direction: "horizontal", - ratio: 0.5, - children: [ - { id: "left", type: "leaf" }, - { id: "right", type: "leaf" }, - ], - }; - - expect(moveSessionToDraftPane(layout, "left", "right")).toBe(layout); - }); - it("wraps the target leaf with a horizontal split on left insert", () => { const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1700000000000); @@ -386,6 +396,54 @@ describe("pane-layout-tree", () => { }); }); + it("wraps a session target with an editor source on edge insert", () => { + const layout: PaneNode = { + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", leafKind: "editor" }, + { id: "right", type: "leaf", leafKind: "session", sessionId: "sess_2" }, + ], + }; + + expect(insertPaneAtEdge(layout, "left", "right", "left")).toEqual({ + id: expect.stringMatching(/^split-right-left-/), + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", leafKind: "editor" }, + { id: "right", type: "leaf", leafKind: "session", sessionId: "sess_2" }, + ], + }); + }); + + it("wraps a session target with a draft source on edge insert", () => { + const layout: PaneNode = { + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", leafKind: "draft" }, + { id: "right", type: "leaf", leafKind: "session", sessionId: "sess_2" }, + ], + }; + + expect(insertPaneAtEdge(layout, "left", "right", "right")).toEqual({ + id: expect.stringMatching(/^split-right-right-/), + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "right", type: "leaf", leafKind: "session", sessionId: "sess_2" }, + { id: "left", type: "leaf", leafKind: "draft" }, + ], + }); + }); + it("returns the original tree when attempting to drag onto the same pane", () => { const layout: PaneNode = { id: "root", @@ -399,11 +457,10 @@ describe("pane-layout-tree", () => { }; expect(insertPaneAtEdge(layout, "left", "left", "left")).toBe(layout); - expect(moveSessionToDraftPane(layout, "left", "left")).toBe(layout); expect(swapPaneSessionsByPaneId(layout, "left", "left")).toBe(layout); }); - it("rejects draft edge insertion and preserves the input layout", () => { + it("wraps a draft target leaf on edge insert", () => { const layout: PaneNode = { id: "root", type: "split", @@ -415,7 +472,16 @@ describe("pane-layout-tree", () => { ], }; - expect(insertPaneAtEdge(layout, "left", "right", "right")).toBe(layout); + expect(insertPaneAtEdge(layout, "left", "right", "right")).toEqual({ + id: expect.stringMatching(/^split-right-right-/), + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "right", type: "leaf" }, + { id: "left", type: "leaf", sessionId: "sess_1" }, + ], + }); }); it("splits a draft pane by pane id without relying on a session id marker", () => { @@ -499,6 +565,29 @@ describe("pane-layout-tree", () => { }); }); + it("appends a session beside an existing editor leaf without replacing the layout", () => { + const layout: PaneNode = { + id: "editor-pane", + type: "leaf", + leafKind: "editor", + }; + + expect(appendSessionToLayout(layout, "sess_2")).toEqual({ + id: expect.stringMatching(/^split-editor-pane-horizontal-/), + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "editor-pane", type: "leaf", leafKind: "editor" }, + expect.objectContaining({ + type: "leaf", + leafKind: "session", + sessionId: "sess_2", + }), + ], + }); + }); + it("appends a new session by splitting the widest column horizontally", () => { const layout: PaneNode = { id: "root", @@ -557,15 +646,15 @@ describe("pane-layout-tree", () => { direction: "horizontal", ratio: 0.5, children: [ - { id: "fallback-leaf-1", type: "leaf", sessionId: "sess_1" }, + { id: "fallback-leaf-1", type: "leaf", leafKind: "session", sessionId: "sess_1" }, { id: "split-fallback-2", type: "split", direction: "horizontal", ratio: 0.5, children: [ - { id: "fallback-leaf-2", type: "leaf", sessionId: "sess_2" }, - { id: "fallback-leaf-3", type: "leaf", sessionId: "sess_3" }, + { id: "fallback-leaf-2", type: "leaf", leafKind: "session", sessionId: "sess_2" }, + { id: "fallback-leaf-3", type: "leaf", leafKind: "session", sessionId: "sess_3" }, ], }, ], diff --git a/packages/web/src/features/agent-panes/pane-layout-tree.ts b/packages/web/src/features/agent-panes/pane-layout-tree.ts index bf42f6fe..623bb5d4 100644 --- a/packages/web/src/features/agent-panes/pane-layout-tree.ts +++ b/packages/web/src/features/agent-panes/pane-layout-tree.ts @@ -1,20 +1,78 @@ -import type { PaneNode } from "./atoms/pane-layout"; +import type { PaneLeaf, PaneNode, PaneSplit } from "./atoms/pane-layout"; -type PaneDirection = NonNullable; +type PaneDirection = NonNullable; type PaneDropPlacement = "left" | "right" | "top" | "bottom" | "center"; -function createDraftLeaf(id: string): PaneNode { +function isLegacyLeaf(node: PaneNode): boolean { + return node.type === "leaf" && node.leafKind === undefined; +} + +function isDraftLeaf(node: PaneNode): boolean { + return node.type === "leaf" && (node.leafKind === "draft" || (!node.leafKind && !node.sessionId)); +} + +function isSessionLeaf(node: PaneNode): boolean { + return node.type === "leaf" && (node.leafKind === "session" || Boolean(node.sessionId)); +} + +function isEditorLeaf(node: PaneNode): boolean { + return node.type === "leaf" && node.leafKind === "editor"; +} + +export function findEditorPaneId(node: PaneNode): string | null { + if (node.type === "leaf") { + return isEditorLeaf(node) ? node.id : null; + } + + for (const child of node.children ?? []) { + const match = findEditorPaneId(child); + if (match) { + return match; + } + } + + return null; +} + +export function paneLayoutHasEditorPaneId(node: PaneNode, paneId: string): boolean { + const leaf = findLeafByPaneId(node, paneId); + return leaf ? isEditorLeaf(leaf) : false; +} + +export function paneLayoutHasDraftPaneId(node: PaneNode, paneId: string): boolean { + const leaf = findLeafByPaneId(node, paneId); + return leaf ? isDraftLeaf(leaf) : false; +} + +function createDraftLeaf(id: string, legacy = false): PaneLeaf { return { id, type: "leaf", + ...(legacy ? {} : { leafKind: "draft" }), }; } -function createSessionLeaf(id: string, sessionId: string): PaneNode { +function createSessionLeaf(id: string, sessionId: string, legacy = false): PaneLeaf { return { id, type: "leaf", sessionId, + ...(legacy ? {} : { leafKind: "session" }), + }; +} + +function createEditorLeaf(id: string): PaneLeaf { + return { + id, + type: "leaf", + leafKind: "editor", + }; +} + +function cloneLeafWithId(leaf: PaneLeaf, id: string): PaneLeaf { + return { + ...leaf, + id, }; } @@ -25,7 +83,7 @@ function createDragSplitId( return `split-${targetPaneId}-${placement}-${Date.now()}`; } -function findLeafByPaneId(node: PaneNode, paneId: string): PaneNode | null { +function findLeafByPaneId(node: PaneNode, paneId: string): PaneLeaf | null { if (node.type === "leaf") { return node.id === paneId ? node : null; } @@ -43,7 +101,7 @@ function findLeafByPaneId(node: PaneNode, paneId: string): PaneNode | null { function replaceLeafByPaneId( node: PaneNode, paneId: string, - replace: (leaf: PaneNode) => PaneNode + replace: (leaf: PaneLeaf) => PaneNode ): PaneNode { if (node.type === "leaf") { if (node.id !== paneId) { @@ -129,7 +187,7 @@ export function splitPaneByPaneId( type: "split", direction, ratio: 0.5, - children: [{ ...node }, createDraftLeaf(`${splitId}-draft`)], + children: [{ ...node }, createDraftLeaf(`${splitId}-draft`, isLegacyLeaf(node))], }; } @@ -169,7 +227,7 @@ export function splitPaneBySessionId( type: "split", direction, ratio: 0.5, - children: [{ ...node }, createDraftLeaf(`${splitId}-draft`)], + children: [{ ...node }, createDraftLeaf(`${splitId}-draft`, isLegacyLeaf(node))], }; } @@ -199,10 +257,7 @@ export function assignSessionToPane(node: PaneNode, paneId: string, sessionId: s return node; } - return { - ...node, - sessionId, - }; + return createSessionLeaf(node.id, sessionId, isLegacyLeaf(node)); } const children = node.children ?? []; @@ -235,10 +290,7 @@ export function replaceSessionInPane( return node; } - return { - ...node, - sessionId: nextSessionId, - }; + return createSessionLeaf(node.id, nextSessionId, isLegacyLeaf(node)); } const children = node.children ?? []; @@ -287,7 +339,7 @@ export function swapPaneSessionsByPaneId( })); } -export function moveSessionToDraftPane( +export function swapPaneLeavesByPaneId( node: PaneNode, sourcePaneId: string, targetPaneId: string @@ -298,12 +350,17 @@ export function moveSessionToDraftPane( const source = findLeafByPaneId(node, sourcePaneId); const target = findLeafByPaneId(node, targetPaneId); - if (!source?.sessionId || !target || target.sessionId) { + if (!source || !target) { return node; } - const stripped = removeLeafByPaneId(node, sourcePaneId) ?? { id: node.id, type: "leaf" }; - return assignSessionToPane(stripped, targetPaneId, source.sessionId); + const withSourceSwapped = replaceLeafByPaneId(node, sourcePaneId, (leaf) => + cloneLeafWithId(target, leaf.id) + ); + + return replaceLeafByPaneId(withSourceSwapped, targetPaneId, (leaf) => + cloneLeafWithId(source, leaf.id) + ); } export function insertPaneAtEdge( @@ -318,16 +375,14 @@ export function insertPaneAtEdge( const source = findLeafByPaneId(node, sourcePaneId); const target = findLeafByPaneId(node, targetPaneId); - if (!source?.sessionId || !target?.sessionId) { + if (!source || !target) { return node; } - const stripped = removeLeafByPaneId(node, sourcePaneId) ?? { id: node.id, type: "leaf" }; - const incomingLeaf: PaneNode = { - id: source.id, - type: "leaf", - sessionId: source.sessionId, - }; + const stripped = + removeLeafByPaneId(node, sourcePaneId) ?? + createDraftLeaf(node.id, node.type === "leaf" && isLegacyLeaf(node)); + const incomingLeaf = cloneLeafWithId(source, source.id); return replaceLeafByPaneId(stripped, targetPaneId, (leaf) => ({ id: createDragSplitId(leaf.id, placement), @@ -353,7 +408,7 @@ export function closePaneBySessionId(node: PaneNode, sessionId: string): PaneNod */ function closeDraftPane(node: PaneNode, paneId: string): PaneNode | null { if (node.type === "leaf") { - if (node.id === paneId && !node.sessionId) { + if (node.id === paneId && isDraftLeaf(node)) { return null; } return node; @@ -391,7 +446,35 @@ function closeDraftPane(node: PaneNode, paneId: string): PaneNode | null { } export function closeDraftPaneById(node: PaneNode, paneId: string): PaneNode { - return closeDraftPane(node, paneId) ?? { id: node.id, type: "leaf" }; + return ( + closeDraftPane(node, paneId) ?? + createDraftLeaf(node.id, node.type === "leaf" && isLegacyLeaf(node)) + ); +} + +export function convertDraftPaneToEditor(node: PaneNode, paneId: string): PaneNode { + const existingEditorPaneId = findEditorPaneId(node); + if (existingEditorPaneId && existingEditorPaneId !== paneId) { + return node; + } + + return replaceLeafByPaneId(node, paneId, (leaf) => { + if (!isDraftLeaf(leaf)) { + return leaf; + } + + return createEditorLeaf(leaf.id); + }); +} + +export function closeEditorPaneById(node: PaneNode, paneId: string): PaneNode { + return replaceLeafByPaneId(node, paneId, (leaf) => { + if (!isEditorLeaf(leaf)) { + return leaf; + } + + return createDraftLeaf(leaf.id); + }); } /** @@ -403,10 +486,7 @@ export function closeDraftPaneById(node: PaneNode, paneId: string): PaneNode { function replaceSessionWithDraft(node: PaneNode, sessionId: string): PaneNode { if (node.type === "leaf") { if (node.sessionId === sessionId) { - return { - id: node.id, - type: "leaf", - }; + return createDraftLeaf(node.id, isLegacyLeaf(node)); } return node; } @@ -432,7 +512,10 @@ function replaceSessionWithDraft(node: PaneNode, sessionId: string): PaneNode { } export function removePaneBySessionId(node: PaneNode, sessionId: string): PaneNode { - return removeSessionPane(node, sessionId) ?? { id: node.id, type: "leaf" }; + return ( + removeSessionPane(node, sessionId) ?? + createDraftLeaf(node.id, node.type === "leaf" && isLegacyLeaf(node)) + ); } function removeSessionPane(node: PaneNode, sessionId: string): PaneNode | null { @@ -476,7 +559,7 @@ function removeSessionPane(node: PaneNode, sessionId: string): PaneNode | null { export function paneLayoutHasSession(node: PaneNode, sessionIds: Set): boolean { if (node.type === "leaf") { - return node.sessionId ? sessionIds.has(node.sessionId) : false; + return isSessionLeaf(node) && node.sessionId ? sessionIds.has(node.sessionId) : false; } return node.children?.some((child) => paneLayoutHasSession(child, sessionIds)) ?? false; @@ -487,7 +570,7 @@ export function paneLayoutReferencesMissingSession( sessionIds: Set ): boolean { if (node.type === "leaf") { - return node.sessionId ? !sessionIds.has(node.sessionId) : false; + return isSessionLeaf(node) && node.sessionId ? !sessionIds.has(node.sessionId) : false; } return ( @@ -500,7 +583,7 @@ export function paneLayoutReferencesMissingSession( */ export function collectSessionIds(node: PaneNode): string[] { if (node.type === "leaf") { - return node.sessionId ? [node.sessionId] : []; + return isSessionLeaf(node) && node.sessionId ? [node.sessionId] : []; } return node.children?.flatMap((child) => collectSessionIds(child)) ?? []; } @@ -528,18 +611,16 @@ export function appendSessionToLayout( return fallbackSplit; } - if (node.type === "leaf" && !node.sessionId) { - return { - ...node, - sessionId, - }; + const anyLeafSplit = splitAnyLeafForNewSession(node, sessionId, direction); + if (anyLeafSplit) { + return anyLeafSplit; } - return { - id: "root", - type: "leaf", - sessionId, - }; + if (node.type === "leaf" && isDraftLeaf(node)) { + return createSessionLeaf(node.id, sessionId, isLegacyLeaf(node)); + } + + return createSessionLeaf("root", sessionId); } export function appendSessionToWidestColumn(node: PaneNode, sessionId: string): PaneNode { @@ -558,11 +639,11 @@ export function appendSessionToWidestColumn(node: PaneNode, sessionId: string): export function createFallbackPaneLayout(sessionIds: string[]): PaneNode { if (sessionIds.length === 0) { - return { id: "root", type: "leaf" }; + return createDraftLeaf("root"); } if (sessionIds.length === 1) { - return { id: "fallback-leaf-1", type: "leaf", sessionId: sessionIds[0]! }; + return createSessionLeaf("fallback-leaf-1", sessionIds[0]!); } const [firstId, ...rest] = sessionIds; @@ -572,7 +653,7 @@ export function createFallbackPaneLayout(sessionIds: string[]): PaneNode { direction: "horizontal", ratio: 0.5, children: [ - { id: "fallback-leaf-1", type: "leaf", sessionId: firstId! }, + createSessionLeaf("fallback-leaf-1", firstId!), createFallbackPaneLayoutBranch(rest, 2), ], }; @@ -585,8 +666,8 @@ export function createFallbackPaneLayout(sessionIds: string[]): PaneNode { export function sanitizePaneLayout(node: PaneNode, liveSessionIds: Set): PaneNode { if (node.type === "leaf") { // If this leaf references a session that is ended or removed, turn it into a draft. - if (node.sessionId && !liveSessionIds.has(node.sessionId)) { - return { id: node.id, type: "leaf" }; + if (isSessionLeaf(node) && node.sessionId && !liveSessionIds.has(node.sessionId)) { + return createDraftLeaf(node.id, isLegacyLeaf(node)); } return node; } @@ -613,11 +694,8 @@ export function sanitizePaneLayout(node: PaneNode, liveSessionIds: Set): function assignFirstDraftPane(node: PaneNode, sessionId: string): PaneNode | null { if (node.type === "leaf") { - if (!node.sessionId) { - return { - ...node, - sessionId, - }; + if (isDraftLeaf(node)) { + return createSessionLeaf(node.id, sessionId, isLegacyLeaf(node)); } return null; @@ -642,6 +720,60 @@ function assignFirstDraftPane(node: PaneNode, sessionId: string): PaneNode | nul return null; } +export function enforceSingleEditorPaneInvariant(node: PaneNode): PaneNode { + return enforceSingleEditorPaneInvariantInternal(node, false).node; +} + +function enforceSingleEditorPaneInvariantInternal( + node: PaneNode, + hasSeenEditorPane: boolean +): { node: PaneNode; hasSeenEditorPane: boolean } { + if (node.type === "leaf") { + if (!isEditorLeaf(node)) { + return { node, hasSeenEditorPane }; + } + + if (hasSeenEditorPane) { + return { + node: createDraftLeaf(node.id), + hasSeenEditorPane, + }; + } + + return { + node, + hasSeenEditorPane: true, + }; + } + + const children = node.children ?? []; + let changed = false; + let nextHasSeenEditorPane = hasSeenEditorPane; + const nextChildren = children.map((child) => { + const result = enforceSingleEditorPaneInvariantInternal(child, nextHasSeenEditorPane); + if (result.node !== child) { + changed = true; + } + nextHasSeenEditorPane = result.hasSeenEditorPane; + return result.node; + }); + + if (!changed) { + return { + node, + hasSeenEditorPane: nextHasSeenEditorPane, + }; + } + + return { + node: { + ...node, + children: nextChildren, + }, + hasSeenEditorPane: nextHasSeenEditorPane, + }; +} + function splitLeafForNewSession( node: PaneNode, sessionId: string, @@ -649,7 +781,7 @@ function splitLeafForNewSession( preferredSessionId?: string ): PaneNode | null { if (node.type === "leaf") { - if (!node.sessionId) { + if (!isSessionLeaf(node) || !node.sessionId) { return null; } @@ -663,7 +795,10 @@ function splitLeafForNewSession( type: "split", direction, ratio: 0.5, - children: [{ ...node }, createSessionLeaf(`${splitId}-session`, sessionId)], + children: [ + { ...node }, + createSessionLeaf(`${splitId}-session`, sessionId, isLegacyLeaf(node)), + ], }; } @@ -686,6 +821,48 @@ function splitLeafForNewSession( return null; } +function splitAnyLeafForNewSession( + node: PaneNode, + sessionId: string, + direction: PaneDirection +): PaneNode | null { + if (node.type === "leaf") { + if (isDraftLeaf(node)) { + return null; + } + + const splitId = `split-${node.id}-${direction}-${Date.now()}`; + return { + id: splitId, + type: "split", + direction, + ratio: 0.5, + children: [ + { ...node }, + createSessionLeaf(`${splitId}-session`, sessionId, isLegacyLeaf(node)), + ], + }; + } + + const children = node.children ?? []; + for (let index = 0; index < children.length; index += 1) { + const child = children[index]!; + const nextChild = splitAnyLeafForNewSession(child, sessionId, direction); + if (!nextChild) { + continue; + } + + return { + ...node, + children: children.map((candidate, candidateIndex) => + candidateIndex === index ? nextChild : candidate + ), + }; + } + + return null; +} + interface ColumnCandidate { path: number[]; width: number; @@ -699,12 +876,13 @@ function splitWidestColumnForNewSession(node: PaneNode, sessionId: string): Pane return replaceNodeAtPath(node, candidate.path, (target) => { const splitId = `split-${target.id}-horizontal-${Date.now()}`; + const legacy = target.type === "leaf" && isLegacyLeaf(target); return { id: splitId, type: "split", direction: "horizontal", ratio: 0.5, - children: [{ ...target }, createSessionLeaf(`${splitId}-session`, sessionId)], + children: [{ ...target }, createSessionLeaf(`${splitId}-session`, sessionId, legacy)], }; }); } @@ -715,7 +893,7 @@ function findWidestColumnCandidate( path: number[] = [] ): ColumnCandidate | null { if (node.type === "leaf") { - if (!node.sessionId) { + if (!isSessionLeaf(node) || !node.sessionId) { return null; } @@ -751,7 +929,7 @@ function findWidestColumnCandidate( function subtreeHasSession(node: PaneNode): boolean { if (node.type === "leaf") { - return Boolean(node.sessionId); + return isSessionLeaf(node) && Boolean(node.sessionId); } return (node.children ?? []).some((child) => subtreeHasSession(child)); @@ -801,11 +979,7 @@ function replaceNodeAtPath( function createFallbackPaneLayoutBranch(sessionIds: string[], startIndex: number): PaneNode { if (sessionIds.length === 1) { - return { - id: `fallback-leaf-${startIndex}`, - type: "leaf", - sessionId: sessionIds[0]!, - }; + return createSessionLeaf(`fallback-leaf-${startIndex}`, sessionIds[0]!); } const [firstId, ...rest] = sessionIds; @@ -815,11 +989,7 @@ function createFallbackPaneLayoutBranch(sessionIds: string[], startIndex: number direction: "horizontal", ratio: 0.5, children: [ - { - id: `fallback-leaf-${startIndex}`, - type: "leaf", - sessionId: firstId!, - }, + createSessionLeaf(`fallback-leaf-${startIndex}`, firstId!), createFallbackPaneLayoutBranch(rest, startIndex + 1), ], }; diff --git a/packages/web/src/features/agent-panes/views/shared/draft-launcher.test.tsx b/packages/web/src/features/agent-panes/views/shared/draft-launcher.test.tsx index 4772044f..e615250e 100644 --- a/packages/web/src/features/agent-panes/views/shared/draft-launcher.test.tsx +++ b/packages/web/src/features/agent-panes/views/shared/draft-launcher.test.tsx @@ -1,16 +1,25 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { act, createEvent, fireEvent, render, screen } from "@testing-library/react"; import { createStore, Provider } from "jotai"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { localeAtom } from "../../../../atoms/app-ui"; import { wsClientAtom } from "../../../../atoms/connection"; +import { WORKSPACE_PATH_DRAG_MIME } from "../../../../lib/workspace-path-drag"; import { DraftLauncher } from "./draft-launcher"; const mockUseProviderLauncher = vi.fn(); +const paneDragEnabledMock = vi.hoisted(() => ({ + value: true, +})); +const originalResizeObserver = global.ResizeObserver; vi.mock("../../actions/use-provider-launcher", () => ({ useProviderLauncher: (...args: unknown[]) => mockUseProviderLauncher(...args), })); +vi.mock("../../actions/use-pane-drag-enabled", () => ({ + usePaneDragEnabled: () => paneDragEnabledMock.value, +})); + function createRuntimeState(providerId: "claude" | "codex") { return { runtime: { @@ -30,9 +39,60 @@ function createRuntimeState(providerId: "claude" | "codex") { }; } +function createDraftLauncherStore() { + const store = createStore(); + + store.set(localeAtom, "en"); + store.set(wsClientAtom, { + sendCommand: vi.fn(), + subscribe: vi.fn(() => () => {}), + } as never); + + return store; +} + +function installResizeObserverMock() { + let callback: ResizeObserverCallback | null = null; + + class ResizeObserverMock { + constructor(observerCallback: ResizeObserverCallback) { + callback = observerCallback; + } + + observe() {} + disconnect() {} + } + + global.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver; + + return { + resize(target: Element, width: number) { + if (!callback) { + throw new Error("ResizeObserver was not created"); + } + + callback( + [ + { + target, + contentRect: { width }, + } as ResizeObserverEntry, + ], + {} as ResizeObserver + ); + }, + }; +} + describe("DraftLauncher", () => { + afterEach(() => { + global.ResizeObserver = originalResizeObserver; + vi.useRealTimers(); + }); + beforeEach(() => { vi.clearAllMocks(); + paneDragEnabledMock.value = true; mockUseProviderLauncher.mockReturnValue({ states: { claude: createRuntimeState("claude"), @@ -87,6 +147,29 @@ describe("DraftLauncher", () => { expect(onClosePane).toHaveBeenCalledWith("pane-1"); }); + it("renders a drag handle in the header actions on desktop", () => { + const store = createDraftLauncherStore(); + const onPaneDragStart = vi.fn(); + + render( + + + + ); + + const dragHandle = screen.getByRole("button", { name: "Drag pane" }); + + expect(dragHandle).toBeInTheDocument(); + + fireEvent.pointerDown(dragHandle); + + expect(onPaneDragStart).toHaveBeenCalledWith(expect.objectContaining({ paneId: "pane-1" })); + }); + it("renders provider cards with semantic business icons", () => { const store = createStore(); @@ -124,6 +207,93 @@ describe("DraftLauncher", () => { expect(screen.getByText("Select Agent")).toBeInTheDocument(); }); + it("switches draft launcher carousel panels", () => { + const store = createDraftLauncherStore(); + + const { container } = render( + + + + ); + + const agentButton = screen.getByRole("button", { name: "Agent" }); + const fileButton = screen.getByRole("button", { name: "File Editor" }); + const carouselTrack = container.querySelector(".agent-draft-component-row"); + + expect(agentButton).toHaveAttribute("aria-pressed", "true"); + expect(fileButton).toHaveAttribute("aria-pressed", "false"); + expect(carouselTrack).not.toHaveClass("agent-draft-component-row--file"); + + fireEvent.click(fileButton); + + expect(agentButton).toHaveAttribute("aria-pressed", "false"); + expect(fileButton).toHaveAttribute("aria-pressed", "true"); + expect(carouselTrack).toHaveClass("agent-draft-component-row--file"); + }); + + it("auto-rotates draft launcher carousel panels in compact layout", async () => { + vi.useFakeTimers(); + const resizeObserver = installResizeObserverMock(); + const store = createDraftLauncherStore(); + + const { container } = render( + + + + ); + + const launcher = container.querySelector(".agent-draft-launcher"); + const agentButton = screen.getByRole("button", { name: "Agent" }); + const fileButton = screen.getByRole("button", { name: "File Editor" }); + const carouselTrack = container.querySelector(".agent-draft-component-row"); + + expect(launcher).not.toBeNull(); + expect(carouselTrack).not.toHaveClass("agent-draft-component-row--file"); + + act(() => { + resizeObserver.resize(launcher as Element, 360); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(4000); + }); + + expect(agentButton).toHaveAttribute("aria-pressed", "false"); + expect(fileButton).toHaveAttribute("aria-pressed", "true"); + expect(carouselTrack).toHaveClass("agent-draft-component-row--file"); + }); + + it("does not auto-rotate draft launcher carousel panels in wide layout", async () => { + vi.useFakeTimers(); + const resizeObserver = installResizeObserverMock(); + const store = createDraftLauncherStore(); + + const { container } = render( + + + + ); + + const launcher = container.querySelector(".agent-draft-launcher"); + const agentButton = screen.getByRole("button", { name: "Agent" }); + const fileButton = screen.getByRole("button", { name: "File Editor" }); + const carouselTrack = container.querySelector(".agent-draft-component-row"); + + expect(launcher).not.toBeNull(); + + act(() => { + resizeObserver.resize(launcher as Element, 640); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(4000); + }); + + expect(agentButton).toHaveAttribute("aria-pressed", "true"); + expect(fileButton).toHaveAttribute("aria-pressed", "false"); + expect(carouselTrack).not.toHaveClass("agent-draft-component-row--file"); + }); + it("renders a draft drop label when pane drag hover is active", () => { const store = createStore(); @@ -193,4 +363,72 @@ describe("DraftLauncher", () => { "/diagnostics?context=session_start&workspaceId=ws-123&providerId=claude&paneId=pane-1&launchMode=assign" ); }); + + it("highlights file drag-over state and opens the dropped workspace file in an editor pane", async () => { + const store = createStore(); + const onOpenFile = vi.fn(); + + store.set(localeAtom, "en"); + store.set(wsClientAtom, { + sendCommand: vi.fn(), + subscribe: vi.fn(() => () => {}), + } as never); + + const { container } = render( + + + + ); + + const root = container.querySelector('[data-pane-id="pane-1"]') as HTMLElement; + const payload = { workspaceId: "ws-123", path: "src/app.tsx", kind: "file" as const }; + + const dataTransfer = { + files: [], + types: [WORKSPACE_PATH_DRAG_MIME, "text/plain"], + items: [], + getData: (type: string) => + type === WORKSPACE_PATH_DRAG_MIME ? JSON.stringify(payload) : payload.path, + }; + const dragOver = createEvent.dragOver(root, { dataTransfer }); + fireEvent(root, dragOver); + + expect(dragOver.defaultPrevented).toBe(true); + expect(await screen.findByText("Open in editor")).toBeInTheDocument(); + + const drop = createEvent.drop(root, { dataTransfer }); + fireEvent(root, drop); + + expect(drop.defaultPrevented).toBe(true); + expect(onOpenFile).toHaveBeenCalledWith("pane-1", "src/app.tsx"); + }); + + it("allows drag-over for workspace file drags even when payload data is not readable until drop", async () => { + const store = createStore(); + + store.set(localeAtom, "en"); + store.set(wsClientAtom, { + sendCommand: vi.fn(), + subscribe: vi.fn(() => () => {}), + } as never); + + const { container } = render( + + + + ); + + const root = container.querySelector('[data-pane-id="pane-1"]') as HTMLElement; + const dataTransfer = { + files: [], + types: [WORKSPACE_PATH_DRAG_MIME, "text/plain"], + items: [], + getData: () => "", + }; + const dragOver = createEvent.dragOver(root, { dataTransfer }); + fireEvent(root, dragOver); + + expect(dragOver.defaultPrevented).toBe(true); + expect(await screen.findByText("Open in editor")).toBeInTheDocument(); + }); }); diff --git a/packages/web/src/features/agent-panes/views/shared/draft-launcher.tsx b/packages/web/src/features/agent-panes/views/shared/draft-launcher.tsx index 57d9e3ec..625e142f 100644 --- a/packages/web/src/features/agent-panes/views/shared/draft-launcher.tsx +++ b/packages/web/src/features/agent-panes/views/shared/draft-launcher.tsx @@ -1,27 +1,53 @@ import type { Session } from "@coder-studio/core"; import { useAtomValue, useSetAtom } from "jotai"; -import { ArrowRight, FlipHorizontal, FlipVertical, X } from "lucide-react"; -import type { FC } from "react"; +import { ArrowRight, FlipHorizontal, FlipVertical, GripVertical, X } from "lucide-react"; +import { type DragEvent, type FC, type PointerEvent, useEffect, useRef, useState } from "react"; import { dispatchCommandAtom } from "../../../../atoms/connection"; import { sessionsAtom } from "../../../../atoms/sessions"; import { Button, IconButton, StatusDot, Tag, ThemedIcon, Tooltip } from "../../../../components/ui"; import { useTranslation } from "../../../../lib/i18n"; +import { + getWorkspacePathDragPayload, + hasWorkspacePathDragType, +} from "../../../../lib/workspace-path-drag"; import { buildDiagnosticsPath } from "../../../diagnostics"; -import type { PaneDropIntent } from "../../actions/pane-drag-types"; +import type { PaneDropIntent, PaneDropPlacement } from "../../actions/pane-drag-types"; +import type { PaneDragSourceSnapshot } from "../../actions/use-pane-drag-controller"; +import { usePaneDragEnabled } from "../../actions/use-pane-drag-enabled"; import { type ProviderId, useProviderLauncher } from "../../actions/use-provider-launcher"; +const COMPACT_CAROUSEL_MAX_WIDTH_REM = 28; +const COMPACT_CAROUSEL_INTERVAL_MS = 4000; + +function getCompactCarouselMaxWidthPx(): number { + if (typeof window === "undefined" || typeof document === "undefined") { + return COMPACT_CAROUSEL_MAX_WIDTH_REM * 16; + } + + const rootFontSize = window.getComputedStyle(document.documentElement).fontSize; + const parsedRootFontSize = Number.parseFloat(rootFontSize); + + if (!Number.isFinite(parsedRootFontSize) || parsedRootFontSize <= 0) { + return COMPACT_CAROUSEL_MAX_WIDTH_REM * 16; + } + + return COMPACT_CAROUSEL_MAX_WIDTH_REM * parsedRootFontSize; +} + interface DraftLauncherDragState { isDragging: boolean; isActiveDropTarget: boolean; - hoverPlacement: "center" | null; + hoverPlacement: PaneDropPlacement | null; } interface DraftLauncherProps { dragState?: DraftLauncherDragState; workspaceId: string; paneId?: string; + onPaneDragStart?: (source: PaneDragSourceSnapshot) => void; onAssignSession?: (paneId: string, sessionId: string) => void; onClosePane?: (paneId: string) => void; + onOpenFile?: (paneId: string, path: string) => void; onPaneDrop?: (intent: PaneDropIntent) => void; onReplaceWithSession?: (sessionId: string) => void; onSplitPane?: (paneId: string, direction: "horizontal" | "vertical") => void; @@ -31,8 +57,10 @@ export const DraftLauncher: FC = ({ dragState, workspaceId, paneId, + onPaneDragStart, onAssignSession, onClosePane, + onOpenFile, onPaneDrop: _onPaneDrop, onReplaceWithSession, onSplitPane, @@ -40,6 +68,14 @@ export const DraftLauncher: FC = ({ const t = useTranslation(); const dispatch = useAtomValue(dispatchCommandAtom); const setSessions = useSetAtom(sessionsAtom); + const [activePanel, setActivePanel] = useState<"agent" | "file">("agent"); + const [isCompactCarousel, setIsCompactCarousel] = useState(false); + const [isFileDropTarget, setIsFileDropTarget] = useState(false); + const draftLauncherRef = useRef(null); + const swipeStartXRef = useRef(null); + const supportsPaneDrag = usePaneDragEnabled(); + const canDragPane = supportsPaneDrag && Boolean(paneId && onPaneDragStart); + const paneDropOverlayPlacement = dragState?.isActiveDropTarget ? dragState.hoverPlacement : null; const { states, launch } = useProviderLauncher( dispatch, workspaceId, @@ -75,7 +111,7 @@ export const DraftLauncher: FC = ({ return Boolean(runtime?.autoInstallSupported && runtime.installReadiness === "ready"); }; - const getProviderCta = (providerId: ProviderId): string => { + const _getProviderCta = (providerId: ProviderId): string => { const state = states[providerId]; if ( state.loading || @@ -145,14 +181,142 @@ export const DraftLauncher: FC = ({ onClosePane?.(paneId); }; + const resolveDroppedFilePath = (event: DragEvent): string | null => { + if (!paneId) { + return null; + } + + const payload = getWorkspacePathDragPayload(event.dataTransfer); + if (!payload || payload.workspaceId !== workspaceId || payload.kind !== "file") { + return null; + } + + return payload.path; + }; + + const handleDragOver = (event: DragEvent) => { + if (!paneId || !hasWorkspacePathDragType(event.dataTransfer)) { + return; + } + + event.preventDefault(); + setIsFileDropTarget(true); + }; + + const handleDragLeave = () => { + setIsFileDropTarget(false); + }; + + const handleDrop = (event: DragEvent) => { + const path = resolveDroppedFilePath(event); + setIsFileDropTarget(false); + if (!path || !paneId) { + return; + } + + event.preventDefault(); + onOpenFile?.(paneId, path); + }; + + const handleCarouselPointerDown = (event: PointerEvent) => { + if (event.pointerType === "mouse") { + return; + } + + swipeStartXRef.current = event.clientX; + }; + + const handleCarouselPointerUp = (event: PointerEvent) => { + const startX = swipeStartXRef.current; + swipeStartXRef.current = null; + + if (startX === null || event.pointerType === "mouse") { + return; + } + + const deltaX = event.clientX - startX; + if (Math.abs(deltaX) < 48) { + return; + } + + setActivePanel(deltaX < 0 ? "file" : "agent"); + }; + + const handleCarouselPointerCancel = () => { + swipeStartXRef.current = null; + }; + + useEffect(() => { + const element = draftLauncherRef.current; + + if (!element) { + return; + } + + const updateCompactState = (width: number) => { + setIsCompactCarousel(width > 0 && width <= getCompactCarouselMaxWidthPx()); + }; + + updateCompactState(element.getBoundingClientRect().width); + + if (typeof ResizeObserver === "function") { + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + + if (!entry) { + return; + } + + updateCompactState(entry.contentRect.width || entry.target.getBoundingClientRect().width); + }); + + observer.observe(element); + + return () => { + observer.disconnect(); + }; + } + + const handleWindowResize = () => { + updateCompactState(element.getBoundingClientRect().width); + }; + + window.addEventListener("resize", handleWindowResize); + + return () => { + window.removeEventListener("resize", handleWindowResize); + }; + }, []); + + useEffect(() => { + if (!isCompactCarousel) { + return; + } + + const timer = window.setTimeout(() => { + setActivePanel((currentPanel) => (currentPanel === "agent" ? "file" : "agent")); + }, COMPACT_CAROUSEL_INTERVAL_MS); + + return () => { + window.clearTimeout(timer); + }; + }, [activePanel, isCompactCarousel]); + return (
- {dragState?.isActiveDropTarget ? ( + {isFileDropTarget ? (
-
Move here
+
{t("agent_panes.open_in_editor")}
+
+ ) : paneDropOverlayPlacement ? ( +
+
{t("agent_panes.move_here")}
) : null} @@ -161,37 +325,65 @@ export const DraftLauncher: FC = ({
- {t("session.provider_select") || "New Session"} + {t("session.provider_select")} - DRAFT + {t("session.state.draft")}
- + {canDragPane ? ( + + } + onPointerDown={(event) => { + event.preventDefault(); + event.stopPropagation(); + + if (event.pointerType === "touch") { + return; + } + + if (!paneId) { + return; + } + + onPaneDragStart?.({ paneId }); + }} + size="sm" + /> + + ) : null} + } onClick={handleSplitHorizontal} size="sm" /> - + } onClick={handleSplitVertical} size="sm" /> - + } onClick={handleClosePane} size="sm" @@ -200,89 +392,165 @@ export const DraftLauncher: FC = ({
-
+
- SESSION LAUNCHER -

- 选择一个 AI 会话,在当前 workspace 里继续查看文件、运行命令和推进代码修改。 -

-
- {( - [ - { - id: "claude", - title: "Claude", - meta: "analysis", - icon: , - description: "更适合长上下文梳理、方案分析和代码审查。", - className: "agent-provider-card-claude", - }, - { - id: "codex", - title: "Codex", - meta: "workspace", - icon: , - description: "更适合终端操作、直接改文件和逐步修复问题。", - className: "agent-provider-card-codex", - }, - ] as const - ).map((provider) => { - const state = states[provider.id]; - const guide = getProviderGuide(provider.id); - const isBusy = - state.loading || - state.installJob?.status === "queued" || - state.installJob?.status === "running"; - - return ( - + ); + })} +
+
+ +
+
+ + + + + + + {t("agent_panes.file_editor")} +
+ +
+
+ + + + + +
+ + {t("agent_panes.drop_file_to_open")} - - ); - })} +
+
+
+ +
+ {[ + { id: "agent" as const, label: t("agent_panes.agent_panel") }, + { id: "file" as const, label: t("agent_panes.file_editor") }, + ].map((panel) => ( +
+ +
{t("agent_panes.draft_footer")}
diff --git a/packages/web/src/features/agent-panes/views/shared/editor-pane-card.test.tsx b/packages/web/src/features/agent-panes/views/shared/editor-pane-card.test.tsx new file mode 100644 index 00000000..30d3240d --- /dev/null +++ b/packages/web/src/features/agent-panes/views/shared/editor-pane-card.test.tsx @@ -0,0 +1,179 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createStore, Provider } from "jotai"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { localeAtom } from "../../../../atoms/app-ui"; +import { + activeFilePathAtomFamily, + type OpenFile, + openFilesAtomFamily, +} from "../../../workspace/atoms"; +import { EditorPaneCard } from "./editor-pane-card"; + +const mocks = vi.hoisted(() => ({ + editorState: { + marker: "editor-state", + currentFile: undefined as OpenFile | undefined, + }, + mockUseCodeEditorActions: vi.fn(), + mockCodeEditorHost: vi.fn(() =>
Editor Host
), + mockCodeEditorDesktopHeaderActions: vi.fn(() => ( +
+ Editor Toolbar +
+ )), +})); +const paneDragEnabledMock = vi.hoisted(() => ({ + value: true, +})); + +vi.mock("../../actions/use-pane-drag-enabled", () => ({ + usePaneDragEnabled: () => paneDragEnabledMock.value, +})); + +vi.mock("../../../code-editor/actions/use-code-editor-actions", () => ({ + useCodeEditorActions: mocks.mockUseCodeEditorActions, +})); + +vi.mock("../../../code-editor/views/shared/code-editor-host", () => ({ + CodeEditorHost: mocks.mockCodeEditorHost, + CodeEditorDesktopHeaderActions: mocks.mockCodeEditorDesktopHeaderActions, +})); + +describe("EditorPaneCard", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("renders a drag handle in the header actions on desktop", () => { + const store = createStore(); + const onClosePane = vi.fn(); + const onSplitPane = vi.fn(); + const onPaneDragStart = vi.fn(); + + mocks.mockUseCodeEditorActions.mockReturnValue(mocks.editorState); + store.set(localeAtom, "en"); + store.set(activeFilePathAtomFamily("ws-123"), "src/app.tsx"); + + render( + + + + ); + + const dragHandle = screen.getByRole("button", { name: "Drag pane" }); + + expect(dragHandle).toBeInTheDocument(); + + fireEvent.pointerDown(dragHandle); + + expect(onPaneDragStart).toHaveBeenCalledWith(expect.objectContaining({ paneId: "pane-1" })); + }); + + it("renders editor pane actions and delegates split and close callbacks", () => { + const store = createStore(); + const onClosePane = vi.fn(); + const onSplitPane = vi.fn(); + + mocks.mockUseCodeEditorActions.mockReturnValue(mocks.editorState); + store.set(localeAtom, "en"); + store.set(activeFilePathAtomFamily("ws-123"), "src/app.tsx"); + + render( + + + + ); + + expect(screen.getByText("app.tsx")).toBeInTheDocument(); + expect(screen.queryByText("src/app.tsx")).not.toBeInTheDocument(); + expect(screen.getByTestId("editor-toolbar")).toBeInTheDocument(); + expect(screen.getByTestId("editor-toolbar").closest(".panel-header")).toBeTruthy(); + expect( + screen.queryByText("Editor Toolbar")?.closest(".editor-pane-card__toolbar-row") + ).toBeNull(); + expect(screen.getByTestId("editor-host")).toBeInTheDocument(); + expect(mocks.mockCodeEditorDesktopHeaderActions).toHaveBeenCalledWith( + expect.objectContaining({ + state: mocks.editorState, + showCloseAction: false, + }), + undefined + ); + expect(mocks.mockCodeEditorHost).toHaveBeenCalledWith( + expect.objectContaining({ + chrome: "content-only", + editorState: mocks.editorState, + }), + undefined + ); + + fireEvent.click(screen.getByRole("button", { name: "Split horizontal" })); + fireEvent.click(screen.getByRole("button", { name: "Split vertical" })); + fireEvent.click(screen.getByRole("button", { name: "Close" })); + + expect(onSplitPane).toHaveBeenNthCalledWith(1, "pane-1", "horizontal"); + expect(onSplitPane).toHaveBeenNthCalledWith(2, "pane-1", "vertical"); + expect(onClosePane).toHaveBeenCalledWith("pane-1"); + }); + + it("marks dirty editor pane titles and confirms before closing dirty files", () => { + const store = createStore(); + const onClosePane = vi.fn(); + const onSplitPane = vi.fn(); + + mocks.mockUseCodeEditorActions.mockReturnValue(mocks.editorState); + store.set(localeAtom, "en"); + store.set(activeFilePathAtomFamily("ws-123"), "src/app.tsx"); + store.set(openFilesAtomFamily("ws-123"), { + "src/app.tsx": { + kind: "text", + path: "src/app.tsx", + content: "changed", + savedContent: "saved", + baseHash: "hash-1", + isDirty: true, + }, + }); + + render( + + + + ); + + const title = screen.getByText("app.tsx"); + const titleElement = title.closest(".panel-header__title"); + const dirtyMeta = titleElement?.nextElementSibling; + + expect(dirtyMeta).toHaveClass("panel-header__meta"); + expect(dirtyMeta?.querySelector(".editor-pane-card__dirty-indicator")).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Close" })); + expect(onClosePane).not.toHaveBeenCalled(); + expect(screen.getByRole("dialog", { name: "Discard unsaved changes?" })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(onClosePane).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "Close" })); + fireEvent.click(screen.getByRole("button", { name: "Discard and Close" })); + + expect(onClosePane).toHaveBeenCalledWith("pane-1"); + }); +}); diff --git a/packages/web/src/features/agent-panes/views/shared/editor-pane-card.tsx b/packages/web/src/features/agent-panes/views/shared/editor-pane-card.tsx new file mode 100644 index 00000000..4594fa93 --- /dev/null +++ b/packages/web/src/features/agent-panes/views/shared/editor-pane-card.tsx @@ -0,0 +1,174 @@ +import { useAtomValue } from "jotai"; +import { FlipHorizontal, FlipVertical, GripVertical, X } from "lucide-react"; +import type { FC } from "react"; +import { useState } from "react"; +import { ConfirmDialog, IconButton, Tooltip } from "../../../../components/ui"; +import { useTranslation } from "../../../../lib/i18n"; +import { useCodeEditorActions } from "../../../code-editor/actions/use-code-editor-actions"; +import { + CodeEditorDesktopHeaderActions, + CodeEditorHost, +} from "../../../code-editor/views/shared/code-editor-host"; +import { PanelHeader } from "../../../shared/components/panel-header"; +import { activeFilePathAtomFamily, openFilesAtomFamily } from "../../../workspace/atoms"; +import type { PaneDropPlacement } from "../../actions/pane-drag-types"; +import type { PaneDragSourceSnapshot } from "../../actions/use-pane-drag-controller"; +import { usePaneDragEnabled } from "../../actions/use-pane-drag-enabled"; + +function getEditorPaneTitle(path: string | null, fallbackTitle: string): string { + if (!path) { + return fallbackTitle; + } + + const segments = path.split("/"); + return segments[segments.length - 1] || path; +} + +interface EditorPaneCardProps { + dragState?: { + isDragging: boolean; + isActiveDropTarget: boolean; + hoverPlacement: PaneDropPlacement | null; + }; + paneId: string; + workspaceId: string; + onClosePane: (paneId: string) => void; + onPaneDragStart?: (source: PaneDragSourceSnapshot) => void; + onSplitPane: (paneId: string, direction: "horizontal" | "vertical") => void; +} + +export const EditorPaneCard: FC = ({ + dragState, + paneId, + workspaceId, + onClosePane, + onPaneDragStart, + onSplitPane, +}) => { + const t = useTranslation(); + const [closeConfirmOpen, setCloseConfirmOpen] = useState(false); + const activeFilePath = useAtomValue(activeFilePathAtomFamily(workspaceId)); + const openFiles = useAtomValue(openFilesAtomFamily(workspaceId)); + const editorState = useCodeEditorActions(); + const supportsPaneDrag = usePaneDragEnabled(); + const canDragPane = supportsPaneDrag && Boolean(onPaneDragStart); + const title = getEditorPaneTitle(activeFilePath, t("agent_panes.file_editor")); + const activeOpenFile = activeFilePath ? openFiles[activeFilePath] : undefined; + const isDirtyTextFile = activeOpenFile?.kind === "text" && activeOpenFile.isDirty === true; + const dragOverlayPlacement = dragState?.isActiveDropTarget ? dragState.hoverPlacement : null; + const dirtyIndicator = isDirtyTextFile ? ( + + ) : null; + const requestClosePane = () => { + if (isDirtyTextFile) { + setCloseConfirmOpen(true); + return; + } + + onClosePane(paneId); + }; + const confirmClosePane = () => { + setCloseConfirmOpen(false); + onClosePane(paneId); + }; + + return ( +
+ {dragOverlayPlacement ? ( +
+ {dragOverlayPlacement === "center" ? ( +
{t("agent_panes.swap")}
+ ) : null} +
+ ) : null} + + + {canDragPane ? ( + + } + onPointerDown={(event) => { + event.preventDefault(); + event.stopPropagation(); + + if (event.pointerType === "touch") { + return; + } + + onPaneDragStart?.({ paneId }); + }} + size="sm" + /> + + ) : null} + + + } + onClick={() => onSplitPane(paneId, "horizontal")} + size="sm" + /> + + + } + onClick={() => onSplitPane(paneId, "vertical")} + size="sm" + /> + + + } + onClick={requestClosePane} + size="sm" + /> + + + } + /> + +
+
+ +
+
+ +
+ ); +}; + +export default EditorPaneCard; diff --git a/packages/web/src/features/agent-panes/views/shared/session-card.tsx b/packages/web/src/features/agent-panes/views/shared/session-card.tsx index 728808af..c2524216 100644 --- a/packages/web/src/features/agent-panes/views/shared/session-card.tsx +++ b/packages/web/src/features/agent-panes/views/shared/session-card.tsx @@ -15,6 +15,7 @@ import { dispatchCommandAtom } from "../../../../atoms/connection"; import { sessionByIdAtomFamily, sessionsAtom } from "../../../../atoms/sessions"; import { workspaceByIdAtomFamily } from "../../../../atoms/workspaces"; import { IconButton, StatusDot, Tag, Tooltip } from "../../../../components/ui"; +import { useTranslation } from "../../../../lib/i18n"; import { useTerminalThemeBackground } from "../../../../theme"; import { PanelHeader } from "../../../shared/components/panel-header"; import { useSupervisor } from "../../../supervisor/actions/use-supervisor"; @@ -72,6 +73,7 @@ export const SessionCard: FC = ({ onSplitHorizontal, onSplitVertical, }) => { + const t = useTranslation(); const session = useAtomValue(sessionByIdAtomFamily(sessionId)); const dispatch = useAtomValue(dispatchCommandAtom); const setSessions = useSetAtom(sessionsAtom); @@ -113,7 +115,7 @@ export const SessionCard: FC = ({ const sessionTitle = session.title?.trim() || formatSessionLabel(session.id); const providerLabel = formatProviderLabel(session.providerId); - const sessionStateLabel = formatSessionStateLabel(session.state); + const sessionStateLabel = formatSessionStateLabel(session.state, t); const terminalReadOnly = terminalReadOnlyOverride ?? !isSessionInteractive(session.state); const isActiveSession = workspace?.uiState.activeSessionId === session.id; const isRunning = session.state === "running"; @@ -187,7 +189,7 @@ export const SessionCard: FC = ({ {dragOverlayPlacement ? (
{dragOverlayPlacement === "center" ? ( -
Swap
+
{t("agent_panes.swap")}
) : null}
) : null} @@ -227,10 +229,11 @@ export const SessionCard: FC = ({ {showHeaderActions ? (
{supportsPaneDrag ? ( - + } onPointerDown={(event) => { event.preventDefault(); @@ -255,28 +258,31 @@ export const SessionCard: FC = ({ /> ) : null} - + } onClick={() => onSplitHorizontal?.()} size="sm" /> - + } onClick={() => onSplitVertical?.()} size="sm" /> - + } onClick={() => void onClose?.()} size="sm" @@ -377,8 +383,15 @@ function formatSessionLabel(sessionId: string) { return sessionId.replace(/[_-]/g, " ").toUpperCase(); } -function formatSessionStateLabel(state: SessionState) { - return state.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase()); +function formatSessionStateLabel( + state: SessionState, + t: (key: string, params?: Record) => string +) { + const key = `session.state.${state}`; + const translated = t(key); + return translated === key + ? state.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase()) + : translated; } function formatProviderLabel(providerId: string) { diff --git a/packages/web/src/features/agent-providers/actions/use-agent-providers.ts b/packages/web/src/features/agent-providers/actions/use-agent-providers.ts index a4a24af6..1c831a92 100644 --- a/packages/web/src/features/agent-providers/actions/use-agent-providers.ts +++ b/packages/web/src/features/agent-providers/actions/use-agent-providers.ts @@ -1,7 +1,8 @@ import type { ProviderListItem } from "@coder-studio/core"; import { useAtomValue } from "jotai"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { dispatchCommandAtom } from "../../../atoms/connection"; +import { useTranslation } from "../../../lib/i18n"; interface UseAgentProvidersResult { providers: ProviderListItem[]; @@ -11,19 +12,20 @@ interface UseAgentProvidersResult { } export function useAgentProviders(): UseAgentProvidersResult { + const t = useTranslation(); const dispatch = useAtomValue(dispatchCommandAtom); const [providers, setProviders] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - const refresh = async () => { + const refresh = useCallback(async () => { setIsLoading(true); setError(null); const result = await dispatch("provider.list", {}); if (!result.ok || !result.data) { setProviders([]); - setError(result.error?.message ?? "Failed to load providers"); + setError(result.error?.message ?? t("provider.load_failed")); setIsLoading(false); return; } @@ -31,11 +33,11 @@ export function useAgentProviders(): UseAgentProvidersResult { setProviders(result.data); setError(null); setIsLoading(false); - }; + }, [dispatch, t]); useEffect(() => { void refresh(); - }, [dispatch]); + }, [refresh]); return { providers, diff --git a/packages/web/src/features/auth/index.tsx b/packages/web/src/features/auth/index.tsx index 900404c6..b579baa8 100644 --- a/packages/web/src/features/auth/index.tsx +++ b/packages/web/src/features/auth/index.tsx @@ -107,13 +107,13 @@ export function LoginPage({ }); if (!response.ok) { - const data = await response.json().catch(() => ({ error: "Login failed" })); + const data = await response.json().catch(() => ({ error: t("auth.login_failed") })); if (data?.blocked === true) { setError(formatBlockedMessage(data.blockedUntil)); return; } - setError(data.error || "Login failed"); + setError(data.error || t("auth.login_failed")); return; } diff --git a/packages/web/src/features/code-editor/actions/use-code-editor-actions.ts b/packages/web/src/features/code-editor/actions/use-code-editor-actions.ts index 5a623b4c..bd8da6ec 100644 --- a/packages/web/src/features/code-editor/actions/use-code-editor-actions.ts +++ b/packages/web/src/features/code-editor/actions/use-code-editor-actions.ts @@ -1,7 +1,9 @@ +import type { GitCommitFileEntry, GitFileDiffPayload } from "@coder-studio/core"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { useCallback, useEffect, useRef, useState } from "react"; import { dispatchCommandAtom } from "../../../atoms/connection"; import { activeWorkspaceAtom } from "../../../atoms/workspaces"; +import { useTranslation } from "../../../lib/i18n"; import { useOpenEditorsActions } from "../../workspace/actions/use-open-editors-actions"; import { activeFilePathAtomFamily, @@ -45,17 +47,8 @@ type FileReadImagePayload = { type FileReadPayload = FileReadTextPayload | FileReadImagePayload; -type GitDiffPayload = { - diff: string; - renderAs: "text" | "image"; - status: "modified" | "added" | "deleted"; - originalContent?: string; - modifiedContent?: string; - originalRevision?: "HEAD" | "INDEX"; - modifiedRevision?: "INDEX" | "WORKTREE"; -}; - export function useCodeEditorActions() { + const t = useTranslation(); const workspace = useAtomValue(activeWorkspaceAtom); const workspaceRootPath = workspace?.path; const dispatch = useAtomValue(dispatchCommandAtom); @@ -65,6 +58,7 @@ export function useCodeEditorActions() { ); const [savingPaths, setSavingPaths] = useState>(() => new Set()); + const savingPathsRef = useRef>(new Set()); const [saveError, setSaveError] = useState<{ path: string; message: string } | null>(null); const [fileLoadError, setFileLoadError] = useState<{ path: string; message: string } | null>( null @@ -75,7 +69,7 @@ export function useCodeEditorActions() { } | null>(null); const workspaceId = workspace?.id; - const [activeFilePath, setActiveFilePath] = useAtom(activeFilePathAtomFamily(workspaceId ?? "")); + const [activeFilePath] = useAtom(activeFilePathAtomFamily(workspaceId ?? "")); const [openFiles, setOpenFiles] = useAtom(openFilesAtomFamily(workspaceId ?? "")); const [mode, setMode] = useAtom(editorModeAtomFamily(workspaceId ?? "")); const editorRefreshToken = useAtomValue(editorRefreshTokenAtomFamily(workspaceId ?? "")); @@ -85,6 +79,7 @@ export function useCodeEditorActions() { const pendingActivePathRef = useRef(null); const nextSaveRequestIdRef = useRef(0); const activeSaveRequestIdByPathRef = useRef>(new Map()); + const nextCommitDiffRequestIdRef = useRef(0); const previousOpenFilePathsRef = useRef(null); const { closePath } = useOpenEditorsActions(workspaceId ?? "", { workspaceRootPath, @@ -106,7 +101,10 @@ export function useCodeEditorActions() { lastSeededModePathRef.current = activeFilePath; const shouldPreserveDiffMode = - mode === "diff" && diffPreview?.source === "file" && diffPreview.path === activeFilePath; + mode === "diff" && + (diffPreview?.kind === "worktree-file-diff" || + diffPreview?.kind === "search-replace-file-diff") && + diffPreview.path === activeFilePath; const nextMode = shouldPreserveDiffMode ? "diff" : deriveEditorModeForOpenFile(currentFile); if (nextMode !== mode) { setMode(nextMode); @@ -138,6 +136,7 @@ export function useCodeEditorActions() { } } + savingPathsRef.current = changed ? next : current; return changed ? next : current; }); setSaveError((current) => (current && removedPaths.has(current.path) ? null : current)); @@ -191,7 +190,7 @@ export function useCodeEditorActions() { if (!result.ok || !result.data) { finishPendingEditorLoad(workspaceId, path, requestId); - const message = result.error?.message ?? "Failed to open file"; + const message = result.error?.message ?? t("code_editor.open_failed_title"); console.error("Failed to open file:", message); setFileLoadError({ path, message }); return; @@ -208,7 +207,7 @@ export function useCodeEditorActions() { if (!response.ok) { finishPendingEditorLoad(workspaceId, path, requestId); - const message = `Failed to fetch text-backed image bytes: ${response.status}`; + const message = `${t("code_editor.text_backed_image_load_failed")}: ${response.status}`; console.error(message); setFileLoadError({ path, message }); return; @@ -242,7 +241,7 @@ export function useCodeEditorActions() { } catch (error) { finishPendingEditorLoad(workspaceId, path, requestId); const message = - error instanceof Error ? error.message : "Failed to fetch text-backed image bytes"; + error instanceof Error ? error.message : t("code_editor.text_backed_image_load_failed"); console.error("Failed to fetch text-backed image bytes:", error); setFileLoadError({ path, message }); } @@ -284,17 +283,20 @@ export function useCodeEditorActions() { setExternalStatus((current) => (current?.path === path ? null : current)); setFileLoadError((current) => (current?.path === path ? null : current)); }, - [dispatch, setOpenFiles, workspaceId, workspaceRootPath] + [dispatch, setOpenFiles, t, workspaceId, workspaceRootPath] ); - const loadTextBackedImageContent = useCallback(async (url: string) => { - const response = await fetch(url, { credentials: "include" }); - if (!response.ok) { - throw new Error(`Failed to fetch text-backed image bytes: ${response.status}`); - } + const loadTextBackedImageContent = useCallback( + async (url: string) => { + const response = await fetch(url, { credentials: "include" }); + if (!response.ok) { + throw new Error(`${t("code_editor.text_backed_image_load_failed")}: ${response.status}`); + } - return response.text(); - }, []); + return response.text(); + }, + [t] + ); const handleSave = useCallback(async () => { if (!workspaceId || !currentFile || currentFile.kind !== "text") { @@ -302,13 +304,16 @@ export function useCodeEditorActions() { } const { path, content, baseHash } = currentFile; - if (savingPaths.has(path)) { + if (savingPathsRef.current.has(path)) { return; } const requestId = ++nextSaveRequestIdRef.current; activeSaveRequestIdByPathRef.current.set(path, requestId); - setSavingPaths((current) => new Set(current).add(path)); + const nextSavingPaths = new Set(savingPathsRef.current); + nextSavingPaths.add(path); + savingPathsRef.current = nextSavingPaths; + setSavingPaths(nextSavingPaths); setSaveError((current) => (current?.path === path ? null : current)); const result = await dispatch<{ newHash: string }>("file.write", { @@ -342,16 +347,15 @@ export function useCodeEditorActions() { }); setExternalStatus((current) => (current?.path === path ? null : current)); } else { - setSaveError({ path, message: result.error?.message ?? "Failed to save file" }); + setSaveError({ path, message: result.error?.message ?? t("code_editor.save_failed_title") }); } activeSaveRequestIdByPathRef.current.delete(path); - setSavingPaths((current) => { - const next = new Set(current); - next.delete(path); - return next; - }); - }, [currentFile, dispatch, savingPaths, setOpenFiles, workspaceId]); + const nextSavingPathsAfterSave = new Set(savingPathsRef.current); + nextSavingPathsAfterSave.delete(path); + savingPathsRef.current = nextSavingPathsAfterSave; + setSavingPaths(nextSavingPathsAfterSave); + }, [currentFile, dispatch, setOpenFiles, t, workspaceId]); const handleContentChange = useCallback( (newContent: string) => { @@ -630,8 +634,13 @@ export function useCodeEditorActions() { workspaceRootPath, ]); - const handleClose = useCallback(() => { - if (diffPreview?.source === "commit") { + const handleClose = useCallback(async () => { + if (diffPreview?.kind === "commit-file-diff") { + setDiffPreview(diffPreview.parentList); + return; + } + + if (diffPreview?.kind === "commit-file-list") { setDiffPreview(null); if (currentFile) { const nextMode = deriveEditorModeForOpenFile(currentFile); @@ -643,7 +652,7 @@ export function useCodeEditorActions() { } if (currentFile?.path || activeFilePath) { - closePath(currentFile?.path ?? activeFilePath); + closePath(currentFile?.path ?? activeFilePath ?? undefined); } setSaveError(null); @@ -675,7 +684,7 @@ export function useCodeEditorActions() { return false; } - const result = await dispatch("git.diff", { + const result = await dispatch("git.diff", { workspaceId, path: currentFile.path, staged: false, @@ -685,13 +694,16 @@ export function useCodeEditorActions() { return false; } - const nextPreview: GitDiffPreview = { + const nextPreview = { + kind: "worktree-file-diff", path: currentFile.path, diff: result.data.diff, staged: false, - source: "file", ...(result.data.renderAs ? { renderAs: result.data.renderAs } : {}), ...(result.data.status ? { status: result.data.status } : {}), + ...(result.data.mime ? { mime: result.data.mime } : {}), + ...(result.data.originalPath ? { originalPath: result.data.originalPath } : {}), + ...(result.data.modifiedPath ? { modifiedPath: result.data.modifiedPath } : {}), ...(result.data.originalContent !== undefined ? { originalContent: result.data.originalContent } : {}), @@ -700,13 +712,80 @@ export function useCodeEditorActions() { : {}), ...(result.data.originalRevision ? { originalRevision: result.data.originalRevision } : {}), ...(result.data.modifiedRevision ? { modifiedRevision: result.data.modifiedRevision } : {}), - }; + } as GitDiffPreview; setDiffPreviewDismissed(false); setDiffPreview(nextPreview); setMode("diff"); return true; }, [currentFile, dispatch, setDiffPreview, setDiffPreviewDismissed, setMode, workspaceId]); + const openCommitFileDiff = useCallback( + async (file: GitCommitFileEntry) => { + if (!workspaceId || diffPreview?.kind !== "commit-file-list") { + return false; + } + + const parentList = diffPreview; + const requestId = nextCommitDiffRequestIdRef.current + 1; + nextCommitDiffRequestIdRef.current = requestId; + + const result = await dispatch("git.commitFileDiff", { + workspaceId, + sha: parentList.commit.sha, + path: file.path, + ...(file.oldPath ? { oldPath: file.oldPath } : {}), + }); + + if (!result.ok || !result.data) { + return false; + } + + const payload = result.data; + + let applied = false; + setDiffPreview((current) => { + if (requestId !== nextCommitDiffRequestIdRef.current) { + return current; + } + + if ( + current?.kind !== "commit-file-list" || + current !== parentList || + current.path !== parentList.path || + current.commit.sha !== parentList.commit.sha + ) { + return current; + } + + applied = true; + return { + kind: "commit-file-diff", + path: file.path, + title: file.path, + commit: parentList.commit, + file, + parentList, + diff: payload.diff, + renderAs: payload.renderAs, + status: payload.status, + ...(payload.mime ? { mime: payload.mime } : {}), + ...(payload.originalPath ? { originalPath: payload.originalPath } : {}), + ...(payload.modifiedPath ? { modifiedPath: payload.modifiedPath } : {}), + ...(payload.originalContent !== undefined + ? { originalContent: payload.originalContent } + : {}), + ...(payload.modifiedContent !== undefined + ? { modifiedContent: payload.modifiedContent } + : {}), + ...(payload.originalRevision ? { originalRevision: payload.originalRevision } : {}), + ...(payload.modifiedRevision ? { modifiedRevision: payload.modifiedRevision } : {}), + }; + }); + return applied; + }, + [diffPreview, dispatch, setDiffPreview, workspaceId] + ); + const isTextFile = currentFile?.kind === "text"; const isImageFile = currentFile?.kind === "image"; const isSvgTextBacked = @@ -729,12 +808,39 @@ export function useCodeEditorActions() { ); const activeDiffChange = diffPreview && - ((diffPreview.source === "file" && diffPreview.path === activeFilePath) || - diffPreview.source === "commit") + (((diffPreview.kind === "worktree-file-diff" || + diffPreview.kind === "search-replace-file-diff") && + diffPreview.path === activeFilePath) || + diffPreview.kind === "commit-file-list" || + diffPreview.kind === "commit-file-diff") ? diffPreview : null; const isSaving = Boolean(isTextFile && savingPaths.has(currentFile.path)); const canSave = Boolean(isTextFile && currentFile.isDirty && !isSaving); + useEffect(() => { + const handleSaveShortcut = (event: KeyboardEvent) => { + const isSaveShortcut = + event.key.toLowerCase() === "s" && (event.ctrlKey || event.metaKey) && !event.altKey; + if (!isSaveShortcut) { + return; + } + + if (!isTextFile) { + return; + } + + event.preventDefault(); + if (canSave) { + void handleSave(); + } + }; + + window.addEventListener("keydown", handleSaveShortcut); + return () => { + window.removeEventListener("keydown", handleSaveShortcut); + }; + }, [canSave, handleSave, isTextFile]); + const activeLoadError = activeFilePath && fileLoadError?.path === activeFilePath ? fileLoadError.message : null; const activeExternalStatus = @@ -771,6 +877,7 @@ export function useCodeEditorActions() { isSvgTextBacked, isTextFile, mode, + openCommitFileDiff, openInDiffMode, saveError: activeSaveError, setMode: (nextMode: WorkspaceEditorMode) => { diff --git a/packages/web/src/features/code-editor/actions/use-open-location.ts b/packages/web/src/features/code-editor/actions/use-open-location.ts index 99c47374..854a3170 100644 --- a/packages/web/src/features/code-editor/actions/use-open-location.ts +++ b/packages/web/src/features/code-editor/actions/use-open-location.ts @@ -24,7 +24,11 @@ export function useOpenLocation(workspaceId: string): { const openLocation = useCallback( async (input: PendingEditorNavigation) => { - if (diffPreview?.source === "commit") { + if ( + diffPreview?.kind === "commit-file-list" || + diffPreview?.kind === "commit-file-diff" || + diffPreview?.kind === "search-replace-file-diff" + ) { setDiffPreview(null); const openFile = openFiles[input.path]; setEditorMode( diff --git a/packages/web/src/features/code-editor/components/commit-file-list-preview.test.tsx b/packages/web/src/features/code-editor/components/commit-file-list-preview.test.tsx new file mode 100644 index 00000000..bb90387c --- /dev/null +++ b/packages/web/src/features/code-editor/components/commit-file-list-preview.test.tsx @@ -0,0 +1,55 @@ +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { CommitFileListPreview } from "./commit-file-list-preview"; + +describe("CommitFileListPreview", () => { + it("renders commit files with split path metadata and opens a selected commit diff", () => { + const onOpenFile = vi.fn(); + const files = [ + { + path: "src/app.tsx", + status: "modified" as const, + renderAs: "text" as const, + }, + { + path: "src/renamed.ts", + oldPath: "src/old.ts", + status: "renamed" as const, + renderAs: "text" as const, + }, + ]; + + render( + + ); + + const modifiedRow = screen.getByRole("button", { name: "src/app.tsx modified" }); + expect(within(modifiedRow).getByText("app.tsx")).toBeInTheDocument(); + expect(within(modifiedRow).getByText("src/")).toBeInTheDocument(); + expect(within(modifiedRow).getByText("modified")).toBeInTheDocument(); + + const renamedRow = screen.getByRole("button", { name: "src/old.ts -> src/renamed.ts renamed" }); + expect(within(renamedRow).getByText("renamed.ts")).toBeInTheDocument(); + expect(within(renamedRow).getByText("src/old.ts")).toBeInTheDocument(); + expect(within(renamedRow).getByText("renamed")).toBeInTheDocument(); + + fireEvent.click(modifiedRow); + + expect(onOpenFile).toHaveBeenCalledWith(files[0]); + }); +}); diff --git a/packages/web/src/features/code-editor/components/commit-file-list-preview.tsx b/packages/web/src/features/code-editor/components/commit-file-list-preview.tsx new file mode 100644 index 00000000..0d13d50d --- /dev/null +++ b/packages/web/src/features/code-editor/components/commit-file-list-preview.tsx @@ -0,0 +1,73 @@ +import type { GitCommitFileEntry } from "@coder-studio/core"; +import type { FC } from "react"; +import { ThemedIcon } from "../../../components/ui"; +import type { GitCommitFileListPreview } from "../../workspace/atoms"; + +interface CommitFileListPreviewProps { + preview: GitCommitFileListPreview; + onOpenFile: (file: GitCommitFileEntry) => void; +} + +function formatFileLabel(file: GitCommitFileEntry): string { + return file.oldPath ? `${file.oldPath} -> ${file.path}` : file.path; +} + +function splitPath(filePath: string) { + const pathParts = filePath.split("/"); + const name = pathParts[pathParts.length - 1] ?? filePath; + const dir = pathParts.length > 1 ? `${pathParts.slice(0, -1).join("/")}/` : ""; + return { dir, name }; +} + +function getStatusSemantic(status: GitCommitFileEntry["status"]) { + switch (status) { + case "deleted": + return "git.status.deleted"; + case "added": + case "renamed": + case "modified": + default: + return "git.status.modified"; + } +} + +export const CommitFileListPreview: FC = ({ preview, onOpenFile }) => { + return ( +
+
+ {preview.files.map((file) => { + const { dir, name } = splitPath(file.path); + return ( + + ); + })} +
+
+ ); +}; + +export default CommitFileListPreview; diff --git a/packages/web/src/features/code-editor/components/image-diff-preview.test.tsx b/packages/web/src/features/code-editor/components/image-diff-preview.test.tsx index 9809e9c1..98791604 100644 --- a/packages/web/src/features/code-editor/components/image-diff-preview.test.tsx +++ b/packages/web/src/features/code-editor/components/image-diff-preview.test.tsx @@ -1,10 +1,31 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { createStore, Provider } from "jotai"; +import type { PropsWithChildren, ReactElement } from "react"; import { describe, expect, it } from "vitest"; +import { localeAtom } from "../../../atoms/app-ui"; import { ImageDiffPreview } from "./image-diff-preview"; +function renderWithLocale(ui: ReactElement) { + const store = createStore(); + store.set(localeAtom, "en"); + + function LocaleProvider({ children }: PropsWithChildren) { + return {children}; + } + + return render(ui, { wrapper: LocaleProvider }); +} + +function getPane(label: "Base" | "Current"): HTMLElement { + const header = screen.getByText(label); + const pane = header.closest("section"); + expect(pane).toBeTruthy(); + return pane as HTMLElement; +} + describe("ImageDiffPreview", () => { it("renders baseline image on top and workspace image on bottom for modified files", () => { - render( + renderWithLocale( { ); const images = screen.getAllByRole("img"); - expect(images[0]).toHaveAttribute("alt", "assets/logo.png base"); - expect(images[1]).toHaveAttribute("alt", "assets/logo.png current"); + expect(images[0]).toHaveAttribute("alt", "assets/logo.png Base"); + expect(images[1]).toHaveAttribute("alt", "assets/logo.png Current"); }); it("renders an empty top state for added images", () => { - render( + renderWithLocale( { /> ); - expect(screen.getByText("No image")).toBeInTheDocument(); - expect(screen.getByRole("img", { name: "assets/new.png current" })).toBeInTheDocument(); + expect(within(getPane("Base")).getByText("No base image")).toBeInTheDocument(); + expect( + within(getPane("Current")).getByRole("img", { name: "assets/new.png Current" }) + ).toBeInTheDocument(); }); it("renders an empty bottom state for deleted images", () => { - render( + renderWithLocale( { /> ); - expect(screen.getByRole("img", { name: "assets/deleted.png base" })).toBeInTheDocument(); - expect(screen.getByText("No image")).toBeInTheDocument(); + expect( + within(getPane("Base")).getByRole("img", { name: "assets/deleted.png Base" }) + ).toBeInTheDocument(); + expect(within(getPane("Current")).getByText("No current image")).toBeInTheDocument(); + }); + + it("renders a pane-local error state when one side fails to load", () => { + renderWithLocale( + + ); + + fireEvent.error(screen.getByRole("img", { name: "assets/logo.png Base" })); + + expect(within(getPane("Base")).getByText("Preview unavailable")).toBeInTheDocument(); + expect( + within(getPane("Current")).getByRole("img", { name: "assets/logo.png Current" }) + ).toBeInTheDocument(); + }); + + it("lets the user retry after an image load failure without changing the url", () => { + renderWithLocale( + + ); + + fireEvent.error(screen.getByRole("img", { name: "assets/logo.png Base" })); + + const basePane = getPane("Base"); + expect(within(basePane).getByText("Preview unavailable")).toBeInTheDocument(); + + fireEvent.click(within(basePane).getByRole("button", { name: "Retry" })); + + expect(within(basePane).queryByText("Preview unavailable")).not.toBeInTheDocument(); + expect(within(basePane).getByRole("img", { name: "assets/logo.png Base" })).toBeInTheDocument(); + }); + + it("resets a pane error state when its image url changes", () => { + const { rerender } = renderWithLocale( + + ); + + fireEvent.error(screen.getByRole("img", { name: "assets/logo.png Current" })); + expect(screen.getByText("Preview unavailable")).toBeInTheDocument(); + + rerender( + + ); + + expect(screen.queryByText("Preview unavailable")).not.toBeInTheDocument(); + expect(screen.getByRole("img", { name: "assets/logo.png Current" })).toHaveAttribute( + "src", + "/api/file?workspaceId=ws-1&path=assets%2Flogo.png&revision=HEAD" + ); }); }); diff --git a/packages/web/src/features/code-editor/components/image-diff-preview.tsx b/packages/web/src/features/code-editor/components/image-diff-preview.tsx index 5704fa46..fc5a90d2 100644 --- a/packages/web/src/features/code-editor/components/image-diff-preview.tsx +++ b/packages/web/src/features/code-editor/components/image-diff-preview.tsx @@ -1,5 +1,7 @@ import type { FC } from "react"; -import { EmptyState } from "../../../components/ui"; +import { useEffect, useState } from "react"; +import { Button, EmptyState } from "../../../components/ui"; +import { useTranslation } from "../../../lib/i18n"; interface ImageDiffPreviewProps { path: string; @@ -15,19 +17,65 @@ function imageLabel(mime: string): string { return head.replace(/^x-/, "").toUpperCase(); } -function ImageDiffPane({ label, url, alt }: { label: string; url?: string; alt: string }) { +function ImageDiffPane({ + label, + emptyTitle, + url, + alt, +}: { + label: string; + emptyTitle: string; + url?: string; + alt: string; +}) { + const t = useTranslation(); + const [errored, setErrored] = useState(false); + const [reloadKey, setReloadKey] = useState(0); + + useEffect(() => { + setErrored(false); + setReloadKey(0); + }, [url]); + return (
{label}
- {url ? ( - {alt} - ) : ( + {!url ? ( No image

} + title={

{emptyTitle}

} + /> + ) : errored ? ( + { + setErrored(false); + setReloadKey((current) => current + 1); + }} + size="sm" + variant="ghost" + > + {t("code_editor.preview_retry")} + + } + className="git-diff-empty" + description={ +

{t("code_editor.image_load_failed_body")}

+ } + title={

{t("code_editor.preview_unavailable")}

} + /> + ) : ( + {alt} setErrored(true)} /> )}
@@ -42,6 +90,8 @@ export const ImageDiffPreview: FC = ({ beforeUrl, afterUrl, }) => { + const t = useTranslation(); + return (
@@ -50,8 +100,18 @@ export const ImageDiffPreview: FC = ({ {status}
- - + +
); diff --git a/packages/web/src/features/code-editor/components/image-preview.test.tsx b/packages/web/src/features/code-editor/components/image-preview.test.tsx index 0b01f605..29810269 100644 --- a/packages/web/src/features/code-editor/components/image-preview.test.tsx +++ b/packages/web/src/features/code-editor/components/image-preview.test.tsx @@ -1,10 +1,24 @@ import { fireEvent, render, screen } from "@testing-library/react"; +import { createStore, Provider } from "jotai"; +import type { PropsWithChildren, ReactElement } from "react"; import { describe, expect, it } from "vitest"; +import { localeAtom } from "../../../atoms/app-ui"; import { ImagePreview } from "./image-preview"; +function renderWithLocale(ui: ReactElement) { + const store = createStore(); + store.set(localeAtom, "en"); + + function LocaleProvider({ children }: PropsWithChildren) { + return {children}; + } + + return render(ui, { wrapper: LocaleProvider }); +} + describe("ImagePreview", () => { it("preserves the migrated empty-state fallback when image loading fails", () => { - render( + renderWithLocale( { }); it("resets the preview state when only the version changes", () => { - const { rerender } = render( + const { rerender } = renderWithLocale( { }); it("appends the cache-busting version with ampersand when the url already has query params", () => { - render( + renderWithLocale( = ({ url, version, mime, sizeBytes, alt }) => { + const t = useTranslation(); const [dimensions, setDimensions] = useState<{ w: number; h: number } | null>(null); const [errored, setErrored] = useState(false); const src = `${url}${url.includes("?") ? "&" : "?"}v=${version}`; @@ -51,12 +53,9 @@ export const ImagePreview: FC = ({ url, version, mime, sizeBy - The image could not be loaded. The file may have been moved or is larger than the - browser allows. -

+

{t("code_editor.image_load_failed_body")}

} - title={

Preview unavailable

} + title={

{t("code_editor.preview_unavailable")}

} /> ) : ( {children}; + } + + return render(ui, { wrapper: LocaleProvider }); +} + describe("LspStatusNotice", () => { it("renders an install action when the server is missing and auto-install is supported", () => { const onInstall = vi.fn(); - render( + renderWithLocale( { it("renders a retry action when installation failed", () => { const onRetry = vi.fn(); - render( + renderWithLocale( { }); it("does not render an install action when prerequisites are missing", () => { - render( + renderWithLocale( { }); it("renders a disabled notice without install or retry actions", () => { - render( + renderWithLocale( ) => string +): string | null { if (!step) { return null; } if (step.status === "running") { - return `Installing: ${step.title}`; + return t("code_editor.lsp_installing_step", { title: step.title }); } if (step.status === "failed") { - return `Install failed at: ${step.title}`; + return t("code_editor.lsp_install_failed_step", { title: step.title }); } return null; } +function getLspMessage( + state: LspNoticeState, + progressMessage: string | null, + t: (key: string, params?: Record) => string +): string { + if (progressMessage) { + return progressMessage; + } + + if (state.kind === "installing" && state.errorCode === "lsp_install_in_progress") { + return t("code_editor.lsp_install_in_progress"); + } + + if (state.kind === "failed" && state.errorCode === "lsp_install_failed") { + return state.message || t("code_editor.lsp_install_failed"); + } + + return state.message; +} + export function LspStatusNotice({ state, onInstall, onRetry, installing = false, }: LspStatusNoticeProps) { + const t = useTranslation(); + if (state.kind === "disabled") { return ( ); } @@ -54,7 +80,7 @@ export function LspStatusNotice({ const activeStep = state.installJob?.steps.find( (step) => step.id === state.installJob?.currentStepId ); - const progressMessage = describeStep(activeStep); + const progressMessage = describeStep(activeStep, t); const canInstall = state.kind === "tool_missing" && state.autoInstallSupported && @@ -64,19 +90,19 @@ export function LspStatusNotice({ const action = canInstall ? ( ) : canRetry ? ( ) : null; return ( ); diff --git a/packages/web/src/features/code-editor/components/monaco-diff-host.test.tsx b/packages/web/src/features/code-editor/components/monaco-diff-host.test.tsx index c5802e34..fe9bb564 100644 --- a/packages/web/src/features/code-editor/components/monaco-diff-host.test.tsx +++ b/packages/web/src/features/code-editor/components/monaco-diff-host.test.tsx @@ -1,12 +1,16 @@ import { render, screen } from "@testing-library/react"; import { createStore, Provider } from "jotai"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { getThemeById } from "../../../theme"; import { MonacoDiffHost } from "./monaco-diff-host"; const { mockCreateDiffEditor, + mockRegisterLanguage, + mockSetLanguageConfiguration, + mockSetMonarchTokensProvider, mockDefineTheme, + mockCreateModel, mockSetModel, mockOriginalModel, mockModifiedModel, @@ -23,6 +27,10 @@ const { setValue: vi.fn(), }; const mockSetModel = vi.fn(); + const mockCreateModel = vi + .fn() + .mockImplementationOnce(() => mockOriginalModel) + .mockImplementationOnce(() => mockModifiedModel); return { mockCreateDiffEditor: vi.fn(() => ({ dispose: vi.fn(), @@ -30,21 +38,33 @@ const { setModel: mockSetModel, updateOptions: vi.fn(), })), + mockCreateModel, mockDefineTheme: vi.fn(), mockSetModel, mockOriginalModel, mockModifiedModel, + mockRegisterLanguage: vi.fn(), + mockSetLanguageConfiguration: vi.fn(), + mockSetMonarchTokensProvider: vi.fn(), mockSetTheme: vi.fn(), }; }); vi.mock("monaco-editor", () => ({ + languages: { + register: mockRegisterLanguage, + setLanguageConfiguration: mockSetLanguageConfiguration, + setMonarchTokensProvider: mockSetMonarchTokensProvider, + IndentAction: { + None: 0, + Indent: 1, + IndentOutdent: 2, + Outdent: 3, + }, + }, editor: { createDiffEditor: mockCreateDiffEditor, - createModel: vi - .fn() - .mockImplementationOnce(() => mockOriginalModel) - .mockImplementationOnce(() => mockModifiedModel), + createModel: mockCreateModel, defineTheme: mockDefineTheme, setTheme: mockSetTheme, }, @@ -67,6 +87,19 @@ vi.mock("monaco-editor/esm/vs/language/typescript/ts.worker?worker", () => ({ })); describe("MonacoDiffHost", () => { + beforeEach(() => { + mockCreateDiffEditor.mockClear(); + mockDefineTheme.mockClear(); + mockSetModel.mockClear(); + mockSetTheme.mockClear(); + mockOriginalModel.dispose.mockClear(); + mockModifiedModel.dispose.mockClear(); + mockCreateModel + .mockReset() + .mockImplementationOnce(() => mockOriginalModel) + .mockImplementationOnce(() => mockModifiedModel); + }); + it("creates a Monaco diff editor with original and modified models", () => { render( @@ -95,4 +128,19 @@ describe("MonacoDiffHost", () => { }); expect(screen.getByTestId("monaco-diff-host")).toBeInTheDocument(); }); + + it("creates vue diff models with the vue language id", () => { + render( + + + + ); + + expect(mockCreateModel).toHaveBeenNthCalledWith(1, expect.stringContaining("before"), "vue"); + expect(mockCreateModel).toHaveBeenNthCalledWith(2, expect.stringContaining("after"), "vue"); + }); }); diff --git a/packages/web/src/features/code-editor/components/monaco-diff-host.tsx b/packages/web/src/features/code-editor/components/monaco-diff-host.tsx index 6b791d8e..2e402cdb 100644 --- a/packages/web/src/features/code-editor/components/monaco-diff-host.tsx +++ b/packages/web/src/features/code-editor/components/monaco-diff-host.tsx @@ -9,6 +9,7 @@ import type { FC } from "react"; import { useEffect, useRef } from "react"; import { themeAtom } from "../../../atoms/app-ui"; import { createWorkspaceMonacoTheme, getThemeById } from "../../../theme"; +import { ensureVueLanguageRegistered } from "../monaco/vue-language"; const monacoGlobal = globalThis as typeof globalThis & { MonacoEnvironment?: monaco.Environment; @@ -24,6 +25,8 @@ monacoGlobal.MonacoEnvironment ??= { }, }; +ensureVueLanguageRegistered(); + interface MonacoDiffHostProps { originalContent: string; modifiedContent: string; @@ -58,7 +61,7 @@ export const MonacoDiffHost: FC = ({ return; } - editorRef.current = monaco.editor.createDiffEditor(containerRef.current, { + const options = { automaticLayout: true, fontFamily: "JetBrains Mono, monospace", fontSize: 13, @@ -67,8 +70,13 @@ export const MonacoDiffHost: FC = ({ readOnly, renderSideBySide: false, scrollBeyondLastLine: false, + "semanticHighlighting.enabled": true, theme: editorTheme, - }); + } satisfies monaco.editor.IStandaloneDiffEditorConstructionOptions & { + "semanticHighlighting.enabled": boolean; + }; + + editorRef.current = monaco.editor.createDiffEditor(containerRef.current, options); return () => { editorRef.current?.dispose(); @@ -127,6 +135,7 @@ function detectEditorLanguage(filePath: string): string { py: "python", go: "go", rs: "rust", + vue: "vue", java: "java", cpp: "cpp", c: "c", diff --git a/packages/web/src/features/code-editor/components/monaco-host.test.tsx b/packages/web/src/features/code-editor/components/monaco-host.test.tsx index edb9dd49..1a3e2e5f 100644 --- a/packages/web/src/features/code-editor/components/monaco-host.test.tsx +++ b/packages/web/src/features/code-editor/components/monaco-host.test.tsx @@ -1,6 +1,6 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { createStore, Provider } from "jotai"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { themeAtom } from "../../../atoms/app-ui"; import { wsClientAtom } from "../../../atoms/connection"; import { getThemeById } from "../../../theme"; @@ -28,6 +28,9 @@ const { mockSetJavaScriptCompilerOptions, mockSetJavaScriptDiagnosticsOptions, mockSetJavaScriptEagerModelSync, + mockRegisterLanguage, + mockSetLanguageConfiguration, + mockSetMonarchTokensProvider, mockSetTypeScriptCompilerOptions, mockSetTypeScriptDiagnosticsOptions, mockSetTypeScriptEagerModelSync, @@ -137,6 +140,9 @@ const { return { dispose: vi.fn() }; } ); + const mockRegisterLanguage = vi.fn(); + const mockSetLanguageConfiguration = vi.fn(); + const mockSetMonarchTokensProvider = vi.fn(); const mockSetTypeScriptCompilerOptions = vi.fn(); const mockSetJavaScriptCompilerOptions = vi.fn(); const mockSetTypeScriptDiagnosticsOptions = vi.fn(); @@ -172,6 +178,9 @@ const { mockRevealRangeInCenter, mockRegistryGetOrCreate, mockRegisterCodeEditorOpenHandler, + mockRegisterLanguage, + mockSetLanguageConfiguration, + mockSetMonarchTokensProvider, mockSetJavaScriptCompilerOptions, mockSetJavaScriptDiagnosticsOptions, mockSetJavaScriptEagerModelSync, @@ -212,6 +221,15 @@ vi.mock("monaco-editor", () => ({ CtrlCmd: 2048, }, languages: { + register: mockRegisterLanguage, + setLanguageConfiguration: mockSetLanguageConfiguration, + setMonarchTokensProvider: mockSetMonarchTokensProvider, + IndentAction: { + None: 0, + Indent: 1, + IndentOutdent: 2, + Outdent: 3, + }, typescript: { JsxEmit: { ReactJSX: 4, @@ -266,6 +284,7 @@ vi.mock("monaco-editor/esm/vs/editor/browser/services/codeEditorService.js", () describe("MonacoHost", () => { beforeEach(() => { + window.localStorage.setItem("ui.locale", JSON.stringify("en")); mockCreateEditor.mockClear(); mockCreateModel.mockClear(); mockDefineTheme.mockClear(); @@ -279,6 +298,9 @@ describe("MonacoHost", () => { mockRevealRangeInCenter.mockClear(); mockRegistryGetOrCreate.mockClear(); mockRegisterCodeEditorOpenHandler.mockClear(); + mockRegisterLanguage.mockClear(); + mockSetLanguageConfiguration.mockClear(); + mockSetMonarchTokensProvider.mockClear(); mockEditorInstance.dispose.mockClear(); mockEditorInstance.getValue.mockClear(); mockEditorInstance.layout.mockClear(); @@ -292,6 +314,10 @@ describe("MonacoHost", () => { openHandlerState.current = null; }); + afterEach(() => { + window.localStorage.clear(); + }); + it("configures Monaco JS/TS defaults for JSX syntax and eager model sync", () => { expect(mockSetTypeScriptCompilerOptions).toHaveBeenCalledWith( expect.objectContaining({ @@ -353,6 +379,7 @@ describe("MonacoHost", () => { expect.any(HTMLDivElement), expect.objectContaining({ readOnly: false, + "semanticHighlighting.enabled": true, theme: "coder-studio-workspace-mint-light", }) ); @@ -625,6 +652,39 @@ describe("MonacoHost", () => { }); }); + it("attaches vue workspace-backed models with the vue language id", async () => { + render( + + const count = 1;'} + /> + + ); + + await waitFor(() => { + expect(mockRegistryGetOrCreate).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceRootPath: "/repo", + path: "src/App.vue", + language: "vue", + }) + ); + expect(mockAttachLspBridgeModel).toHaveBeenCalledWith( + { + workspaceId: "ws-test", + workspaceRootPath: "/repo", + path: "src/App.vue", + monacoLanguage: "vue", + model: workspaceModelA, + }, + expect.any(Function) + ); + }); + }); + it("does not attach the lsp bridge when runtime mode is off", async () => { const store = createStore(); store.set(lspRuntimeModeAtom, "off"); diff --git a/packages/web/src/features/code-editor/components/monaco-host.tsx b/packages/web/src/features/code-editor/components/monaco-host.tsx index 3225bbec..26731f60 100644 --- a/packages/web/src/features/code-editor/components/monaco-host.tsx +++ b/packages/web/src/features/code-editor/components/monaco-host.tsx @@ -25,6 +25,7 @@ import { globalLspBridge, type LspBridgeState } from "../lsp/bridge"; import { lspRuntimeModeAtom } from "../lsp/runtime-mode"; import { monacoModelRegistry } from "../monaco/model-registry"; import { fromWorkspaceFileUri } from "../monaco/uri"; +import { ensureVueLanguageRegistered } from "../monaco/vue-language"; import { LspStatusNotice } from "./lsp-status-notice"; const monacoGlobal = globalThis as typeof globalThis & { @@ -52,6 +53,7 @@ monacoGlobal.MonacoEnvironment ??= { let javaScriptTypeScriptDefaultsConfigured = false; configureJavaScriptTypeScriptDefaults(); +ensureVueLanguageRegistered(); interface MonacoTypeScriptLanguage { JsxEmit: { @@ -184,6 +186,7 @@ export const MonacoHost: FC = ({ minimap: { enabled: false }, readOnly, scrollBeyondLastLine: false, + "semanticHighlighting.enabled": true, padding: { top: 12, bottom: 12 }, automaticLayout: true, }); @@ -434,6 +437,7 @@ function detectEditorLanguage(filePath: string): string { py: "python", go: "go", rs: "rust", + vue: "vue", java: "java", cpp: "cpp", c: "c", diff --git a/packages/web/src/features/code-editor/index.test.tsx b/packages/web/src/features/code-editor/index.test.tsx index 9b1181b7..090a596d 100644 --- a/packages/web/src/features/code-editor/index.test.tsx +++ b/packages/web/src/features/code-editor/index.test.tsx @@ -160,6 +160,14 @@ function createDeferred() { return { promise, resolve, reject }; } +function pressSaveShortcut() { + fireEvent.keyDown(window, { + key: "s", + code: "KeyS", + ctrlKey: true, + }); +} + describe("CodeEditorHost", () => { afterEach(() => { vi.restoreAllMocks(); @@ -288,10 +296,15 @@ describe("CodeEditorHost", () => { baseHash: string; encoding: "utf-8"; }>(); - const sendCommand = vi - .fn() - .mockImplementationOnce(() => firstRead.promise) - .mockImplementationOnce(() => secondRead.promise); + let fileReadCount = 0; + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "file.read") { + fileReadCount += 1; + return fileReadCount === 1 ? firstRead.promise : secondRead.promise; + } + + return null; + }); const { store } = setupStore({ activePath: "src/foo.ts", sendCommand }); render( @@ -320,15 +333,16 @@ describe("CodeEditorHost", () => { }); await waitFor(() => { - expect(sendCommand).toHaveBeenNthCalledWith( - 2, + const fileReadCalls = sendCommand.mock.calls.filter(([op]) => op === "file.read"); + expect(fileReadCalls).toHaveLength(2); + expect(fileReadCalls[1]).toEqual([ "file.read", { workspaceId: "ws-1", path: "src/foo.ts", }, - undefined - ); + undefined, + ]); }); await act(async () => { @@ -452,7 +466,7 @@ describe("CodeEditorHost", () => { ); }); - const heading = screen.getByRole("heading", { level: 2, name: "Open Editors (1)" }); + const heading = screen.getByRole("heading", { level: 2, name: "Open Files (1)" }); const section = heading.closest("section") as HTMLElement; const closeAll = within(section).getByRole("button", { name: "Close all" }); expect(closeAll).toBeEnabled(); @@ -781,10 +795,12 @@ describe("CodeEditorHost", () => { }); store.set(editorModeAtomFamily("ws-1"), "diff"); store.set(gitDiffPreviewAtomFamily("ws-1"), { + kind: "worktree-file-diff", path: "src/unrelated.ts", diff: "diff --git a/src/unrelated.ts b/src/unrelated.ts", + renderAs: "text", + status: "modified", staged: false, - source: "file", }); render( @@ -826,10 +842,12 @@ describe("CodeEditorHost", () => { }); store.set(editorModeAtomFamily("ws-1"), "diff"); store.set(gitDiffPreviewAtomFamily("ws-1"), { + kind: "worktree-file-diff", path: "src/final.ts", diff: "diff --git a/src/final.ts b/src/final.ts", + renderAs: "text", + status: "modified", staged: false, - source: "file", }); render( @@ -908,13 +926,7 @@ describe("CodeEditorHost", () => { expect(screen.queryByTestId("monaco-host")).not.toBeInTheDocument(); // Save button must be disabled for images (nothing to write back). - const saveBtn = screen.getByRole("button", { name: "Save File" }); - expect(saveBtn).toBeDisabled(); - expect(saveBtn).not.toHaveAttribute("title"); - - fireEvent.mouseEnter(saveBtn); - fireEvent.focus(saveBtn); - expect(screen.queryByRole("tooltip")).toBeNull(); + expect(screen.queryByRole("button", { name: "Save File" })).not.toBeInTheDocument(); }); it("defaults text files into edit mode and shows the text editor", async () => { @@ -1063,10 +1075,12 @@ describe("CodeEditorHost", () => { }, }); store.set(gitDiffPreviewAtomFamily("ws-1"), { + kind: "worktree-file-diff", path: "src/dirty.ts", diff: "diff --git a/src/dirty.ts b/src/dirty.ts", + renderAs: "text", + status: "modified", staged: false, - source: "file", }); render( @@ -1077,20 +1091,98 @@ describe("CodeEditorHost", () => { expect(store.get(editorModeAtomFamily("ws-1"))).toBe("edit"); expect(store.get(gitDiffPreviewAtomFamily("ws-1"))).toEqual({ + kind: "worktree-file-diff", path: "src/dirty.ts", diff: "diff --git a/src/dirty.ts b/src/dirty.ts", + renderAs: "text", + status: "modified", staged: false, - source: "file", + }); + }); + + it("keeps search replace diff previews as active diff state for the matching file", async () => { + const { store } = setupStore({ + activePath: "src/dirty.ts", + openFiles: { + "src/dirty.ts": { + kind: "text", + path: "src/dirty.ts", + content: "changed", + savedContent: "original", + baseHash: "dirty-hash", + isDirty: false, + }, + }, + }); + store.set(editorModeAtomFamily("ws-1"), "diff"); + store.set(gitDiffPreviewAtomFamily("ws-1"), { + kind: "search-replace-file-diff", + path: "src/dirty.ts", + title: "src/dirty.ts", + sessionId: "session-1", + baseHash: "dirty-hash", + originalContent: "original", + modifiedContent: "changed", + }); + + const { result } = renderHook(() => useCodeEditorActions(), { + wrapper: wrapperFor(store), + }); + + expect(result.current.mode).toBe("diff"); + expect(result.current.activeDiffChange).toEqual({ + kind: "search-replace-file-diff", + path: "src/dirty.ts", + title: "src/dirty.ts", + sessionId: "session-1", + baseHash: "dirty-hash", + originalContent: "original", + modifiedContent: "changed", }); }); it("renders commit diff preview in the mobile content-only editor surface without an active file", () => { const { store } = setupStore(); store.set(gitDiffPreviewAtomFamily("ws-1"), { - path: "abc123", - title: "abc123 · commit subject", + kind: "commit-file-diff", + path: "src/app.tsx", + title: "src/app.tsx", diff: "diff --git a/src/app.tsx b/src/app.tsx", - source: "commit", + renderAs: "text", + status: "modified", + originalContent: "const app = 0;", + modifiedContent: "const app = 1;", + commit: { + sha: "abc123", + shortSha: "abc123", + subject: "commit subject", + authorName: "Spencer", + authoredAt: 1, + }, + file: { + path: "src/app.tsx", + status: "modified", + renderAs: "text", + }, + parentList: { + kind: "commit-file-list", + path: "abc123", + title: "abc123 · commit subject", + commit: { + sha: "abc123", + shortSha: "abc123", + subject: "commit subject", + authorName: "Spencer", + authoredAt: 1, + }, + files: [ + { + path: "src/app.tsx", + status: "modified", + renderAs: "text", + }, + ], + }, }); render( @@ -1099,9 +1191,13 @@ describe("CodeEditorHost", () => { ); + expect(screen.getByTestId("monaco-diff-host")).toHaveAttribute( + "data-original", + "const app = 0;" + ); expect(screen.getByTestId("monaco-diff-host")).toHaveAttribute( "data-modified", - "diff --git a/src/app.tsx b/src/app.tsx" + "const app = 1;" ); expect(screen.queryByRole("button", { name: "Close" })).not.toBeInTheDocument(); }); @@ -1129,10 +1225,45 @@ describe("CodeEditorHost", () => { }, }); store.set(gitDiffPreviewAtomFamily("ws-1"), { - path: "abc123", - title: "abc123 · commit subject", + kind: "commit-file-diff", + path: "src/app.tsx", + title: "src/app.tsx", diff: "diff --git a/src/app.tsx b/src/app.tsx", - source: "commit", + renderAs: "text", + status: "modified", + originalContent: "const app = 0;", + modifiedContent: "const app = 1;", + commit: { + sha: "abc123", + shortSha: "abc123", + subject: "commit subject", + authorName: "Spencer", + authoredAt: 1, + }, + file: { + path: "src/app.tsx", + status: "modified", + renderAs: "text", + }, + parentList: { + kind: "commit-file-list", + path: "abc123", + title: "abc123 · commit subject", + commit: { + sha: "abc123", + shortSha: "abc123", + subject: "commit subject", + authorName: "Spencer", + authoredAt: 1, + }, + files: [ + { + path: "src/app.tsx", + status: "modified", + renderAs: "text", + }, + ], + }, }); render( @@ -1163,7 +1294,7 @@ describe("CodeEditorHost", () => { }); }); - it("closing a commit-history preview restores the background file to its normal mode", async () => { + it("closing a commit file diff returns to its parent commit file list before restoring the background file", async () => { const { store } = setupStore({ activePath: "src/background.ts", openFiles: { @@ -1192,10 +1323,45 @@ describe("CodeEditorHost", () => { act(() => { store.set(editorModeAtomFamily("ws-1"), "diff"); store.set(gitDiffPreviewAtomFamily("ws-1"), { - path: "abc123", - title: "abc123 · commit subject", + kind: "commit-file-diff", + path: "src/app.tsx", + title: "src/app.tsx", diff: "diff --git a/src/app.tsx b/src/app.tsx", - source: "commit", + renderAs: "text", + status: "modified", + originalContent: "const app = 0;", + modifiedContent: "const app = 1;", + commit: { + sha: "abc123", + shortSha: "abc123", + subject: "commit subject", + authorName: "Spencer", + authoredAt: 1, + }, + file: { + path: "src/app.tsx", + status: "modified", + renderAs: "text", + }, + parentList: { + kind: "commit-file-list", + path: "abc123", + title: "abc123 · commit subject", + commit: { + sha: "abc123", + shortSha: "abc123", + subject: "commit subject", + authorName: "Spencer", + authoredAt: 1, + }, + files: [ + { + path: "src/app.tsx", + status: "modified", + renderAs: "text", + }, + ], + }, }); }); @@ -1205,6 +1371,44 @@ describe("CodeEditorHost", () => { fireEvent.click(screen.getByRole("button", { name: "Close" })); + await waitFor(() => { + expect(store.get(gitDiffPreviewAtomFamily("ws-1"))).toEqual({ + kind: "commit-file-list", + path: "abc123", + title: "abc123 · commit subject", + commit: { + sha: "abc123", + shortSha: "abc123", + subject: "commit subject", + authorName: "Spencer", + authoredAt: 1, + }, + files: [ + { + path: "src/app.tsx", + status: "modified", + renderAs: "text", + }, + ], + }); + }); + + const sendCommand = ( + store.get(wsClientAtom) as unknown as { + sendCommand: ReturnType; + } + ).sendCommand; + expect(sendCommand).not.toHaveBeenCalledWith( + "git.commitDetail", + expect.objectContaining({ + workspaceId: "ws-1", + sha: "abc123", + }), + undefined + ); + + fireEvent.click(screen.getByRole("button", { name: "Close" })); + await waitFor(() => { expect(store.get(gitDiffPreviewAtomFamily("ws-1"))).toBeNull(); expect(store.get(editorModeAtomFamily("ws-1"))).toBe("edit"); @@ -1213,6 +1417,206 @@ describe("CodeEditorHost", () => { }); }); + it("ignores a stale commit file diff response after the user switches to another commit list", async () => { + const diffDeferred = createDeferred<{ + diff: string; + renderAs: "text"; + status: "modified"; + originalContent: string; + modifiedContent: string; + }>(); + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "git.commitFileDiff") { + return diffDeferred.promise; + } + + if (op === "file.read") { + return { + kind: "text", + content: "hello world", + baseHash: "abc123", + encoding: "utf-8", + }; + } + + return null; + }); + const { store } = setupStore({ sendCommand }); + + const parentListA = { + kind: "commit-file-list" as const, + path: "abc123", + title: "abc123 · commit subject", + commit: { + sha: "abc123", + shortSha: "abc123", + subject: "commit subject", + authorName: "Spencer", + authoredAt: 1, + }, + files: [ + { + path: "src/app.tsx", + status: "modified" as const, + renderAs: "text" as const, + }, + ], + }; + const parentListB = { + kind: "commit-file-list" as const, + path: "def456", + title: "def456 · other commit", + commit: { + sha: "def456", + shortSha: "def456", + subject: "other commit", + authorName: "Spencer", + authoredAt: 2, + }, + files: [ + { + path: "src/other.tsx", + status: "modified" as const, + renderAs: "text" as const, + }, + ], + }; + + act(() => { + store.set(gitDiffPreviewAtomFamily("ws-1"), parentListA); + }); + + const { result } = renderHook(() => useCodeEditorActions(), { + wrapper: wrapperFor(store), + }); + + const openPromise = result.current.openCommitFileDiff(parentListA.files[0]!); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "git.commitFileDiff", + { + workspaceId: "ws-1", + sha: "abc123", + path: "src/app.tsx", + }, + undefined + ); + }); + + act(() => { + store.set(gitDiffPreviewAtomFamily("ws-1"), parentListB); + }); + + let applied = true; + await act(async () => { + diffDeferred.resolve({ + diff: "diff --git a/src/app.tsx b/src/app.tsx", + renderAs: "text", + status: "modified", + originalContent: "const app = 0;\n", + modifiedContent: "const app = 1;\n", + }); + applied = await openPromise; + }); + + expect(applied).toBe(false); + expect(store.get(gitDiffPreviewAtomFamily("ws-1"))).toEqual(parentListB); + }); + + it("ignores a stale commit file diff response after the same commit list is reopened", async () => { + const diffDeferred = createDeferred<{ + diff: string; + renderAs: "text"; + status: "modified"; + originalContent: string; + modifiedContent: string; + }>(); + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "git.commitFileDiff") { + return diffDeferred.promise; + } + + if (op === "file.read") { + return { + kind: "text", + content: "hello world", + baseHash: "abc123", + encoding: "utf-8", + }; + } + + return null; + }); + const { store } = setupStore({ sendCommand }); + + const parentListA = { + kind: "commit-file-list" as const, + path: "abc123", + title: "abc123 · commit subject", + commit: { + sha: "abc123", + shortSha: "abc123", + subject: "commit subject", + authorName: "Spencer", + authoredAt: 1, + }, + files: [ + { + path: "src/app.tsx", + status: "modified" as const, + renderAs: "text" as const, + }, + ], + }; + const reopenedParentList = { + ...parentListA, + files: [...parentListA.files], + }; + + act(() => { + store.set(gitDiffPreviewAtomFamily("ws-1"), parentListA); + }); + + const { result } = renderHook(() => useCodeEditorActions(), { + wrapper: wrapperFor(store), + }); + + const openPromise = result.current.openCommitFileDiff(parentListA.files[0]!); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "git.commitFileDiff", + { + workspaceId: "ws-1", + sha: "abc123", + path: "src/app.tsx", + }, + undefined + ); + }); + + act(() => { + store.set(gitDiffPreviewAtomFamily("ws-1"), null); + store.set(gitDiffPreviewAtomFamily("ws-1"), reopenedParentList); + }); + + let applied = true; + await act(async () => { + diffDeferred.resolve({ + diff: "diff --git a/src/app.tsx b/src/app.tsx", + renderAs: "text", + status: "modified", + originalContent: "const app = 0;\n", + modifiedContent: "const app = 1;\n", + }); + applied = await openPromise; + }); + + expect(applied).toBe(false); + expect(store.get(gitDiffPreviewAtomFamily("ws-1"))).toEqual(reopenedParentList); + }); + it("closing a commit-history preview restores the background file save error", async () => { const sendCommand = vi.fn().mockImplementation(async (op: string, args?: { path?: string }) => { if (op === "file.write" && args?.path === "src/background.ts") { @@ -1251,21 +1655,34 @@ describe("CodeEditorHost", () => { ); - fireEvent.click(screen.getByRole("button", { name: "Save File" })); + pressSaveShortcut(); expect(await screen.findByRole("alert")).toHaveTextContent("Save failed on background"); act(() => { store.set(gitDiffPreviewAtomFamily("ws-1"), { + kind: "commit-file-list", path: "abc123", title: "abc123 · commit subject", - diff: "diff --git a/src/app.tsx b/src/app.tsx", - source: "commit", + commit: { + sha: "abc123", + shortSha: "abc123", + subject: "commit subject", + authorName: "Spencer", + authoredAt: 1, + }, + files: [ + { + path: "src/app.tsx", + status: "modified", + renderAs: "text", + }, + ], }); }); await waitFor(() => { - expect(screen.getByTestId("monaco-diff-host")).toBeInTheDocument(); + expect(screen.getByTestId("commit-file-list-preview")).toBeInTheDocument(); expect(screen.queryByText("Save failed on background")).not.toBeInTheDocument(); }); @@ -1278,6 +1695,151 @@ describe("CodeEditorHost", () => { }); }); + it("openLocation normalizes editor mode when exiting a commit file list preview over a file-diff background", async () => { + const { store } = setupStore({ + activePath: "src/background.ts", + openFiles: { + "src/background.ts": { + kind: "text", + path: "src/background.ts", + content: "background", + savedContent: "background", + baseHash: "hash-bg", + isDirty: false, + }, + }, + }); + + render( + + + + ); + + await waitFor(() => { + expect(store.get(editorModeAtomFamily("ws-1"))).toBe("edit"); + expect(screen.getByTestId("monaco-host")).toHaveTextContent("background"); + }); + + act(() => { + store.set(editorModeAtomFamily("ws-1"), "diff"); + store.set(gitDiffPreviewAtomFamily("ws-1"), { + kind: "commit-file-list", + path: "abc123", + title: "abc123 · commit subject", + commit: { + sha: "abc123", + shortSha: "abc123", + subject: "commit subject", + authorName: "Spencer", + authoredAt: 1, + }, + files: [ + { + path: "src/app.tsx", + status: "modified", + renderAs: "text", + }, + ], + }); + }); + + await waitFor(() => { + expect(screen.getByTestId("commit-file-list-preview")).toBeInTheDocument(); + expect(store.get(editorModeAtomFamily("ws-1"))).toBe("diff"); + }); + + const { result } = renderHook(() => useOpenLocation("ws-1"), { + wrapper: wrapperFor(store), + }); + + await act(async () => { + await result.current.openLocation({ + workspaceId: "ws-1", + path: "src/background.ts", + source: "manual", + }); + }); + + await waitFor(() => { + expect(store.get(gitDiffPreviewAtomFamily("ws-1"))).toBeNull(); + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBe("src/background.ts"); + expect(store.get(editorModeAtomFamily("ws-1"))).toBe("edit"); + expect(screen.getByTestId("monaco-host")).toHaveTextContent("background"); + expect(screen.queryByTestId("commit-file-list-preview")).not.toBeInTheDocument(); + }); + }); + + it("shows the commit file list preview while a background save error remains hidden", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string, args?: { path?: string }) => { + if (op === "file.write" && args?.path === "src/background.ts") { + throw new Error("Save failed on background"); + } + + if (op === "file.read") { + return { + kind: "text", + content: "hello world", + baseHash: "abc123", + encoding: "utf-8", + }; + } + + return null; + }); + const { store } = setupStore({ + activePath: "src/background.ts", + sendCommand, + openFiles: { + "src/background.ts": { + kind: "text", + path: "src/background.ts", + content: "changed background", + savedContent: "saved background", + baseHash: "hash-bg", + isDirty: true, + }, + }, + }); + + render( + + + + ); + + pressSaveShortcut(); + + expect(await screen.findByRole("alert")).toHaveTextContent("Save failed on background"); + + act(() => { + store.set(gitDiffPreviewAtomFamily("ws-1"), { + kind: "commit-file-list", + path: "abc123", + title: "abc123 · commit subject", + commit: { + sha: "abc123", + shortSha: "abc123", + subject: "commit subject", + authorName: "Spencer", + authoredAt: 1, + }, + files: [ + { + path: "src/app.tsx", + status: "modified", + renderAs: "text", + }, + ], + }); + }); + + await waitFor(() => { + expect(screen.getByTestId("commit-file-list-preview")).toBeInTheDocument(); + expect(screen.queryByText("Save failed on background")).not.toBeInTheDocument(); + }); + }); + it("openLocation normalizes editor mode when exiting a commit-history preview over a file-diff background", async () => { const { store } = setupStore({ activePath: "src/background.ts", @@ -1307,15 +1869,28 @@ describe("CodeEditorHost", () => { act(() => { store.set(editorModeAtomFamily("ws-1"), "diff"); store.set(gitDiffPreviewAtomFamily("ws-1"), { + kind: "commit-file-list", path: "abc123", title: "abc123 · commit subject", - diff: "diff --git a/src/app.tsx b/src/app.tsx", - source: "commit", + commit: { + sha: "abc123", + shortSha: "abc123", + subject: "commit subject", + authorName: "Spencer", + authoredAt: 1, + }, + files: [ + { + path: "src/app.tsx", + status: "modified", + renderAs: "text", + }, + ], }); }); await waitFor(() => { - expect(screen.getByTestId("monaco-diff-host")).toBeInTheDocument(); + expect(screen.getByTestId("commit-file-list-preview")).toBeInTheDocument(); expect(store.get(editorModeAtomFamily("ws-1"))).toBe("diff"); }); @@ -1336,7 +1911,7 @@ describe("CodeEditorHost", () => { expect(store.get(activeFilePathAtomFamily("ws-1"))).toBe("src/background.ts"); expect(store.get(editorModeAtomFamily("ws-1"))).toBe("edit"); expect(screen.getByTestId("monaco-host")).toHaveTextContent("background"); - expect(screen.queryByTestId("monaco-diff-host")).not.toBeInTheDocument(); + expect(screen.queryByTestId("commit-file-list-preview")).not.toBeInTheDocument(); }); }); @@ -1397,10 +1972,12 @@ describe("CodeEditorHost", () => { untracked: [], }); store.set(gitDiffPreviewAtomFamily("ws-1"), { + kind: "worktree-file-diff", path: "src/app.ts", diff: "diff --git a/src/app.ts b/src/app.ts", + renderAs: "text", + status: "modified", staged: false, - source: "file", }); const { result } = renderHook(() => useCodeEditorActions(), { @@ -1435,11 +2012,12 @@ describe("CodeEditorHost", () => { untracked: [], }); store.set(gitDiffPreviewAtomFamily("ws-1"), { + kind: "worktree-file-diff", path: "src/app.ts", diff: "diff --git a/src/app.ts b/src/app.ts", staged: false, - source: "file", renderAs: "text", + status: "modified", originalContent: "export const app = 1;", modifiedContent: "export const app = 2;", }); @@ -1455,6 +2033,8 @@ describe("CodeEditorHost", () => { }); fireEvent.click(screen.getByRole("button", { name: "Close" })); + expect(screen.getByRole("dialog", { name: "Discard unsaved changes?" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Discard and Close" })); await waitFor(() => { expect(store.get(gitDiffPreviewAtomFamily("ws-1"))).toBeNull(); @@ -1463,7 +2043,7 @@ describe("CodeEditorHost", () => { }); }); - it("shows the save tooltip on desktop for a text buffer", async () => { + it("omits the desktop save button for a text buffer", async () => { const { store } = setupStore({ activePath: "src/save.ts", openFiles: { @@ -1484,11 +2064,7 @@ describe("CodeEditorHost", () => { ); - const saveBtn = screen.getByRole("button", { name: "Save File" }); - expect(saveBtn).not.toHaveAttribute("title"); - - fireEvent.mouseEnter(saveBtn); - expect(screen.getByRole("tooltip")).toHaveTextContent("Save File"); + expect(screen.queryByRole("button", { name: "Save File" })).not.toBeInTheDocument(); }); it("clears dirty state when text returns to the last saved content", async () => { @@ -1528,7 +2104,7 @@ describe("CodeEditorHost", () => { }); }); - expect(screen.getByRole("button", { name: "Save File" })).toBeDisabled(); + expect(screen.queryByRole("button", { name: "Save File" })).not.toBeInTheDocument(); }); it("reloads a clean text buffer after an external refresh signal changes the file on disk", async () => { @@ -1624,7 +2200,7 @@ describe("CodeEditorHost", () => { ); - fireEvent.click(screen.getByRole("button", { name: "Save File" })); + pressSaveShortcut(); expect(await screen.findByRole("alert")).toHaveTextContent("Save failed on A"); @@ -1632,6 +2208,8 @@ describe("CodeEditorHost", () => { .getByRole("button", { name: "src/a.ts" }) .closest(".workspace-open-editors__row") as HTMLElement; fireEvent.click(within(activeRow).getByRole("button", { name: "Close src/a.ts" })); + expect(screen.getByRole("dialog", { name: "Discard unsaved changes?" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Discard and Close" })); await waitFor(() => { expect(store.get(activeFilePathAtomFamily("ws-1"))).toBeNull(); @@ -1693,7 +2271,7 @@ describe("CodeEditorHost", () => { ); - fireEvent.click(screen.getByRole("button", { name: "Save File" })); + pressSaveShortcut(); await waitFor(() => { expect(sendCommand).toHaveBeenCalledWith( @@ -1717,7 +2295,7 @@ describe("CodeEditorHost", () => { expect(screen.getByTestId("monaco-host")).toHaveTextContent("changed b"); expect(screen.queryByRole("button", { name: "Saving" })).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: "Save File" })); + pressSaveShortcut(); await waitFor(() => { expect(sendCommand).toHaveBeenCalledWith( @@ -1737,6 +2315,126 @@ describe("CodeEditorHost", () => { }); }); + it("deduplicates repeated save shortcut dispatches while a save is in flight", async () => { + const saveDeferred = createDeferred<{ newHash: string }>(); + const sendCommand = vi.fn().mockImplementation(async (op: string, args?: { path?: string }) => { + if (op === "file.write" && args?.path === "src/a.ts") { + return saveDeferred.promise; + } + + if (op === "file.read") { + return { + kind: "text", + content: "hello world", + baseHash: "abc123", + encoding: "utf-8", + }; + } + + return null; + }); + const { store } = setupStore({ + activePath: "src/a.ts", + sendCommand, + openFiles: { + "src/a.ts": { + kind: "text", + path: "src/a.ts", + content: "changed a", + savedContent: "saved a", + baseHash: "hash-a", + isDirty: true, + }, + }, + }); + + render( + + + + ); + + pressSaveShortcut(); + pressSaveShortcut(); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "file.write", + { + workspaceId: "ws-1", + path: "src/a.ts", + content: "changed a", + baseHash: "hash-a", + }, + undefined + ); + }); + expect(sendCommand.mock.calls.filter(([op]) => op === "file.write")).toHaveLength(1); + + await act(async () => { + saveDeferred.resolve({ newHash: "hash-a-2" }); + }); + }); + + it("deduplicates overlapping save requests before saving state rerenders", async () => { + const saveDeferred = createDeferred<{ newHash: string }>(); + const sendCommand = vi.fn().mockImplementation(async (op: string, args?: { path?: string }) => { + if (op === "file.write" && args?.path === "src/a.ts") { + return saveDeferred.promise; + } + + if (op === "file.read") { + return { + kind: "text", + content: "hello world", + baseHash: "abc123", + encoding: "utf-8", + }; + } + + return null; + }); + const { store } = setupStore({ + activePath: "src/a.ts", + sendCommand, + openFiles: { + "src/a.ts": { + kind: "text", + path: "src/a.ts", + content: "changed a", + savedContent: "saved a", + baseHash: "hash-a", + isDirty: true, + }, + }, + }); + + const { result } = renderHook(() => useCodeEditorActions(), { + wrapper: wrapperFor(store), + }); + + void result.current.handleSave(); + void result.current.handleSave(); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "file.write", + { + workspaceId: "ws-1", + path: "src/a.ts", + content: "changed a", + baseHash: "hash-a", + }, + undefined + ); + }); + expect(sendCommand.mock.calls.filter(([op]) => op === "file.write")).toHaveLength(1); + + await act(async () => { + saveDeferred.resolve({ newHash: "hash-a-2" }); + }); + }); + it("ignores a stale save success after close all preserves commit preview and the file is reopened", async () => { const staleSave = createDeferred<{ newHash: string }>(); const sendCommand = vi.fn().mockImplementation(async (op: string, args?: { path?: string }) => { @@ -1781,7 +2479,7 @@ describe("CodeEditorHost", () => { wrapper: wrapperFor(store), }); - fireEvent.click(screen.getByRole("button", { name: "Save File" })); + pressSaveShortcut(); await waitFor(() => { expect(sendCommand).toHaveBeenCalledWith( @@ -1798,26 +2496,54 @@ describe("CodeEditorHost", () => { act(() => { store.set(gitDiffPreviewAtomFamily("ws-1"), { + kind: "commit-file-list", path: "abc123", title: "abc123 · commit subject", - diff: "diff --git a/src/app.tsx b/src/app.tsx", - source: "commit", + commit: { + sha: "abc123", + shortSha: "abc123", + subject: "commit subject", + authorName: "Spencer", + authoredAt: 1, + }, + files: [ + { + path: "src/app.tsx", + status: "modified", + renderAs: "text", + }, + ], }); }); await waitFor(() => { - expect(screen.getByTestId("monaco-diff-host")).toBeInTheDocument(); + expect(screen.getByTestId("commit-file-list-preview")).toBeInTheDocument(); }); fireEvent.click(screen.getByRole("button", { name: "Close all" })); + expect(screen.getByRole("dialog", { name: "Discard unsaved changes?" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Discard and Close" })); expect(store.get(activeFilePathAtomFamily("ws-1"))).toBeNull(); expect(store.get(openFilesAtomFamily("ws-1"))).toEqual({}); expect(store.get(gitDiffPreviewAtomFamily("ws-1"))).toEqual({ + kind: "commit-file-list", path: "abc123", title: "abc123 · commit subject", - diff: "diff --git a/src/app.tsx b/src/app.tsx", - source: "commit", + commit: { + sha: "abc123", + shortSha: "abc123", + subject: "commit subject", + authorName: "Spencer", + authoredAt: 1, + }, + files: [ + { + path: "src/app.tsx", + status: "modified", + renderAs: "text", + }, + ], }); await act(async () => { @@ -1898,7 +2624,7 @@ describe("CodeEditorHost", () => { wrapper: wrapperFor(store), }); - fireEvent.click(screen.getByRole("button", { name: "Save File" })); + pressSaveShortcut(); await waitFor(() => { expect(sendCommand).toHaveBeenCalledWith( @@ -1915,18 +2641,33 @@ describe("CodeEditorHost", () => { act(() => { store.set(gitDiffPreviewAtomFamily("ws-1"), { + kind: "commit-file-list", path: "abc123", title: "abc123 · commit subject", - diff: "diff --git a/src/app.tsx b/src/app.tsx", - source: "commit", + commit: { + sha: "abc123", + shortSha: "abc123", + subject: "commit subject", + authorName: "Spencer", + authoredAt: 1, + }, + files: [ + { + path: "src/app.tsx", + status: "modified", + renderAs: "text", + }, + ], }); }); await waitFor(() => { - expect(screen.getByTestId("monaco-diff-host")).toBeInTheDocument(); + expect(screen.getByTestId("commit-file-list-preview")).toBeInTheDocument(); }); fireEvent.click(screen.getByRole("button", { name: "Close all" })); + expect(screen.getByRole("dialog", { name: "Discard unsaved changes?" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Discard and Close" })); await act(async () => { await result.current.openLocation({ diff --git a/packages/web/src/features/code-editor/lsp/bridge.test.tsx b/packages/web/src/features/code-editor/lsp/bridge.test.tsx index 4da3fb5a..403766c3 100644 --- a/packages/web/src/features/code-editor/lsp/bridge.test.tsx +++ b/packages/web/src/features/code-editor/lsp/bridge.test.tsx @@ -39,6 +39,7 @@ vi.mock("monaco-editor", () => ({ registerHoverProvider: vi.fn(), registerReferenceProvider: vi.fn(), registerDocumentSymbolProvider: vi.fn(), + registerDocumentSemanticTokensProvider: vi.fn(), SymbolKind: { Variable: 13, }, @@ -210,6 +211,55 @@ describe("createLspBridge", () => { }); }); + it("opens vue documents through the lazy lsp bridge using the vue language id", async () => { + const sendCommand = vi + .fn() + .mockResolvedValueOnce({ + kind: "ready", + displayName: "Vue language server", + source: "managed", + summary: { + workspaceId: "ws-1", + serverKind: "vue", + status: "ready", + capabilities: { + definition: true, + references: true, + hover: true, + documentSymbols: true, + diagnostics: true, + }, + }, + }) + .mockResolvedValue(undefined); + + const bridge = createLspBridge({ + sendCommand: sendCommand as BridgeSendCommand, + subscribe: vi.fn(() => () => {}), + }); + + bridge.attachModel({ + workspaceId: "ws-1", + workspaceRootPath: "/repo", + path: "src/App.vue", + monacoLanguage: "vue", + model: createMockModel( + "\n", + 1, + monaco.Uri.file("/repo/src/App.vue") + ), + }); + + await vi.waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith("lsp.openDocument", { + workspaceId: "ws-1", + path: "src/App.vue", + languageId: "vue", + text: "\n", + }); + }); + }); + it("does not open a document when ensureSession returns disabled", async () => { const sendCommand = vi.fn().mockResolvedValueOnce({ kind: "disabled", diff --git a/packages/web/src/features/code-editor/lsp/bridge.ts b/packages/web/src/features/code-editor/lsp/bridge.ts index 44a41a5e..ff150831 100644 --- a/packages/web/src/features/code-editor/lsp/bridge.ts +++ b/packages/web/src/features/code-editor/lsp/bridge.ts @@ -4,6 +4,7 @@ import type { LspEnsureSessionResult, LspHoverResult, LspLocation, + LspSemanticTokens, LspToolInstallJobSnapshot, } from "@coder-studio/core"; import { Topics } from "@coder-studio/core"; @@ -49,15 +50,19 @@ const noopTransport: LspBridgeTransport = { subscribe: () => () => {}, }; -type MissingOrFailedReadiness = Exclude< +type InstallableReadiness = Extract< LspEnsureSessionResult, - { kind: "ready" | "unsupported_language" } + { kind: "tool_missing" | "installing" | "failed" } >; -function isMissingOrFailedReadiness( +function isInstallableReadiness( readiness: LspEnsureSessionResult -): readiness is MissingOrFailedReadiness { - return readiness.kind !== "ready" && readiness.kind !== "unsupported_language"; +): readiness is InstallableReadiness { + return ( + readiness.kind === "tool_missing" || + readiness.kind === "installing" || + readiness.kind === "failed" + ); } export function createLspBridge(initialTransport: Partial = {}) { @@ -123,6 +128,11 @@ export function createLspBridge(initialTransport: Partial = workspaceId: meta.workspaceId, path: meta.path, }), + requestSemanticTokens: async ({ meta }) => + await transport.sendCommand("lsp.semanticTokens", { + workspaceId: meta.workspaceId, + path: meta.path, + }), }); function configure(nextTransport: Partial): void { @@ -173,7 +183,7 @@ export function createLspBridge(initialTransport: Partial = if (readiness.kind !== "ready") { onStateChange?.(readiness); - if (isMissingOrFailedReadiness(readiness) && readiness.installJob) { + if (isInstallableReadiness(readiness) && readiness.installJob) { currentJobId = readiness.installJob.jobId; schedulePoll(); } @@ -360,6 +370,7 @@ export function createLspBridge(initialTransport: Partial = provideHover: providers.provideHover, provideReferences: providers.provideReferences, provideDocumentSymbols: providers.provideDocumentSymbols, + provideDocumentSemanticTokens: providers.provideDocumentSemanticTokens, }; } diff --git a/packages/web/src/features/code-editor/lsp/language-map.test.ts b/packages/web/src/features/code-editor/lsp/language-map.test.ts new file mode 100644 index 00000000..46663821 --- /dev/null +++ b/packages/web/src/features/code-editor/lsp/language-map.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from "vitest"; +import { resolveLspServerKind } from "./language-map"; + +describe("resolveLspServerKind", () => { + it("prefers the vue server kind for vue files even when Monaco reports typescript", () => { + expect(resolveLspServerKind("src/App.vue", "typescript")).toBe("vue"); + }); +}); diff --git a/packages/web/src/features/code-editor/lsp/language-map.ts b/packages/web/src/features/code-editor/lsp/language-map.ts index 2e9e7c8c..8645e021 100644 --- a/packages/web/src/features/code-editor/lsp/language-map.ts +++ b/packages/web/src/features/code-editor/lsp/language-map.ts @@ -4,6 +4,7 @@ const TYPESCRIPT_EXTENSIONS = new Set(["ts", "tsx", "js", "jsx", "mts", "cts", " const PYTHON_EXTENSIONS = new Set(["py"]); const GO_EXTENSIONS = new Set(["go"]); const RUST_EXTENSIONS = new Set(["rs"]); +const VUE_EXTENSIONS = new Set(["vue"]); export function resolveLspServerKind( filePath: string, @@ -11,6 +12,9 @@ export function resolveLspServerKind( ): LspServerKind | null { const extension = filePath.split(".").pop()?.toLowerCase() ?? ""; + if (VUE_EXTENSIONS.has(extension) || monacoLanguage === "vue") { + return "vue"; + } if (TYPESCRIPT_EXTENSIONS.has(extension) || monacoLanguage === "typescript") { return "typescript"; } diff --git a/packages/web/src/features/code-editor/lsp/providers.test.ts b/packages/web/src/features/code-editor/lsp/providers.test.ts index f39674f6..32ef4e61 100644 --- a/packages/web/src/features/code-editor/lsp/providers.test.ts +++ b/packages/web/src/features/code-editor/lsp/providers.test.ts @@ -27,6 +27,7 @@ vi.mock("monaco-editor", () => ({ registerHoverProvider: vi.fn(), registerReferenceProvider: vi.fn(), registerDocumentSymbolProvider: vi.fn(), + registerDocumentSemanticTokensProvider: vi.fn(), registerLinkProvider: vi.fn(), SymbolKind: { Variable: 13, @@ -72,6 +73,34 @@ function createMockModel( } describe("LSP providers", () => { + it("registers Monaco providers for vue files on the vue language", () => { + const bridge = createLspBridge({ + sendCommand: vi.fn() as BridgeSendCommand, + subscribe: vi.fn(() => () => {}), + }); + + const registerDefinitionProvider = vi.mocked(monaco.languages.registerDefinitionProvider); + + bridge.attachModel({ + workspaceId: "ws-1", + workspaceRootPath: "/repo", + path: "src/App.vue", + monacoLanguage: "vue", + model: createMockModel( + "\n", + 1, + monaco.Uri.file("/repo/src/App.vue") + ), + }); + + expect(registerDefinitionProvider).toHaveBeenCalledWith( + "vue", + expect.objectContaining({ + provideDefinition: expect.any(Function), + }) + ); + }); + it("registers a link provider that resolves relative import specifiers to workspace files", async () => { const registerLinkProvider = vi.mocked(monaco.languages.registerLinkProvider); const requestDefinition = vi.fn(async () => [ @@ -98,6 +127,7 @@ describe("LSP providers", () => { requestHover: async () => null, requestReferences: async () => [], requestDocumentSymbols: async () => [], + requestSemanticTokens: async () => null, }); registry.register("typescript"); @@ -155,6 +185,138 @@ describe("LSP providers", () => { ); }); + it("registers semantic token providers and converts LSP token data for Monaco", async () => { + const registerDocumentSemanticTokensProvider = vi.mocked( + monaco.languages.registerDocumentSemanticTokensProvider + ); + const requestSemanticTokens = vi.fn(async () => ({ + resultId: "semantic-1", + data: [0, 13, 11, 8, 1], + })); + + const registry = createLspProviderRegistry({ + lookupModelMetadata: () => ({ + workspaceId: "ws-1", + workspaceRootPath: "/repo", + path: "src/main.go", + }), + requestDefinition: async () => [], + requestDeclaration: async () => [], + requestTypeDefinition: async () => [], + requestHover: async () => null, + requestReferences: async () => [], + requestDocumentSymbols: async () => [], + requestSemanticTokens, + }); + + registry.register("go"); + + expect(registerDocumentSemanticTokensProvider).toHaveBeenCalledWith( + "go", + expect.objectContaining({ + getLegend: expect.any(Function), + provideDocumentSemanticTokens: expect.any(Function), + releaseDocumentSemanticTokens: expect.any(Function), + }) + ); + + const provider = + registerDocumentSemanticTokensProvider.mock.calls[ + registerDocumentSemanticTokensProvider.mock.calls.length - 1 + ]![1]; + const model = createMockModel( + "package main\n\nfunc sharedValue() {}\n", + 1, + monaco.Uri.file("/repo/src/main.go") + ); + + expect(provider.getLegend().tokenTypes).toContain("variable"); + + const tokens = await provider.provideDocumentSemanticTokens(model, null, { + isCancellationRequested: false, + } as never); + + expect(requestSemanticTokens).toHaveBeenCalledWith({ + meta: { + workspaceId: "ws-1", + workspaceRootPath: "/repo", + path: "src/main.go", + }, + version: 1, + }); + expect(tokens).toEqual({ + resultId: "semantic-1", + data: new Uint32Array([0, 13, 11, 8, 1]), + }); + }); + + it("wires Monaco semantic token requests through the LSP bridge", async () => { + const registerDocumentSemanticTokensProvider = vi.mocked( + monaco.languages.registerDocumentSemanticTokensProvider + ); + const sendCommand = vi.fn(async (op) => { + if (op === "lsp.ensureSession") { + return { + kind: "ready", + displayName: "Rust language server", + source: "managed", + summary: { + workspaceId: "ws-1", + serverKind: "rust", + status: "ready", + capabilities: { + definition: true, + references: true, + hover: true, + documentSymbols: true, + semanticTokens: true, + diagnostics: true, + }, + }, + }; + } + + if (op === "lsp.semanticTokens") { + return { + resultId: "semantic-rust", + data: [0, 7, 5, 8, 0], + }; + } + + return null; + }) as BridgeSendCommand; + const bridge = createLspBridge({ + sendCommand, + subscribe: vi.fn(() => () => {}), + }); + const model = createMockModel("fn main() {}\n", 1, monaco.Uri.file("/repo/src/main.rs")); + + bridge.attachModel({ + workspaceId: "ws-1", + workspaceRootPath: "/repo", + path: "src/main.rs", + monacoLanguage: "rust", + model, + }); + + const provider = + registerDocumentSemanticTokensProvider.mock.calls[ + registerDocumentSemanticTokensProvider.mock.calls.length - 1 + ]![1]; + const tokens = await provider.provideDocumentSemanticTokens(model, null, { + isCancellationRequested: false, + } as never); + + expect(sendCommand).toHaveBeenCalledWith("lsp.semanticTokens", { + workspaceId: "ws-1", + path: "src/main.rs", + }); + expect(tokens).toEqual({ + resultId: "semantic-rust", + data: new Uint32Array([0, 7, 5, 8, 0]), + }); + }); + it("returns same-file definitions as Monaco locations", async () => { const bridge = createLspBridge({ sendCommand: vi.fn(async (op) => { diff --git a/packages/web/src/features/code-editor/lsp/providers.ts b/packages/web/src/features/code-editor/lsp/providers.ts index 0d4c00f8..ebaf8aae 100644 --- a/packages/web/src/features/code-editor/lsp/providers.ts +++ b/packages/web/src/features/code-editor/lsp/providers.ts @@ -1,7 +1,19 @@ -import type { LspDocumentSymbol, LspHoverResult, LspLocation } from "@coder-studio/core"; +import { + LSP_SEMANTIC_TOKEN_MODIFIERS, + LSP_SEMANTIC_TOKEN_TYPES, + type LspDocumentSymbol, + type LspHoverResult, + type LspLocation, + type LspSemanticTokens, +} from "@coder-studio/core"; import * as monaco from "monaco-editor"; import { toWorkspaceFileUri } from "../monaco/uri"; +const SEMANTIC_TOKENS_LEGEND: monaco.languages.SemanticTokensLegend = { + tokenTypes: [...LSP_SEMANTIC_TOKEN_TYPES], + tokenModifiers: [...LSP_SEMANTIC_TOKEN_MODIFIERS], +}; + export interface LspModelMetadata { workspaceId: string; workspaceRootPath: string; @@ -44,6 +56,10 @@ export interface LspProviderRegistryDeps { meta: LspModelMetadata; version: number; }) => Promise; + requestSemanticTokens: (input: { + meta: LspModelMetadata; + version: number; + }) => Promise; } export function createLspProviderRegistry(deps: LspProviderRegistryDeps) { @@ -74,6 +90,11 @@ export function createLspProviderRegistry(deps: LspProviderRegistryDeps) { monaco.languages.registerDocumentSymbolProvider(languageId, { provideDocumentSymbols, }); + monaco.languages.registerDocumentSemanticTokensProvider(languageId, { + getLegend, + provideDocumentSemanticTokens, + releaseDocumentSemanticTokens, + }); if (supportsImportSpecifierLinks(languageId)) { monaco.languages.registerLinkProvider?.(languageId, { provideLinks, @@ -244,6 +265,42 @@ export function createLspProviderRegistry(deps: LspProviderRegistryDeps) { return result.map(toMonacoSymbol); } + function getLegend(): monaco.languages.SemanticTokensLegend { + return SEMANTIC_TOKENS_LEGEND; + } + + async function provideDocumentSemanticTokens( + model: monaco.editor.ITextModel, + _lastResultId: string | null, + token: monaco.CancellationToken + ): Promise { + if (token.isCancellationRequested) { + return null; + } + + const meta = deps.lookupModelMetadata(model); + if (!meta) { + return null; + } + + const requestVersion = model.getVersionId(); + const result = await deps.requestSemanticTokens({ + meta, + version: requestVersion, + }); + + if (token.isCancellationRequested || !result || model.getVersionId() !== requestVersion) { + return null; + } + + return { + resultId: result.resultId, + data: new Uint32Array(result.data), + }; + } + + function releaseDocumentSemanticTokens(_resultId: string | undefined): void {} + async function provideLinks( model: monaco.editor.ITextModel ): Promise { @@ -306,6 +363,7 @@ export function createLspProviderRegistry(deps: LspProviderRegistryDeps) { provideHover, provideReferences, provideDocumentSymbols, + provideDocumentSemanticTokens, provideLinks, resolveLink, }; diff --git a/packages/web/src/features/code-editor/monaco/language-tokenization.test.ts b/packages/web/src/features/code-editor/monaco/language-tokenization.test.ts new file mode 100644 index 00000000..6433b13c --- /dev/null +++ b/packages/web/src/features/code-editor/monaco/language-tokenization.test.ts @@ -0,0 +1,45 @@ +import { beforeAll, describe, expect, it } from "vitest"; + +const samples = [ + { languageId: "python", source: "def main():\n return 1\n" }, + { languageId: "go", source: "package main\nfunc main() {}\n" }, + { languageId: "rust", source: "fn main() {\n let value = 1;\n}\n" }, + { + languageId: "vue", + source: '\n', + }, +] as const; + +let monaco: typeof import("monaco-editor"); +let ensureVueLanguageRegistered: typeof import("./vue-language").ensureVueLanguageRegistered; + +describe("Monaco language tokenization", () => { + it.each(samples)("tokenizes $languageId code with non-plaintext tokens", async ({ + languageId, + source, + }) => { + await monaco.editor.colorize(source, languageId, {}); + + const tokens = monaco.editor.tokenize(source, languageId).flat(); + + expect(tokens.some((token) => token.type && token.type !== "source")).toBe(true); + }); +}); + +beforeAll(async () => { + window.matchMedia ??= () => + ({ + matches: false, + media: "", + onchange: null, + addEventListener() {}, + removeEventListener() {}, + addListener() {}, + removeListener() {}, + dispatchEvent: () => false, + }) as MediaQueryList; + + monaco = await import("monaco-editor"); + ({ ensureVueLanguageRegistered } = await import("./vue-language")); + ensureVueLanguageRegistered(); +}, 30_000); diff --git a/packages/web/src/features/code-editor/monaco/vue-language.test.ts b/packages/web/src/features/code-editor/monaco/vue-language.test.ts new file mode 100644 index 00000000..165ceac4 --- /dev/null +++ b/packages/web/src/features/code-editor/monaco/vue-language.test.ts @@ -0,0 +1,133 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockRegisterLanguage, mockSetLanguageConfiguration, mockSetMonarchTokensProvider } = + vi.hoisted(() => ({ + mockRegisterLanguage: vi.fn(), + mockSetLanguageConfiguration: vi.fn(), + mockSetMonarchTokensProvider: vi.fn(), + })); + +const VUE_LANGUAGE_REGISTERED_KEY = Symbol.for("coder-studio.monaco.vue-language.registered"); + +vi.mock("monaco-editor", () => ({ + languages: { + register: mockRegisterLanguage, + setLanguageConfiguration: mockSetLanguageConfiguration, + setMonarchTokensProvider: mockSetMonarchTokensProvider, + IndentAction: { Indent: 1, IndentOutdent: 2 }, + }, +})); + +interface MonarchProvider { + tokenizer: { + root: Array; + templateBlock?: Array; + tagAttributes?: Array; + scriptTsEmbedded?: Array; + scriptJsEmbedded?: Array; + styleCssEmbedded?: Array; + styleScssEmbedded?: Array; + [state: string]: Array | undefined; + }; +} + +function getRegisteredProvider(): MonarchProvider { + const lastCall = mockSetMonarchTokensProvider.mock.calls.at(-1); + expect(lastCall?.[0]).toBe("vue"); + return lastCall?.[1] as MonarchProvider; +} + +function stringifyRules(rules: Array | undefined): string { + return rules + ? JSON.stringify(rules, (_, value) => (value instanceof RegExp ? value.source : value)) + : ""; +} + +describe("ensureVueLanguageRegistered", () => { + beforeEach(() => { + vi.resetModules(); + mockRegisterLanguage.mockClear(); + mockSetLanguageConfiguration.mockClear(); + mockSetMonarchTokensProvider.mockClear(); + delete (globalThis as Record)[VUE_LANGUAGE_REGISTERED_KEY]; + }); + + it("registers the vue language exactly once", async () => { + const monaco = await import("monaco-editor"); + const { ensureVueLanguageRegistered } = await import("./vue-language"); + + ensureVueLanguageRegistered(); + ensureVueLanguageRegistered(); + + expect(monaco.languages.register).toHaveBeenCalledWith({ id: "vue" }); + expect(monaco.languages.register).toHaveBeenCalledTimes(1); + expect(monaco.languages.setLanguageConfiguration).toHaveBeenCalledWith( + "vue", + expect.objectContaining({ + comments: { blockComment: [""] }, + brackets: expect.arrayContaining([ + ["<", ">"], + ["{", "}"], + ]), + autoClosingPairs: expect.arrayContaining([{ open: "{", close: "}" }]), + }) + ); + expect(monaco.languages.setMonarchTokensProvider).toHaveBeenCalledWith( + "vue", + expect.any(Object) + ); + }); + + it("delegates + + diff --git a/scripts/probe-fixtures/tsconfig.json b/scripts/probe-fixtures/tsconfig.json new file mode 100644 index 00000000..14980239 --- /dev/null +++ b/scripts/probe-fixtures/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "jsx": "preserve", + "lib": ["ESNext", "DOM"], + "types": ["vue/types"], + "allowImportingTsExtensions": false, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["**/*.vue", "**/*.ts"] +} diff --git a/scripts/probe-rust.mjs b/scripts/probe-rust.mjs new file mode 100644 index 00000000..c1f77c2a --- /dev/null +++ b/scripts/probe-rust.mjs @@ -0,0 +1,160 @@ +#!/usr/bin/env node +// Quick LSP probe for rust-analyzer against `lsp-test/probe.rs` (or any path +// passed on argv). Spawns rust-analyzer directly, sends initialize + +// didOpen, then dumps the response shape for hover at a few canonical +// positions. Useful for verifying the protocol contract without going +// through the coder-studio LSP layer. +// +// Usage: node scripts/probe-rust.mjs [path/to/file.rs] + +import { spawn } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +const require = createRequire( + pathToFileURL(join(process.cwd(), "packages", "server", "package.json")).toString() +); +const { + createMessageConnection, + StreamMessageReader, + StreamMessageWriter, +} = require("vscode-jsonrpc/node.js"); + +const RUST_ANALYZER = process.env.RUST_ANALYZER ?? "rust-analyzer"; +const sample = resolve(process.argv[2] ?? "lsp-test/probe.rs"); +const text = readFileSync(sample, "utf8"); +const uri = pathToFileURL(sample).toString(); +const rootDir = process.cwd(); + +console.log("rust-analyzer:", RUST_ANALYZER); +console.log("sample: ", sample); +console.log("rootDir: ", rootDir); + +const child = spawn(RUST_ANALYZER, [], { + stdio: ["pipe", "pipe", "pipe"], + shell: false, + windowsHide: true, +}); +child.stderr.on("data", (b) => process.stderr.write("[ra stderr] " + b.toString())); +child.on("exit", (code) => console.log("[ra] exit:", code)); + +const conn = createMessageConnection( + new StreamMessageReader(child.stdout), + new StreamMessageWriter(child.stdin) +); +conn.onUnhandledNotification((n) => + console.log("<- notification:", n.method, JSON.stringify(n.params).slice(0, 160)) +); +conn.listen(); + +(async () => { + try { + const tInit = Date.now(); + console.log("-> initialize"); + const init = await Promise.race([ + conn.sendRequest("initialize", { + processId: process.pid, + rootUri: pathToFileURL(rootDir).toString(), + workspaceFolders: [{ uri: pathToFileURL(rootDir).toString(), name: "probe-ws" }], + capabilities: {}, + initializationOptions: {}, + }), + new Promise((_, r) => setTimeout(() => r(new Error("init timeout 30s")), 30000)), + ]); + console.log( + "initialize returned in", + Date.now() - tInit, + "ms, hoverProvider:", + !!init?.capabilities?.hoverProvider + ); + conn.sendNotification("initialized", {}); + + console.log("-> didOpen", uri); + conn.sendNotification("textDocument/didOpen", { + textDocument: { uri, languageId: "rust", version: 1, text }, + }); + + // Reproduce the user's bug: hover *immediately*, before indexing is done. + // With a tight timeout this should fail to return anything within budget. + const tEarly = Date.now(); + try { + const early = await Promise.race([ + conn.sendRequest("textDocument/hover", { + textDocument: { uri }, + position: { line: 16, character: 5 }, + }), + new Promise((_, rj) => setTimeout(() => rj(new Error("early hover timeout 8s")), 8000)), + ]); + console.log( + `early hover after ${Date.now() - tEarly}ms:`, + JSON.stringify(early, null, 0).slice(0, 160) + ); + } catch (e) { + console.log(`early hover failed after ${Date.now() - tEarly}ms:`, e.message); + } + + // Wait for rust-analyzer to load (cold start can be slow). Wait either + // for a "Loading: " progress notification ending or a fixed timeout. + let loadDone = false; + conn.onUnhandledNotification?.((n) => { + if (n.method === "$/progress") { + const v = n.params?.value ?? {}; + if (v.kind === "end") loadDone = true; + console.log("[progress]", v.kind ?? "?", v.title ?? "", v.message ?? ""); + } + }); + const start = Date.now(); + while (!loadDone && Date.now() - start < 25_000) { + await new Promise((r) => setTimeout(r, 500)); + } + console.log("ready in", Date.now() - start, "ms"); + + // Find positions of interest — `anchor` is the substring whose middle we + // want to land on (so we don't hover the leading keyword). + const lines = text.split(/\r?\n/); + async function hoverAt(label, lineFragment, anchor) { + let line = -1, + ch = 0; + for (let i = 0; i < lines.length; i++) { + const lineIdx = lines[i].indexOf(lineFragment); + if (lineIdx >= 0) { + line = i; + const anchorIdx = lines[i].indexOf(anchor, lineIdx); + ch = anchorIdx + Math.floor(anchor.length / 2); + break; + } + } + if (line < 0) { + console.log(`hover[${label}] - line fragment not found: '${lineFragment}'`); + return; + } + const r = await Promise.race([ + conn.sendRequest("textDocument/hover", { + textDocument: { uri }, + position: { line, character: ch }, + }), + new Promise((_, rj) => setTimeout(() => rj(new Error(label + " timeout")), 15000)), + ]).catch((e) => ({ __error: e.message })); + console.log( + `hover[${label}] L${line + 1}:${ch + 1}:`, + JSON.stringify(r, null, 0).slice(0, 300) + ); + } + + await hoverAt("fn-multiply_by-decl", "fn multiply_by", "multiply_by"); + await hoverAt("fn-multiply_by-call", "multiply_by(*n,", "multiply_by"); + await hoverAt("var-total", "let mut total", "total"); + await hoverAt("struct-Greeter", "struct Greeter", "Greeter"); + await hoverAt("method-greet", "fn greet(&self)", "greet"); + } catch (e) { + console.error("PROBE FAILED:", e.message); + } finally { + try { + await conn.sendRequest("shutdown", null); + } catch {} + child.kill(); + setTimeout(() => process.exit(0), 200).unref?.(); + } +})(); diff --git a/scripts/probe-vue-bridge.mjs b/scripts/probe-vue-bridge.mjs new file mode 100644 index 00000000..8c1c95e3 --- /dev/null +++ b/scripts/probe-vue-bridge.mjs @@ -0,0 +1,340 @@ +#!/usr/bin/env node +// Probe the Vue + tsserver bridge end-to-end against a real Volar + TS server. +// Usage: node scripts/probe-vue-bridge.mjs [path/to/some.vue] +// +// Spawns @vue/language-server (managed install) and typescript-language-server +// (bundled), initializes both with the same payloads our LspSession uses, opens +// a .vue document on Volar, and asks Volar for hover at a specific position. +// Bridges tsserver/request <-> workspace/executeCommand inline so we can print +// each step of the round-trip. + +import { spawn } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +// Resolve from packages/server which has vscode-jsonrpc in its node_modules. +const require = createRequire( + pathToFileURL(join(process.cwd(), "packages", "server", "package.json")).toString() +); +const jsonrpc = require("vscode-jsonrpc/node.js"); +const { createMessageConnection, StreamMessageReader, StreamMessageWriter } = jsonrpc; + +const STATE_DIR = process.env.STATE_DIR ?? join(tmpdir(), "coder-studio-dev"); +const VUE_INSTALL_ROOT = join(STATE_DIR, "lsp-tools", "vue", "3.3.2-typescript-6.0.3"); +const VUE_BIN = + process.platform === "win32" + ? join(VUE_INSTALL_ROOT, "node_modules", ".bin", "vue-language-server.cmd") + : join(VUE_INSTALL_ROOT, "node_modules", ".bin", "vue-language-server"); +const VUE_PKG = join(VUE_INSTALL_ROOT, "node_modules", "@vue", "language-server"); +const TSDK = join(VUE_INSTALL_ROOT, "node_modules", "typescript", "lib"); + +const TSLS_CLI = require.resolve("typescript-language-server/lib/cli.mjs", { + paths: [join(process.cwd(), "packages", "server"), process.cwd()], +}); + +const sample = process.argv[2] ? resolve(process.argv[2]) : writeSample(); + +const sampleText = readFileSync(sample, "utf8"); +const sampleUri = pathToFileURL(sample).toString(); +const rootDir = dirname(sample); +const rootUri = pathToFileURL(rootDir).toString(); + +console.log("paths:"); +console.log(" vue bin: ", VUE_BIN); +console.log(" vue install: ", VUE_PKG); +console.log(" tsdk: ", TSDK); +console.log(" tsls cli: ", TSLS_CLI); +console.log(" sample: ", sample); +console.log(" sample exists? ", existsSync(sample)); +console.log(" vue bin exists? ", existsSync(VUE_BIN)); +console.log(); + +if (!existsSync(VUE_BIN)) { + console.error("Vue bin missing; run the app once so it installs Volar."); + process.exit(1); +} + +const volar = spawn(VUE_BIN, ["--stdio"], { + cwd: rootDir, + stdio: ["pipe", "pipe", "pipe"], + shell: process.platform === "win32", + windowsHide: true, +}); +const tsls = spawn(process.execPath, [TSLS_CLI, "--stdio"], { + cwd: rootDir, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, +}); + +volar.stderr.on("data", (b) => process.stderr.write("[volar stderr] " + b.toString())); +tsls.stderr.on("data", (b) => process.stderr.write("[tsls stderr] " + b.toString())); +volar.on("exit", (code) => console.log("[volar] exit code:", code)); +tsls.on("exit", (code) => console.log("[tsls] exit code:", code)); + +const volarConn = createMessageConnection( + new StreamMessageReader(volar.stdout), + new StreamMessageWriter(volar.stdin) +); +const tslsConn = createMessageConnection( + new StreamMessageReader(tsls.stdout), + new StreamMessageWriter(tsls.stdin) +); + +volarConn.onUnhandledNotification((n) => + console.log("[volar->] unhandled notification:", n.method, JSON.stringify(n.params).slice(0, 200)) +); +tslsConn.onUnhandledNotification((n) => + console.log("[tsls->] unhandled notification:", n.method, JSON.stringify(n.params).slice(0, 200)) +); + +function unwrap(raw) { + if (raw === null || raw === undefined) return null; + if (typeof raw !== "object") return raw; + if (!("body" in raw) && raw.type !== "response") return raw; + if (raw.success === false) return null; + return raw.body ?? null; +} + +// Bridge tsserver/request -> workspace/executeCommand on tsls +volarConn.onNotification("tsserver/request", async (payload) => { + if (!Array.isArray(payload) || payload.length < 2) { + console.log("[bridge] malformed tsserver/request payload:", payload); + return; + } + const [id, command, args] = payload; + console.log("[bridge] tsserver/request id=", id, "command=", command); + try { + const raw = await Promise.race([ + tslsConn.sendRequest("workspace/executeCommand", { + command: "typescript.tsserverRequest", + arguments: [command, args], + }), + new Promise((_, reject) => setTimeout(() => reject(new Error("bridge timeout")), 8000)), + ]); + const unwrapped = unwrap(raw); + console.log("[bridge] tsserver response (unwrapped):", trim(unwrapped)); + volarConn.sendNotification("tsserver/response", [id, unwrapped]); + } catch (e) { + console.log("[bridge] tsserver request failed:", e.message); + volarConn.sendNotification("tsserver/response", [id, null]); + } +}); + +volarConn.listen(); +tslsConn.listen(); + +const VUE_INIT_OPTIONS = { typescript: { tsdk: TSDK } }; +// Override location with PROBE_LOCATION env if set, so we can try alternative +// paths without editing the file. +const LOCATION = process.env.PROBE_LOCATION ?? VUE_PKG; +console.log("plugin location:", LOCATION); +const TSLS_INIT_OPTIONS = { + plugins: [ + { + name: "@vue/typescript-plugin", + location: LOCATION, + languages: ["vue"], + configNamespace: "typescript", + }, + ], + tsserver: { + logVerbosity: "verbose", + logDirectory: process.env.TSSERVER_LOG_DIR ?? join(tmpdir(), "tsserver-probe-logs"), + trace: "verbose", + }, +}; + +const initParams = { + processId: process.pid, + rootUri, + workspaceFolders: [{ uri: rootUri, name: "probe-workspace" }], + capabilities: {}, +}; + +(async () => { + try { + console.log("-> initialize both servers in parallel"); + const [vInit, tInit] = await Promise.all([ + volarConn.sendRequest("initialize", { + ...initParams, + initializationOptions: VUE_INIT_OPTIONS, + }), + tslsConn.sendRequest("initialize", { + ...initParams, + initializationOptions: TSLS_INIT_OPTIONS, + }), + ]); + console.log("volar capabilities.hoverProvider:", !!vInit?.capabilities?.hoverProvider); + console.log( + "tsls capabilities.executeCommandProvider:", + trim(tInit?.capabilities?.executeCommandProvider) + ); + + volarConn.sendNotification("initialized", {}); + tslsConn.sendNotification("initialized", {}); + + console.log("-> didOpen on both ends"); + volarConn.sendNotification("textDocument/didOpen", { + textDocument: { + uri: sampleUri, + languageId: "vue", + version: 1, + text: sampleText, + }, + }); + tslsConn.sendNotification("textDocument/didOpen", { + textDocument: { + uri: sampleUri, + languageId: "vue", + version: 1, + text: sampleText, + }, + }); + + // Wait longer so tsserver fully boots and indexes the plugin. + await new Promise((r) => setTimeout(r, 3500)); + + const lines = sampleText.split(/\r?\n/); + async function probeAt(label, target) { + let line = 0; + let char = 0; + for (let i = 0; i < lines.length; i++) { + const idx = lines[i].indexOf(target); + if (idx >= 0) { + line = i; + char = idx + Math.max(0, Math.floor(target.length / 2)); + break; + } + } + console.log( + `\n>>> ${label} at L${line + 1}:${char + 1} >> '${lines[line]?.slice(Math.max(0, char - 3), char + target.length + 3)}'` + ); + const position = { line, character: char }; + + // Fan out: ask Volar and TSLS in parallel, then merge as the real + // LspSession does. This mirrors what coder-studio's server does today + // and is the actual user-visible behavior. + const tasks = [ + Promise.race([ + volarConn.sendRequest("textDocument/hover", { + textDocument: { uri: sampleUri }, + position, + }), + new Promise((_, reject) => + setTimeout(() => reject(new Error("volar hover timeout")), 8000) + ), + ]).catch((e) => ({ __error: e.message })), + Promise.race([ + tslsConn.sendRequest("textDocument/hover", { + textDocument: { uri: sampleUri }, + position, + }), + new Promise((_, reject) => + setTimeout(() => reject(new Error("tsls hover timeout")), 8000) + ), + ]).catch((e) => ({ __error: e.message })), + ]; + const [vh, th] = await Promise.all(tasks); + console.log(`hover[${label}] volar :`, JSON.stringify(vh, null, 0)); + console.log(`hover[${label}] tsls :`, JSON.stringify(th, null, 0)); + const mergedContents = []; + for (const r of [vh, th]) { + if (r && !r.__error && r?.contents) { + if (typeof r.contents === "string") mergedContents.push(r.contents); + else if (typeof r.contents?.value === "string") mergedContents.push(r.contents.value); + else if (Array.isArray(r.contents)) + for (const c of r.contents) { + if (typeof c === "string") mergedContents.push(c); + else if (typeof c?.value === "string") mergedContents.push(c.value); + } + } + } + console.log(`MERGED[${label}]:`, mergedContents.length ? mergedContents : "(empty)"); + } + + async function probeTslsHoverAt(label, target) { + let line = 0; + let char = 0; + for (let i = 0; i < lines.length; i++) { + const idx = lines[i].indexOf(target); + if (idx >= 0) { + line = i; + char = idx + Math.max(0, Math.floor(target.length / 2)); + break; + } + } + try { + const hover = await Promise.race([ + tslsConn.sendRequest("textDocument/hover", { + textDocument: { uri: sampleUri }, + position: { line, character: char }, + }), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`tsls hover timeout`)), 8000) + ), + ]); + console.log(`tslsHover[${label}]:`, JSON.stringify(hover, null, 0)); + } catch (e) { + console.log(`tslsHover[${label}] failed:`, e.message); + } + } + + await probeAt("count-decl", "const count"); + await probeTslsHoverAt("count-decl", "const count"); + await probeAt("ref-import", "ref, computed"); + await probeTslsHoverAt("ref-import", "ref, computed"); + await probeAt("count-usage-in-template", "{{ count"); + await probeTslsHoverAt("count-usage-in-template", "{{ count"); + + // Inspect document symbols to confirm Volar parses the SFC at all. + try { + const symbols = await volarConn.sendRequest("textDocument/documentSymbol", { + textDocument: { uri: sampleUri }, + }); + console.log("\ndocumentSymbols:", trim(symbols)); + } catch (e) { + console.log("documentSymbol failed:", e.message); + } + } catch (e) { + console.error("PROBE FAILED:", e.message); + } finally { + console.log("-> shutting down"); + try { + await volarConn.sendRequest("shutdown", null); + } catch {} + try { + await tslsConn.sendRequest("shutdown", null); + } catch {} + volar.kill(); + tsls.kill(); + setTimeout(() => process.exit(0), 500).unref?.(); + } +})(); + +function trim(value) { + const s = JSON.stringify(value); + return s == null ? String(value) : s.length > 240 ? s.slice(0, 240) + "..." : s; +} + +function writeSample() { + const path = join(tmpdir(), "probe-vue-bridge-sample.vue"); + const content = ` + + +`; + if (!existsSync(path)) { + const fs = require("node:fs"); + fs.writeFileSync(path, content); + } + return path; +}