diff --git a/docs/design/2026-07-16-webshell-git-status-diff.md b/docs/design/2026-07-16-webshell-git-status-diff.md new file mode 100644 index 00000000000..52b2e098cfe --- /dev/null +++ b/docs/design/2026-07-16-webshell-git-status-diff.md @@ -0,0 +1,895 @@ +# Web Shell git 状态感知与可视化 diff + +## 背景 + +当 workspace 是一个 git 仓库时,Web Shell 目前的 git 集成非常薄,只有两处: + +- 工具栏里的 branch chip(`GitBranchIndicator`),只显示当前分支名。数据来自 + daemon 的 `WorkspaceGitState`,它只追踪 `branch` 一个字段。 +- `/diff` 斜杠命令。在 Web Shell 中它是 ACP 透传,daemon 走非交互路径返回一段 + 纯文本统计(`diffCommand.ts` 的 `renderDiffModelText`)。 + +这意味着用户想确认“工作区干不干净”“有没有 commit 没推”“agent 到底改了哪些行” +时,要么只能看到一个分支名,要么只能读一段终端文本——而 Web Shell 是图形界面, +本应做得比终端更好。 + +core 里其实已经有完整的 git 能力可以复用: + +- `gitDirect.ts`:`resolveBranchName`(直读 `.git/HEAD`,微秒级)、 + `watchRepoBranch`(监听 `/logs/HEAD` reflog)、`readGitHead` + (区分 branch / detached)。 +- `gitDiff.ts`:`fetchGitDiff`(工作区 vs HEAD 的 per-file 统计)、 + `fetchGitDiffHunks`(`Map` 行级 hunk)、`GitDiffResult` / + `PerFileStats` / `GitDiffStats` 类型,以及一组成熟的上限 + (`MAX_FILES=50`、`MAX_DIFF_SIZE_BYTES=1MB`、`MAX_LINES_PER_FILE=400`)和 + transient state(merge/rebase/cherry-pick/revert)检测。 +- `gitUtils.ts`:`getRecentGitStatus` 已经用 `git status --short --branch` + 一次拿到 branch + short status + 最近 5 条 commit,并解析了 branch header。 + +本期目标是在不改变 agent 行为的前提下,把这些已有能力接到 Web Shell UI 上, +分两层落地:第一层增强状态感知(branch chip 旁边显示 dirty / ahead-behind / +stash / detached),第二层提供一个浏览器里的可视化 diff 查看器。 + +## 目标 + +- branch chip 在不打开任何弹窗的情况下,能一眼看出工作区是否干净、相对 + upstream 的 ahead/behind、是否有 stash、是否处于 detached HEAD。 +- 提供一个图形化 diff 查看器:变更文件列表 + 点击展开的单文件行级 diff,复用 + Shiki 做语法高亮。 +- 复用 core 已有的 `fetchGitDiff` / `fetchGitDiffHunks` / `resolveBranchName` + 等能力,不在 Web Shell 里重新实现 git 解析。 +- 兼容旧 daemon、旧 client、非 git 仓库、transient state、detached HEAD 等 + 边界状态,缺失数据时优雅降级而不是报错或空白。 +- 只读优先:所有展示能力都是只读的,不引入任何会改变仓库状态的写操作。 + +## 非目标 + +- 不做提交工作流(stage / commit / 生成 commit message)。属于后续增量。 +- 不做分支管理(切换 / 新建 / 删除分支)。 +- 不做 GitHub 集成(PR / issue / CI checks)。属于后续增量。 +- 不做远程同步(fetch / pull / push)。 +- 不监听整个工作区文件树来实时刷新 dirty 状态。dirty 状态在用户编辑文件后 + 不会逐键实时更新(见“刷新策略”),这是有意的成本取舍。 +- 不改变 `/diff` 在非 Web Shell 客户端(管道、日志、远程 transport)的纯文本 + 输出;那些路径继续走 daemon 的 `renderDiffModelText`。 +- 不为 untracked 文件合成行级 hunk(本期 untracked 只显示“新文件 + 行数”, + 与 CLI `DiffDialog` 行为一致)。 + +## 现状链路 + +### 数据来源(core) + +- `resolveBranchName(cwd)`:直读 `.git/HEAD`,返回分支名或 detached 时的短 + SHA;非仓库返回 `undefined`。微秒级,可放在渲染热路径。 +- `watchRepoBranch(cwd, onChange)`:多个订阅者共享一个对 + `/logs/HEAD` 的 `fs.watch`,在 branch 切换 / commit / reset 时触发。 + 它**不会**因为编辑工作区文件而触发(编辑不写 reflog)。 +- `readGitHead(gitDir)`:返回 `{ type: 'branch' | 'detached', name }`,可用于 + 判断 detached。 +- `fetchGitDiff(cwd)`:返回 `GitDiffResult { stats, perFileStats }`,比较工作区 + 与 HEAD;transient state 或非仓库返回 `null`。 +- `fetchGitDiffHunks(cwd)`:返回 `Map`,内部执行 + `git diff HEAD`。注意 untracked 文件不会出现在 `git diff HEAD` 输出里。 + +### daemon + +- `WorkspaceGitState`(`packages/cli/src/serve/workspace-git-state.ts`): + 每个 workspace 一个 entry,缓存 `branch`,用 `watchRepoBranch` 监听变化, + 变化时通过 `bridge.publishWorkspaceEvent({ type: 'git_branch_changed', ... })` + 推送。`getStatus()` 当前返回 `{ v: 1, workspaceCwd, branch }`。 +- 路由(`packages/cli/src/serve/routes/workspace-git.ts`): + `GET /workspace/git`(绑定 workspace)和 + `GET /workspaces/:workspace/git`(带 qualified workspace 参数,需 trusted + runtime)。 +- `/diff` 命令(`packages/cli/src/ui/commands/diffCommand.ts`):交互模式打开 + Ink 的 `DiffDialog`;非交互 / ACP 返回 `fetchGitDiff` + `buildDiffRenderModel` + - `renderDiffModelText` 的纯文本。 + +### SDK / webui + +- `DaemonWorkspaceGitStatus` 类型 + `DaemonClient.workspaceGit()` + (`GET /workspace/git`)。 +- 事件 `git_branch_changed` 在 `sdk-typescript/src/daemon/events.ts` 注册。 +- `webui/src/daemon/session/mappers.ts` 把 `git_branch_changed` 映射到 + `connection.gitBranch`(并用 `workspaceCwd` 做了归属校验)。 + +### Web Shell + +- `GitBranchIndicator.tsx`:纯展示 chip(branch 名 + tooltip)。 +- `ChatEditor.tsx`:把 `gitBranch` 作为一个 toolbar action 渲染 + (`gitBranchVisible`、compact / expanded 两种形态)。 +- `App.tsx`: + - `connection.gitBranch`(来自 SSE `git_branch_changed`)驱动会话内的 chip。 + - `selectedWorkspaceGitBranch`:在选择 workspace 但**尚未连接 session** 时, + 通过 `workspace.client.workspaceByCwd(cwd).workspaceGit()` 拉取一次分支做 + 预览。 + - `activePanel` 机制统一管理各类弹窗(settings / status / sessions / + extensions / plugins 等),`components/dialogs/*.tsx` + 同名 + `.module.css` 是标准弹窗形态。 +- `customization.tsx`:markdown 代码块用 Shiki 高亮 + (`WebShellCodeBlockRenderInfo.resolvedLanguage` 是规范化后的 Shiki language + id),可作为 diff 行内语法高亮的复用基础。 +- `constants/localCommands.ts`:`getLocalCommands(t)` 定义本地斜杠命令补全。 + +## 方案概述 + +整体数据流沿用现有 branch chip 的形态,向下扩展 core / daemon / SDK,向上扩展 +Web Shell 组件: + +```text +core (gitDirect + gitDiff) + ├─ getGitWorkingTreeStatus(cwd) [新增] dirty / ahead / behind / stash / detached + ├─ fetchGitDiff(cwd) [复用] 文件列表 + 统计 + └─ fetchGitDiffHunksForFile(cwd, path) [新增] 单文件行级 hunk + │ + ▼ +daemon (serve) + ├─ WorkspaceGitState.getStatus() [扩展] 返回 enriched status + ├─ GET /workspace/git [扩展] 携带 enriched 字段 + ├─ GET /workspace/git/diff [新增] 文件列表 + 统计 + └─ GET /workspace/git/diff/file [新增] 单文件 hunk(按需) + │ + ▼ +SDK (DaemonClient + types + events) + ├─ DaemonWorkspaceGitStatus [扩展] 新字段(可选,向后兼容) + ├─ DaemonWorkspaceGitDiff / ...File [新增] + ├─ workspaceGitDiff() / workspaceGitDiffFile(path) [新增] + └─ git_status_changed [新增事件,可选] + │ + ▼ +webui (mappers) + └─ git_status_changed → connection.gitStatus [新增] + │ + ▼ +Web Shell (client) + ├─ GitBranchIndicator [扩展] dirty 点 / ahead-behind / stash / detached + └─ GitDiffDialog [新增] 文件列表 + 单文件行级 diff(Shiki 高亮) +``` + +两层共享同一组 daemon git 路由:第一层用 `GET /workspace/git`(enriched), +第二层用 `GET /workspace/git/diff` 与 `.../diff/file`。dirty 指示点可点击, +点击直接打开 `GitDiffDialog`,把两层串起来。 + +## UI 草图(Before / After) + +落地后页面只变两处:输入框工具栏的 branch chip(第一层)和一个新的 Changes +弹窗(第二层)。 + +### 第一层:branch chip + +chip 仍在输入框左下角工具栏(位置不变),信息更丰富,有 compact / expanded +两种形态(工具栏空间够时自动展开,沿用现有 toolbar 测量逻辑)。 + +现在(只有分支名): + +```text +┌─────────────────────────────────────────────┐ +│ [⑂ main] [@ ⏎ 发送] │ ← 输入框工具栏 +└─────────────────────────────────────────────┘ +``` + +之后 · compact(空间不够时): + +```text + [⑂ main •] + └─ dirty 小圆点:有未提交改动时出现 +``` + +之后 · expanded(空间够时,显示完整状态): + +```text + [⑂ main • ↑2 ↓1 ⧉3] + │ │ │ │ └─ stash 数量(3 个 stash) + │ │ │ └───── behind:落后 upstream 1 个 commit + │ │ └───────── ahead:领先 upstream 2 个 commit + │ └──────────── dirty 点 + └──────────────── 分支名 +``` + +几种特殊状态: + +```text + [⑂ main] 干净时没有 dirty 点 + [⑂ a1b2c3d ⚠] detached HEAD:chip 变警告色,显示短 SHA + [⑂ feature ↑2] 无 upstream 时不显示 ↓,只有 ahead +``` + +交互: + +- 悬停 → tooltip 显示完整说明(如 `main · 3 个未提交改动 · 领先 2 / 落后 1`)。 +- 点击 dirty 点 → 直接打开第二层的 Changes 弹窗。 + +### 第二层:Changes 弹窗 + +点 chip 或输入 `/diff` 打开。它是一个覆盖整个聊天区的浮层(与 `/status`、 +`/settings` 同一种 `activePanel` 形态,约 70vh 可滚动): + +```text +┌─ Changes ───────────────────────────── vs HEAD ─ ✕ ┐ +│ │ +│ 4 files changed, +128 / -37 │ ← 汇总 header +│ │ +│ +12 -3 src/services/foo.ts │ ← 文件行(点开前) +│ +88 -0 src/components/Bar.tsx (new) │ +│ ~ assets/logo.png (binary) │ +│ +28 -34 legacy/old.ts (deleted) │ +│ │ +│ ▼ src/services/foo.ts │ ← 点开后:行级 diff +│ ┌──────────────────────────────────────────────────┐ │ +│ │ 11 const config = load(); │ │ ← 上下文行(灰) +│ │ 12 - const timeout = 2000; │ │ ← 删除行(红底) +│ │ 12 + const timeout = 5000; │ │ ← 新增行(绿底) +│ │ 13 + const retries = 3; │ │ +│ │ 14 export { config, timeout }; │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ +│ …and 2 more (showing first 50) │ ← 超上限时的截断提示 +└──────────────────────────────────────────────────────┘ +``` + +交互细节: + +- 文件行默认折叠,只显示 `+A -R 路径` 和标记(`(new)` / `(binary)` / + `(deleted)`)。 +- 点文件行 → 按需拉该文件的 hunk 并展开(不一次性加载全部,省流量)。 +- 行级 diff 用 Shiki 语法高亮(按扩展名识别语言),`+` / `-` 行分别绿 / 红底 + 着色,与现有 markdown 代码块同一套高亮器。 +- `(binary)` / `(new)` 文件不可展开(无 hunk),与 CLI `DiffDialog` 行为一致。 +- 非 git 仓库 / merge 进行中 → 弹窗显示占位文案(如“当前不是 git 仓库,或正在 + merge / rebase”),不报错。 + +## 数据结构 + +### 第一层:enriched git status + +扩展 `WorkspaceGitStatus`(daemon 端)与 `DaemonWorkspaceGitStatus`(SDK 端)。 +新字段全部可选,`v` 升到 `2`,保证旧 daemon / 旧 client 互相兼容: + +```ts +interface DaemonWorkspaceGitStatus { + v: 1 | 2; + workspaceCwd: string; + branch: string | null; + + // —— v2 新增字段,全部可选 —— + /** true 表示 detached HEAD(branch 此时为短 SHA)。 */ + detached?: boolean; + /** 已暂存文件数(porcelain X 列非 '.')。 */ + staged?: number; + /** 已修改未暂存文件数(porcelain Y 列非 '.')。 */ + unstaged?: number; + /** 未跟踪文件数('??')。 */ + untracked?: number; + /** 冲突(unmerged)文件数。 */ + conflicted?: number; + /** 是否配置了 upstream。 */ + hasUpstream?: boolean; + /** 领先 upstream 的 commit 数;仅当 `hasUpstream` 为 true 时有意义,无 + * upstream 时为 0(此时 UI 不显示 ↑N)。 */ + ahead?: number; + /** 落后 upstream 的 commit 数;同 `ahead`,仅有 upstream 时有意义。 */ + behind?: number; + /** stash 数量。 */ + stashCount?: number; + /** 进行中的操作(merge/rebase/cherry-pick/revert/bisect)。 */ + operation?: 'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect'; + /** 重字段(dirty/ahead/behind/stash)的计算时间戳(epoch ms),用于新鲜度判断。 */ + computedAt?: number; +} +``` + +派生信号(前端计算,不进 wire format):`dirty = staged + unstaged + untracked > 0`。 + +> **transient state 处理变更**:早期设计让 `getGitWorkingTreeStatus` 在 +> merge/rebase 期间返回 `null`。Phase 1 决定把"进行中操作"显式 surfaced,因此 +> 改为:transient 期间仍返回状态,并通过 `operation` 字段标记操作类型 +> (`git status` 在这些状态下仍能正常输出)。返回 `null` 只保留给"非仓库 / +> git 失败"。`fetchGitDiff`(第二层)仍在 transient 时返回 null,二者语义不同。 + +### 第二层:diff payload + +文件列表与单文件 hunk 分两个路由,避免一次性把多文件 diff(最坏 +`MAX_FILES × MAX_DIFF_SIZE_BYTES`)塞进单个响应: + +```ts +interface DaemonWorkspaceGitDiffFile { + /** 仓库根相对路径,未净化,渲染前必须 sanitize。 */ + path: string; + /** 二进制文件为 undefined。 */ + added?: number; + removed?: number; + isBinary: boolean; + isUntracked: boolean; + isDeleted: boolean; + /** untracked 文本文件超过读取上限时为 true(added 为下界)。 */ + truncated: boolean; +} + +interface DaemonWorkspaceGitDiff { + v: 1; + workspaceCwd: string; + /** false 表示非仓库 / HEAD 缺失 / transient state,前端显示占位。 */ + available: boolean; + filesCount: number; + linesAdded: number; + linesRemoved: number; + files: DaemonWorkspaceGitDiffFile[]; + /** filesCount - files.length,per-file 上限截断时的剩余数。 */ + hiddenCount: number; +} + +/** 与 `diff` 库的 Hunk 字段对齐,序列化后传输。 */ +interface DaemonDiffHunk { + oldStart: number; + oldLines: number; + newStart: number; + newLines: number; + lines: string[]; // 带 ' ' / '+' / '-' 前缀 +} + +interface DaemonWorkspaceGitDiffHunks { + v: 1; + workspaceCwd: string; + path: string; + available: boolean; // false: 该文件无 hunk(untracked / 无变化 / 越界) + hunks: DaemonDiffHunk[]; +} +``` + +`DaemonDiffHunk` 直接对应 core 的 `GitDiffHunk`(即 `diff` 库的 `Hunk`), +daemon 端只需把 `Map` 里对应文件的 hunk 数组序列化即可,前端不需要依赖 `diff` +库。 + +## 关键修改点 + +### 1. core:新增工作区状态与单文件 hunk + +两个函数都放在 `packages/core/src/utils/gitDiff.ts`,以便直接复用该文件内已有 +的 `findGitRoot`、`isInTransientGitState`(当前未导出)、`parseGitDiff` 等私有 +/ 公有构件,避免跨文件暴露内部函数: + +- `getGitWorkingTreeStatus(cwd): Promise` + - 复用 `findGitRoot` 判断是否仓库;非仓库 / git 失败返回 `null`。transient + state(merge/rebase/cherry-pick/…)期间仍返回状态,并通过 `operation` + 字段标记操作类型(见上方"transient state 处理变更"),故不调用 + `isInTransientGitState`。 + - 一次 `git --no-optional-locks status --porcelain=v1 --branch -z` 调用, + 解析 branch header(branch / detached / `...upstream` / `[ahead N, behind +M]`)和 porcelain 行(统计 staged / unstaged / untracked)。解析逻辑可参考 + `getRecentGitStatus` 已有的 branch header 处理。 + - stash 数量:优先直读 `/logs/refs/stash` 行数(与 `gitDirect.ts` + 的直读哲学一致,避免第二个子进程);读不到则记 0。 + - detached 由 `readGitHead` 或 branch header(`HEAD (no branch)` / + `No commits yet`)判定。 + - 返回结构对齐 `DaemonWorkspaceGitStatus` 的 v2 字段。 +- `fetchGitDiffHunksForFile(cwd, filePath): Promise` + - 执行 `git --no-optional-locks diff --no-ext-diff --no-textconv HEAD -- +`,复用 `parseGitDiff` 取该文件的 hunk 数组。 + - 与 `fetchGitDiffHunks` 一样传 `--no-ext-diff` / `--no-textconv`,避免 + `GIT_EXTERNAL_DIFF` / textconv 在只读路径上执行用户命令。 + - 单文件调用,天然受 `MAX_DIFF_SIZE_BYTES` 约束,响应体积可控。 + +两个函数都需要对 `filePath` 做校验:拒绝绝对路径、拒绝以 `/` 开头、拒绝包含 +`..` 越界段的 path,确保只把它当作仓库根相对路径传给 git。 + +### 2. daemon:扩展 status + 新增 diff 路由 + +- `WorkspaceGitState.getStatus()`:在原有 `branch` 基础上调用 + `getGitWorkingTreeStatus`,合并出 enriched `WorkspaceGitStatus`(v2)。 + `branch` 仍走 `resolveBranchName` 的缓存 + `watchRepoBranch`;重字段 + (dirty/ahead/behind/stash)每次 `getStatus` 现算(调用频率受“刷新策略” + 约束,见下文),不长期缓存以免 stale。 +- `routes/workspace-git.ts`: + - `GET /workspace/git` / `GET /workspaces/:workspace/git` 返回 enriched 结果 + (路由签名不变,只是 payload 字段增多)。 + - 新增 `GET /workspace/git/diff` 与 `GET /workspace/git/diff/file?path=...`, + 以及对应的 qualified 版本 `/workspaces/:workspace/git/diff[/file]`,复用 + `requireTrustedWorkspaceRuntime` / `resolveWorkspaceRuntimeFromParam` 的 + trusted 校验。 + - diff 路由内部调用 `fetchGitDiff` / `fetchGitDiffHunksForFile`,把结果映射成 + `DaemonWorkspaceGitDiff` / `DaemonWorkspaceGitDiffHunks`。`path` 查询参数 + 必须经过第 1 步的校验后才能传给 git。 + +### 3. SDK:类型 + client 方法 + 事件 + +- `DaemonWorkspaceGitStatus` 增加 v2 可选字段(如上)。 +- 新增 `DaemonWorkspaceGitDiff` / `DaemonWorkspaceGitDiffFile` / + `DaemonWorkspaceGitDiffHunks` / `DaemonDiffHunk` 类型,从 + `sdk-typescript/src/index.ts` 与 `src/daemon/index.ts` 导出。 +- `DaemonClient` 新增: + - `workspaceGitDiff(): Promise` + - `workspaceGitDiffFile(path: string, oldPath?: string): Promise` + (`path` 作为 query 参数需 `urlEncode`,对齐现有 `workspaceMcpTools` 等 + 方法的写法;`oldPath` 可选,传入时服务端按 rename 检测计算 old→new 的 + diff,否则重命名文件会显示为整文件新增;`oldPath` 同样需 `urlEncode`)。 +- 事件:可选新增 `git_status_changed`(携带 enriched status)。本期更倾向于 + **不新增推送事件**,而是复用现有 `git_branch_changed` 作为“需要重新拉取 + status”的信号——见“刷新策略”。是否新增 `git_status_changed` 留作实施时权衡, + 默认不加以缩小 PR 面积。 + +### 4. webui:connection 状态 + +- 若采用“复用 `git_branch_changed` 触发重拉”方案:`mappers.ts` 无需改动, + Web Shell 在收到 `connection.gitBranch` 变化时重新调用 `workspaceGit()`。 +- 若后续新增 `git_status_changed`:在 `mappers.ts` 增加一个 case,写入 + `connection.gitStatus`(新增可选字段),并做与 `git_branch_changed` 相同的 + `workspaceCwd` 归属校验。 + +### 5. Web Shell:增强 chip + 新增 diff 弹窗 + +- `GitBranchIndicator` 扩展: + - 入参从 `branch` 扩展为接收 enriched status(dirty / ahead / behind / + stashCount / detached)。 + - compact 形态:branch 名 + dirty 小圆点(有任一变更时显示)。 + - expanded 形态:追加 `↑N`(ahead)`↓M`(behind)、stash 角标;detached 时 + chip 变色并显示短 SHA。 + - chip 可点击:dirty 时点击打开 `GitDiffDialog`;其余情况可打开一个轻量 + status popover(或直接复用 diff 弹窗的 header)。复用现有 + `useWebShellPortalRoot()` 挂载 popover,保留 `data-web-shell-git-branch` + 属性。 +- 新增 `components/dialogs/GitDiffDialog.tsx`(+ `.module.css`),对齐 + `DaemonStatusDialog` 的形态: + - 打开时调用 `workspaceGitDiff()` 拉文件列表 + 统计;展示 header + (`N files changed, +A / -R`)和文件行(`+A -R 文件名`,binary / untracked / + deleted 标记,复用 `diffCommand.ts` 的列布局语义)。 + - 点击文件行按需调用 `workspaceGitDiffFile(path)` 拉 hunk,展开为统一 diff + (`+`/`-`/` ` 行着色),行内语法高亮复用 Shiki(按文件扩展名解析 language)。 + - 文件名渲染前必须 sanitize(参考 `sanitizeFilenameForDisplay` 的语义), + 防止 git 允许的原始控制字节 / 转义注入。 + - `available === false` 时显示占位文案(非仓库 / HEAD 缺失 / transient + state),对齐 `diffCommand.ts` 的提示语义。 + - 通过 `diffWorkspaceCwd` 状态打开:设为目标 workspace 的 cwd 即打开弹窗, + 设回 `undefined` 关闭(不复用 `activePanel`,见 Phase 2“调研修正”)。 +- `/diff` 命令本地化:在 Web Shell 中把 `/diff` 从 ACP 透传改为本地实现—— + 打开 `GitDiffDialog`(对齐 CLI 交互模式打开 `DiffDialog` 的行为)。在 + `App.tsx` 的命令分发处识别 `/diff` 并 `setDiffWorkspaceCwd()`, + 不再发给 daemon。`getLocalCommands` 中补 `diff` 的补全项与 `local.diff` 文案。 + +### 6. 刷新策略(第一层的新鲜度) + +- branch:保持现状,`watchRepoBranch` 经 `git_branch_changed` 实时推送,热路径 + 直读,零额外成本。 +- 重字段(dirty / ahead / behind / stash)在以下时机重新拉取 + `workspaceGit()`: + 1. 用户打开 status popover 或 `GitDiffDialog` 时(按需,权威)。 + 2. 收到 `git_branch_changed`(commit / reset / 切分支都会同时改变这些值)。 + 3. 标签页 `visibilitychange` 重新可见时。 + 4. 仅对**当前选中 / 可见的 workspace**做一次低速轮询(如 30s,可配置), + 保证 dirty 点在编辑后“足够新”。**不**对所有 workspace 轮询。 +- 明确取舍:不对工作区文件树建立 watcher,dirty 不会逐键实时刷新。这是为了 + 避免昂贵的全树监听;对“编辑后立刻想看 dirty”的场景,focus / 轮询 / 打开弹窗 + 都能覆盖。 + +### 7. i18n + +新增文案需同时提供 en 与 zh-CN(`i18n.tsx`):chip 的 dirty / ahead / behind / +stash / detached 的 aria-label 与 tooltip、`GitDiffDialog` 的 header / 列标记 / +占位文案、`local.diff` 补全描述。复用 `git.currentBranch` 既有 key 的命名风格。 + +## 兼容性 + +- 旧 daemon(v1)只返回 `{ v, workspaceCwd, branch }`:新 client 把缺失的 v2 + 字段当作“未知”,chip 退化为当前的纯 branch 显示,不显示 dirty/ahead 等。 +- 旧 client 读到 v2 payload:只认 `branch`,忽略多余字段,行为不变。 +- 非 git 仓库 / detached / transient state:`getGitWorkingTreeStatus` 与 + `fetchGitDiff` 返回 null,前端显示占位或隐藏重字段,不报错。 +- `git_branch_changed` 仍保留,不破坏现有 branch chip 链路。 +- `/diff` 在非 Web Shell 客户端的纯文本输出不变(daemon `renderDiffModelText` + 路径不动)。 +- diff payload 受 core 既有上限约束(`MAX_FILES` / `MAX_DIFF_SIZE_BYTES` / + `MAX_LINES_PER_FILE`),大 diff 通过 `hiddenCount` 与单文件按需加载控制体积。 + +## 测试计划 + +### Unit tests + +- `getGitWorkingTreeStatus`:clean / dirty(staged、unstaged、untracked 混合)/ + detached / 有 upstream 的 ahead-behind / 无 upstream / transient state / + 非仓库各分支;branch header 解析正确。 +- `fetchGitDiffHunksForFile`:单文件有变化 / 无变化 / untracked 返回空 / + 非法 path(绝对路径、`..` 越界)被拒绝;`--no-ext-diff` / `--no-textconv` + 被传入。 +- `WorkspaceGitState.getStatus`:返回 enriched 结构,branch 仍来自缓存、 + 重字段来自 `getGitWorkingTreeStatus`。 +- diff 路由:`GET /workspace/git/diff` 把 `fetchGitDiff` 结果映射为 + `DaemonWorkspaceGitDiff`;`.../diff/file` 校验 `path` 并映射 hunk; + qualified 路由复用 trusted 校验(参考 `workspace-git.test.ts` 现有用例)。 +- `DaemonClient.workspaceGitDiff` / `workspaceGitDiffFile`:正确拼接 URL、 + `path` 经过 `urlEncode`。 +- `GitBranchIndicator`:dirty 点显示 / 隐藏;ahead-behind 渲染;detached 文案; + compact / expanded 形态;可点击 aria。 +- `GitDiffDialog`:文件列表渲染(binary / untracked / deleted 标记);点击展开 + 按需拉 hunk;`available === false` 占位;文件名 sanitize;hunk 行着色。 +- `/diff` 本地化:`App.tsx` 收到 `/diff` 时本地拦截,`setDiffWorkspaceCwd(<当前 cwd>)` + 打开工作区 Changes 弹窗而非透传 daemon(无 cwd 时 toast 提示;参考 `App.test.tsx` 现有用例)。 + +### Integration / browser verification + +- 在干净仓库 / 有改动仓库 / detached / 无 upstream 仓库下,chip 显示符合预期。 +- 编辑文件后,focus 或打开弹窗时 dirty 点出现;commit 后 `git_branch_changed` + 触发 chip 更新。 +- 打开 `GitDiffDialog`:文件列表正确,点击文件展开行级 diff,Shiki 高亮正常, + 大文件 / 多文件被上限截断时有 `hiddenCount` 提示。 +- 非 git 目录下打开 `/diff` 显示占位文案而非报错。 + +## 风险和控制 + +- 风险:重字段每次 `getStatus` 现算会在多 workspace 下放大 `git status` 子进程 + 成本。控制:只对当前可见 workspace 低速轮询,其余按需(打开弹窗 / focus)才 + 拉取;branch 始终走直读,不进子进程。 +- 风险:dirty 不实时(编辑后不逐键刷新)可能让用户困惑。控制:在 chip tooltip + 或 popover 注明“点击刷新 / 数据为最近一次快照”,并保证打开弹窗时取权威值。 +- 风险:`path` 查询参数来自前端,可能构造越界路径。控制:daemon 与 core 两层 + 都校验(拒绝绝对路径 / `..` 越界段),且最终由 git 限定在仓库内。 +- 风险:git 允许的原始控制字节 / 转义进入文件名,造成渲染注入。控制:渲染前 + 统一 sanitize,复用 `sanitizeFilenameForDisplay` 语义。 +- 风险:跨包新增类型扩大 PR 面积。控制:diff hunk 用最小 `DaemonDiffHunk` + 结构,不让 SDK 反向依赖 `diff` 库或 Web Shell client 类型;默认不新增 + `git_status_changed` 事件以缩小改动面。 +- 风险:Shiki 对部分语言 / 大文件高亮有性能成本。控制:仅对展开的单个文件做 + 高亮,且受 `MAX_LINES_PER_FILE` 约束;流式无关,无需 debounce。 + +## Phase 1 详细实施计划(含进度) + +**目标**:branch chip 从“只显示分支名”升级为实时状态条——显示 dirty / +ahead-behind / stash / detached / **operation**(merge/rebase/…)/ **conflicted** +(冲突数)。纯增量、只读、向后兼容;不动 branch 显示路径。 + +**数据流**: + +```text +core getGitWorkingTreeStatus(cwd) + → daemon WorkspaceGitState.getStatus() (WorkspaceGitStatus v2) + → GET /workspace/git / GET /workspaces/:workspace/git + → SDK DaemonWorkspaceGitStatus (DaemonClient.workspaceGit()) + → webui connection(git_branch_changed 仍驱动 branch;重字段走 REST) + → Web Shell gitStatus 状态 → ChatEditor → GitBranchIndicator +``` + +**文件清单**: + +| 操作 | 文件 | 说明 | +| ---- | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 修改 | `packages/core/src/utils/gitDiff.ts` | `GitOperation` / `GitWorkingTreeStatus` / `getGitWorkingTreeStatus` / `parseStatusBranchLine` / `parseStatusEntries` / `detectGitOperation` / `countStashEntries` | +| 修改 | `packages/core/src/utils/gitDiff.test.ts` | 解析单测 + 真实仓库集成测试 | +| 修改 | `packages/cli/src/serve/workspace-git-state.ts` | `WorkspaceGitStatus` v2 + `getStatus` 合并 enriched | +| 修改 | `packages/cli/src/serve/workspace-git-state.test.ts` | mock + v2 断言 + enriched/operation 用例 | +| 修改 | `packages/sdk-typescript/src/daemon/...`(`DaemonWorkspaceGitStatus` 声明处) | 加 v2 可选字段 | +| 修改 | `packages/web-shell/client/components/GitBranchIndicator.tsx`(+ `.module.css`) | 渲染 dirty 点 / ↑N↓M / stash / detached / operation 徽标 / conflicted | +| 修改 | `packages/web-shell/client/components/ChatEditor.tsx` | 透传 enriched status | +| 修改 | `packages/web-shell/client/App.tsx` | `gitStatus` 状态 + 刷新策略 | +| 修改 | `packages/web-shell/client/i18n.tsx` | en + zh-CN 文案 | +| 修改 | `packages/web-shell/client/components/GitBranchIndicator.test.tsx` | 各状态渲染用例 | + +### Task 1 · core ✅ 已完成 + +- [x] `GitOperation` 类型 + `GitWorkingTreeStatus`(含 `conflicted` / `operation`) +- [x] `getGitWorkingTreeStatus`:`git status --porcelain=v1 --branch -z` 一次调用 + - 直读 stash reflog + `detectGitOperation`;transient 不再返回 null +- [x] `parseStatusBranchLine` / `parseStatusEntries`(含 unmerged → conflicted) +- [x] 单测:解析 + clean/dirty/detached/stash/ahead-behind/非仓库/merge/rebase/ + cherry-pick(**79 个通过**) + +### Task 2 · daemon ✅ 已完成 + +- [x] `WorkspaceGitStatus` 升级到 v2 + enriched 可选字段(dirty 部分) +- [x] `getStatus` 合并 `getGitWorkingTreeStatus`(branch 用 watcher 缓存,重字段 + 每次现算) +- [x] 接口加 `operation?` / `conflicted?`;`getStatus` 透传这两个字段 +- [x] 测试:enriched 输出用例补 `operation` / `conflicted`(**14 个通过**) + +### Task 3 · SDK ✅ 已完成 + +- [x] `DaemonWorkspaceGitStatus` 加 v2 可选字段(`detached/staged/unstaged/ +untracked/conflicted/hasUpstream/ahead/behind/stashCount/operation/ +computedAt`),`v: 1 | 2`;新增 `DaemonGitOperation` 类型并从 + `src/index.ts` / `src/daemon/index.ts` 导出 +- [x] `workspaceGit()` 无需改(返回更多字段即可) +- [x] SDK 类型单测(v1 mock)仍通过 + +### Task 4 · Web Shell ✅ 已完成 + +- [x] `GitBranchIndicator` 入参从 `branch` 扩展为接收 enriched status;渲染: + dirty 点、`↑N`/`↓M`、stash 角标、detached 换图标、**operation 徽标** + (`REBASING`/`MERGING`/…)、conflicted 数(**非颜色兜底**:形状+数字) +- [x] compact(图标角标)/ expanded 两种形态;保留 `data-web-shell-git-branch` +- [x] `App.tsx` 新增 `gitStatus` 状态,经 `workspaceGit()` 拉取;branch 仍用 + `connection.gitBranch`(SSE,实时) +- [x] 刷新策略(focus + branch 变化 + 仅当前 workspace 30s 可见性轮询) +- [x] `ChatEditor` 透传 enriched status 到 `GitBranchIndicator` +- [x] i18n(en + zh-CN):aria-label / tooltip / operation 文案 +- [x] `GitBranchIndicator.test.tsx` 补各状态用例(**8 个通过**) + +**刷新策略**(第一层新鲜度): + +- branch:保持现状,`git_branch_changed`(reflog watch)实时推送。 +- 重字段(dirty/ahead/behind/stash/operation/conflicted)在以下时机重拉 + `workspaceGit()`:① 打开 status popover / diff 弹窗;② 收到 + `git_branch_changed`;③ 标签页 `visibilitychange` 重新可见;④ 仅对**当前活跃 + workspace** 低速轮询(如 30s,可配置)。 +- 不对工作区文件树建 watcher,dirty 不逐键实时(成本取舍)。 + +### Task 5 · 验证 ✅ 已完成 + +- [x] `npm run build && npm run typecheck`(全仓通过) +- [x] `npm run lint`(改动文件全过) +- [x] 单测:core 79 / cli 14 / sdk 1 / web-shell GitBranchIndicator 8 + + ChatEditor&App 118 +- [ ] 浏览器验收(留待 PR 前补):干净 / dirty / detached / 无 upstream / + rebase 中 各状态 chip 显示正确;focus / 打开弹窗后 dirty 点刷新 + +> Phase 1 已提交于分支 `feat/webshell-git-status-chip`。 + +--- + +## Phase 2 详细实施计划(含进度) + +**目标**:新增只读的「Changes」弹窗——文件列表(工作区 vs HEAD)+ 点开按需 +加载单文件行级 diff(Shiki 高亮)。`/diff` 从 ACP 透传改为本地打开该弹窗, +dirty chip 点击联动。纯增量、只读、向后兼容。 + +**数据流**: + +```text +core fetchGitDiff(cwd) / fetchGitDiffHunksForFile(cwd, path) + → daemon GET /workspace/git/diff (列表 + 统计) + GET /workspace/git/diff/file?path= (单文件 hunk) + (+ qualified /workspaces/:workspace/git/diff[/file]) + → SDK DaemonWorkspaceGitDiff / DaemonWorkspaceGitDiffHunks + + DaemonClient.workspaceGitDiff() / workspaceGitDiffFile(path) + → Web Shell GitDiffDialog(列表 → 懒加载单文件 hunk → Shiki 高亮) +``` + +**调研修正(与早期草案的差异,重要)**: + +- **daemon 是 express,不用 zod**:query 参数用手写助手 `requireStringQuery` / + `parseIntInRange`(参考 `routes/workspace-file-read.ts`),不要引入 zod schema。 +- **路径安全(实施修正:单文件 diff 路由不走 fs factory)**:早期草案要求 + `?path=` 经 `factory.forRequest(...).resolve(path, 'read')` 沙箱化。实施时 + 发现 `'read'` 意图会拒绝**工作区已删除的文件**(ENOENT),而这类文件仍在 + HEAD 中、必须能 diff,故单文件 diff 路由**不**经 fs factory。改由四层纵深 + 约束:(1) qualified 路由要求 trusted workspace;(2) core + `fetchGitDiffHunksForFile` 把 path 规范化为 repo-relative,拒绝绝对路径 / + 盘符 / `..` 越界;(3) git 只在仓库内 diff,untracked 合成读取用 + `O_NOFOLLOW`,且仅对 `ls-files --others` 确认为 untracked 的路径执行;(4) + 路由只读。详见 `routes/workspace-git-diff.ts` 顶部注释。 +- **读路由头**:复用 `applyReadHeaders(res)`(`no-store` + `nosniff`)。 +- **错误**:git 业务用 `sendBridgeError`,trust/解析失败用 runtime 助手 + (已自动发响应);缺 `path` query 返回 `400 parse_error`。 +- **Shiki 已有封装**:复用 `components/messages/codeHighlighter.ts` + (`getCodeHighlighter` / `highlightToHtmlSync` / `isTooLargeToHighlight`), + 不新接 highlighter。 +- **`virtual-viewport` 在代码库中不存在**(早期文档引用的概念未落地)。大 diff + 靠 core 既有上限(`MAX_FILES=50` / `MAX_LINES_PER_FILE=400` / + `MAX_DIFF_SIZE_BYTES=1MB`)+ **单文件懒加载**控制 DOM 规模;本期不引入虚拟 + 滚动(400 行内 DOM 可承受),如后续需要再单独立项。 +- **弹窗形态**:用 `components/ui/dialog`(Radix Dialog + `useWebShellPortalRoot`, + 对齐 `McpManagerPage`),`DialogContent` 覆盖 className 加宽;经 + `showGitDiffDialog` 状态标志开关并纳入 `dialogOpen` 聚合(`App.tsx:2739`)。 +- **`/diff` 本地化**:当前 `/diff` 是 ACP/agent 命令(serve 无对应路由)。本期 + 在 `App.tsx` 命令分发处拦截 `/diff` → 打开弹窗(不发给 daemon),并在 + `getLocalCommands` 补 `diff` 补全项(`local.diff` 文案已存在)。 + +**文件清单**: + +| 操作 | 文件 | 说明 | +| ---- | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| 修改 | `packages/core/src/utils/gitDiff.ts` | 新增 `fetchGitDiffHunksForFile(cwd, path)`(单文件 hunk) | +| 修改 | `packages/core/src/utils/gitDiff.test.ts` | 单文件 hunk 真实仓库用例 | +| 新增 | `packages/cli/src/serve/routes/workspace-git-diff.ts` | 两个 GET 路由(bound + qualified) | +| 修改 | `packages/cli/src/serve/server.ts` | 注册新路由(import + 两处 register 调用) | +| 新增 | `packages/cli/src/serve/routes/workspace-git-diff.test.ts` | 路由单测(含 path 越界拒绝) | +| 修改 | `packages/sdk-typescript/src/daemon/types.ts` | `DaemonWorkspaceGitDiff` / `...File` / `...Hunks` / `DaemonDiffHunk` | +| 修改 | `packages/sdk-typescript/src/daemon/index.ts` + `src/index.ts` | 导出新类型 | +| 修改 | `packages/sdk-typescript/src/daemon/DaemonClient.ts` | `workspaceGitDiff()` / `workspaceGitDiffFile(path)` | +| 新增 | `packages/web-shell/client/components/dialogs/GitDiffDialog.tsx`(+ `.module.css`) | 文件列表 + 单文件 hunk 渲染 + Shiki 高亮 | +| 修改 | `packages/web-shell/client/components/GitBranchIndicator.tsx` | dirty chip 可点击(`onOpenDiff` 回调) | +| 修改 | `packages/web-shell/client/components/ChatEditor.tsx` | 透传 `onOpenGitDiff` 回调 | +| 修改 | `packages/web-shell/client/App.tsx` | `showGitDiffDialog` 状态 + 渲染弹窗 + `/diff` 本地拦截 | +| 修改 | `packages/web-shell/client/constants/localCommands.ts` | `diff` 补全项 | +| 修改 | `packages/web-shell/client/i18n.tsx` | 弹窗文案(en + zh-CN) | +| 新增 | `packages/web-shell/client/components/dialogs/GitDiffDialog.test.tsx` | 列表 / hunk / 占位 / 越界用例 | + +### Task 1 · core ✅ 已完成 + +- [x] `fetchGitDiffHunksForFile(cwd, filePath): Promise` + - `git --no-optional-locks diff --no-ext-diff --no-textconv HEAD -- `, + 复用 `parseGitDiff` 取该文件 hunk 数组。 + - 与 `fetchGitDiffHunks` 一致传 `--no-ext-diff` / `--no-textconv`。 + - repo-relative 校验(`toRepoRelativePath`):拒绝绝对路径 / 盘符 / `..` + 越界段(纵深防御)。 + - **untracked 全新增**(`synthesizeUntrackedHunk`):`git diff HEAD` 无输出 + 且 `ls-files --others` 确为 untracked 时,`O_NOFOLLOW` 读取文件内容合成单个 + 全新增 hunk(受 `MAX_LINES_PER_FILE` / `MAX_DIFF_SIZE_BYTES` 约束;二进制 + 按既有语义)。 + - 非仓库 / transient / 该文件无变化(且非 untracked)→ 返回 `null`。 +- [x] 单测:真实仓库改动单文件、未改文件、**untracked 文件全新增**、越界 path + (`gitDiff.test.ts` 共 88 用例通过)。 + +### Task 2 · daemon ✅ 已完成 + +- [x] 新建 `routes/workspace-git-diff.ts`,导出 + `registerWorkspaceGitDiffRoutes` + `registerWorkspaceQualifiedGitDiffRoutes`。 +- [x] `GET /workspace/git/diff`:调 `fetchGitDiff` → 映射 `DaemonWorkspaceGitDiff` + (files 列表 + 统计 + `hiddenCount`);`available` 反映 null(非仓库/ + transient)。 +- [x] `GET /workspace/git/diff/file?path=`:校验 `req.query['path']`(缺则 + `400 parse_error`)→ `fetchGitDiffHunksForFile` → 映射 + `DaemonWorkspaceGitDiffHunks`;`applyReadHeaders`;错误 `sendBridgeError`。 + **不经 fs factory**(`'read'` 意图会拒绝已删除文件),改由 trust gate + + core repo-relative 规范化 + git 仓库内含 + `O_NOFOLLOW` + `ls-files` + gate 四层约束(见「调研修正」与路由顶部注释)。 +- [x] qualified 版本:`resolveWorkspaceRuntimeFromParam` + + `requireTrustedWorkspaceRuntime`(仿 `workspace-git.ts` 的 + `resolveTrustedRuntime`)。 +- [x] `server.ts`:import + 两处注册调用(紧邻 `registerWorkspaceGitRoutes`)。 +- [x] 路由单测:列表、单文件、越界 path 拒绝、非仓库占位(8 用例通过)。 + +### Task 3 · SDK ✅ 已完成 + +- [x] `types.ts`:`DaemonWorkspaceGitDiffFile` / `DaemonWorkspaceGitDiff` / + `DaemonDiffHunk` / `DaemonWorkspaceGitDiffHunks`(结构见「第二层 diff + payload」)。 +- [x] `daemon/index.ts` + `src/index.ts` 导出新类型。 +- [x] `DaemonClient`:`workspaceGitDiff()` / `workspaceGitDiffFile(path)` + (path 作为 query,`urlEncode`,对齐 `workspaceMcpTools` 写法); + bound 与 workspace-qualified 两个 client 类各加一对方法。 +- [x] 浏览器 bundle 上限 160KB→165KB(`packages/sdk-typescript/scripts/build.js`, + 含说明注释)。 + 已补 client 方法单测:`DaemonClient.test.ts` 覆盖 `workspaceGitDiff()` / + `workspaceGitDiffFile(path, oldPath?)` 的 URL 构造(含 path/oldPath 的 + `urlEncode`)与响应反序列化,与既有 `workspaceGit()` 单测同一模式;契约 + 另由 cli 路由单测 + typecheck + web-shell 消费侧测试覆盖。 + +### Task 4 · Web Shell ✅ 已完成 + +- [x] `GitDiffDialog.tsx`(+ `.module.css`): + - 打开时 `workspaceGitDiff()` 拉列表 + 统计;header `N files · +A / -R`。 + - 文件行:`+A -R 文件名`,binary / untracked / deleted 标记;文件名渲染前 + `sanitizeControlChars`(git 允许奇异字节)。 + - 点击文件行 `workspaceGitDiffFile(path)` 懒加载 hunk,展开统一 diff + (`+`/`-`/` ` 行背景着色);Shiki **per-side 精确高亮**(`codeToTokens`, + language 由 `languageForPath` + `resolveFenceLanguage` 解析;复用 + `codeHighlighter.ts` 的懒加载与 `isTooLargeToHighlight` 降级)。 + - `available === false` / 空 diff / 错误 → 占位文案。 + - 用 `DialogShell`(内部走 `ui/dialog` + `useWebShellPortalRoot`),`size="xl"` + - `allowFullscreen`;`showGitDiffDialog` 状态纳入 `dialogOpen` 聚合。 +- [x] `GitBranchIndicator`:提供 `onOpenDiff` 时 chip 渲染为 ` + ) : ( + + {chipInner} + + )} - {branch} + +
+
+ {s.detached ? `${t('git.detached')} (${branch})` : branch} +
+ {phrases.length > 0 ? ( + phrases.map((phrase) => ( +
+ {phrase} +
+ )) + ) : status?.computedAt !== undefined ? ( +
{t('git.clean')}
+ ) : null} +
+
); diff --git a/packages/web-shell/client/components/dialogs/GitDiffDialog.module.css b/packages/web-shell/client/components/dialogs/GitDiffDialog.module.css new file mode 100644 index 00000000000..97e0fb94dad --- /dev/null +++ b/packages/web-shell/client/components/dialogs/GitDiffDialog.module.css @@ -0,0 +1,159 @@ +.placeholder { + padding: 24px 12px; + text-align: center; + color: var(--muted-foreground); +} + +.fileList { + display: flex; + flex-direction: column; + gap: 8px; +} + +.file { + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; +} + +.fileHeader { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 10px; + background: var(--subtle-bg); + border: 0; + cursor: pointer; + font: inherit; + text-align: left; + color: inherit; +} + +.fileHeader:hover { + background: var(--subtle-bg-strong); +} + +.fileStats { + display: inline-flex; + gap: 6px; + flex-shrink: 0; + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +.statAdd { + color: var(--success-color); +} + +.statDel { + color: var(--error-color); +} + +.fileBinary { + color: var(--muted-foreground); +} + +.filePath { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fileOldPath { + color: var(--muted-foreground); +} + +.fileTag { + flex-shrink: 0; + padding: 0 6px; + border-radius: 4px; + background: var(--muted); + color: var(--muted-foreground); + font-size: 10px; + text-transform: uppercase; +} + +.fileBody { + border-top: 1px solid var(--border); +} + +.filePlaceholder { + padding: 12px; + color: var(--muted-foreground); + font-size: 12px; +} + +.hiddenNote { + padding: 4px 10px; + color: var(--muted-foreground); + font-size: 11px; +} + +.diffLines { + max-height: 480px; + overflow: auto; + font-size: 12px; +} + +.diffLine { + display: flex; + min-height: 18px; + line-height: 18px; +} + +.diffLineAdd { + background: var(--success-bg); +} + +.diffLineDel { + background: var(--error-bg); +} + +.diffLineContext { + background: transparent; +} + +.diffLineMeta { + background: transparent; + color: var(--muted-foreground); + font-style: italic; +} + +.diffOldNo, +.diffNewNo { + width: 40px; + flex-shrink: 0; + text-align: right; + padding-right: 6px; + user-select: none; + color: var(--muted-foreground); + opacity: 0.6; + font-variant-numeric: tabular-nums; +} + +.diffMarker { + width: 14px; + flex-shrink: 0; + text-align: center; + user-select: none; + color: var(--muted-foreground); +} + +.diffLineAdd .diffMarker { + color: var(--success-color); +} + +.diffLineDel .diffMarker { + color: var(--error-color); +} + +.diffContent { + flex: 1 1 auto; + min-width: 0; + white-space: pre; + overflow-x: auto; + padding-right: 8px; +} diff --git a/packages/web-shell/client/components/dialogs/GitDiffDialog.test.tsx b/packages/web-shell/client/components/dialogs/GitDiffDialog.test.tsx new file mode 100644 index 00000000000..64c1e582eee --- /dev/null +++ b/packages/web-shell/client/components/dialogs/GitDiffDialog.test.tsx @@ -0,0 +1,466 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { act, StrictMode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { I18nProvider } from '../../i18n'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = () => {}; +} + +// A STABLE client object: the dialog's fetch effect depends on `client`, so a +// fresh object per render (as a naive mock returns) would re-fire it in a loop. +const { workspaceGitDiff, workspaceGitDiffFile, workspaceClient, shikiState } = + vi.hoisted(() => { + const workspaceGitDiff = vi.fn(); + const workspaceGitDiffFile = vi.fn(); + const workspaceClient = { + workspaceByCwd: () => ({ workspaceGitDiff, workspaceGitDiffFile }), + }; + // Per-test switch for the highlighter path: `resolvedLang` steers whether + // buildRows even asks for a highlighter ('text' skips it), `highlighter` + // (when set) makes getCodeHighlighter resolve instead of reject. + const shikiState = { + resolvedLang: 'text', + highlighter: null as { + codeToTokens: ( + code: string, + opts: { lang: string; theme: string }, + ) => { tokens: Array> }; + } | null, + }; + return { + workspaceGitDiff, + workspaceGitDiffFile, + workspaceClient, + shikiState, + }; + }); + +vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ + useWorkspace: () => ({ client: workspaceClient }), +})); + +// Shiki's WASM engine isn't available under jsdom; by default the stub rejects +// so buildRows takes the plain-text path. A test can install a fake +// highlighter via `shikiState` to exercise the token-interleaving success path. +vi.mock('../messages/codeHighlighter', () => ({ + getCodeHighlighter: vi.fn(() => + shikiState.highlighter + ? Promise.resolve(shikiState.highlighter) + : Promise.reject(new Error('no shiki in tests')), + ), + isTooLargeToHighlight: () => false, +})); + +vi.mock('../messages/Markdown', () => ({ + resolveFenceLanguage: (lang: string) => ({ + label: lang, + lang, + resolvedLang: shikiState.resolvedLang, + }), +})); + +vi.mock('../messages/ToolGroup', () => ({ + languageForPath: () => 'text', +})); + +const { GitDiffDialog } = await import('./GitDiffDialog'); + +let container: HTMLDivElement; +let root: Root; + +function mount(workspaceCwd = '/repo', strict = false) { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + const dialog = ( + + + + ); + act(() => { + root.render(strict ? {dialog} : dialog); + }); +} + +async function flush() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.clearAllMocks(); + shikiState.resolvedLang = 'text'; + shikiState.highlighter = null; +}); + +function diffPayload( + overrides: Partial<{ + available: boolean; + files: Array>; + hiddenCount: number; + }> = {}, +) { + const files = overrides.files ?? [ + { + path: 'src/a.ts', + added: 2, + removed: 1, + isBinary: false, + isUntracked: false, + isDeleted: false, + truncated: false, + }, + ]; + return { + v: 1 as const, + workspaceCwd: '/repo', + available: overrides.available ?? true, + filesCount: files.length, + linesAdded: 2, + linesRemoved: 1, + files, + hiddenCount: overrides.hiddenCount ?? 0, + }; +} + +describe('GitDiffDialog', () => { + it('renders the changed file list with stats', async () => { + workspaceGitDiff.mockResolvedValue(diffPayload()); + mount(); + await flush(); + + expect(workspaceGitDiff).toHaveBeenCalled(); + expect(document.body.textContent).toContain('src/a.ts'); + expect(document.body.textContent).toContain('+2'); + expect(document.body.textContent).toContain('-1'); + }); + + it('shows a truncation note when more files are hidden', async () => { + workspaceGitDiff.mockResolvedValue(diffPayload({ hiddenCount: 3 })); + mount(); + await flush(); + + expect(document.body.textContent).toContain('3 more file(s) not shown'); + }); + + it('loads and renders a file diff when expanded', async () => { + workspaceGitDiff.mockResolvedValue(diffPayload()); + workspaceGitDiffFile.mockResolvedValue({ + v: 1, + workspaceCwd: '/repo', + path: 'src/a.ts', + available: true, + hunks: [ + { + oldStart: 1, + oldLines: 1, + newStart: 1, + newLines: 2, + lines: ['-const a = 1', '+const a = 2', '+const b = 3'], + }, + ], + }); + mount(); + await flush(); + + const header = document.body.querySelector( + 'button[aria-expanded="false"]', + ) as HTMLButtonElement; + expect(header).not.toBeNull(); + await act(async () => { + header.click(); + }); + await flush(); + + expect(workspaceGitDiffFile).toHaveBeenCalledWith('src/a.ts', undefined); + // Plain-text fallback: the line bodies render without the +/- prefix + // (the marker is a separate column). + expect(document.body.textContent).toContain('const a = 2'); + expect(document.body.textContent).toContain('const b = 3'); + expect(document.body.textContent).toContain('const a = 1'); + }); + + it('still loads a file diff under StrictMode (cancelled flag resets on remount)', async () => { + // StrictMode replays mount→unmount→mount and a ref persists across the + // replay, so the row's cancelled flag must reset on mount — otherwise the + // fetched hunks are dropped and the row sticks on "Loading changes…". + workspaceGitDiff.mockResolvedValue(diffPayload()); + workspaceGitDiffFile.mockResolvedValue({ + v: 1, + workspaceCwd: '/repo', + path: 'src/a.ts', + available: true, + hunks: [ + { + oldStart: 1, + oldLines: 1, + newStart: 1, + newLines: 1, + lines: ['-const a = 1', '+const a = 2'], + }, + ], + }); + mount('/repo', true); + await flush(); + + const header = document.body.querySelector( + 'button[aria-expanded="false"]', + ) as HTMLButtonElement; + expect(header).not.toBeNull(); + await act(async () => { + header.click(); + }); + await flush(); + + expect(document.body.textContent).toContain('const a = 2'); + expect(document.body.textContent).not.toContain('Loading changes…'); + }); + + it('forwards the pre-rename oldPath when expanding a renamed file', async () => { + workspaceGitDiff.mockResolvedValue( + diffPayload({ + files: [ + { + path: 'src/new.ts', + oldPath: 'src/old.ts', + added: 1, + removed: 1, + isBinary: false, + isUntracked: false, + isDeleted: false, + truncated: false, + }, + ], + }), + ); + workspaceGitDiffFile.mockResolvedValue({ + v: 1, + workspaceCwd: '/repo', + path: 'src/new.ts', + available: true, + hunks: [ + { + oldStart: 1, + oldLines: 1, + newStart: 1, + newLines: 1, + lines: ['-const a = 1', '+const a = 2'], + }, + ], + }); + mount(); + await flush(); + + const header = document.body.querySelector( + 'button[aria-expanded="false"]', + ) as HTMLButtonElement; + expect(header).not.toBeNull(); + await act(async () => { + header.click(); + }); + await flush(); + + // The pre-rename path is forwarded so the daemon diffs old→new (rename + // detection) instead of showing the new path as fully added. + expect(workspaceGitDiffFile).toHaveBeenCalledWith( + 'src/new.ts', + 'src/old.ts', + ); + }); + + it('shows a placeholder when git is unavailable', async () => { + workspaceGitDiff.mockResolvedValue( + diffPayload({ available: false, files: [] }), + ); + mount(); + await flush(); + + expect(document.body.textContent).toContain('Git is not available'); + }); + + it('shows an empty placeholder for a clean working tree', async () => { + workspaceGitDiff.mockResolvedValue(diffPayload({ files: [] })); + mount(); + await flush(); + + expect(document.body.textContent).toContain('No changes'); + }); + + it('marks untracked and binary files in the list', async () => { + workspaceGitDiff.mockResolvedValue( + diffPayload({ + files: [ + { + path: 'new.txt', + added: 1, + removed: 0, + isBinary: false, + isUntracked: true, + isDeleted: false, + truncated: false, + }, + { + path: 'logo.png', + isBinary: true, + isUntracked: false, + isDeleted: false, + truncated: false, + }, + ], + }), + ); + mount(); + await flush(); + + expect(document.body.textContent).toContain('Untracked'); + expect(document.body.textContent).toContain('Binary'); + }); + + it('shows an error placeholder when the diff list fails to load', async () => { + workspaceGitDiff.mockRejectedValue(new Error('network down')); + mount(); + await flush(); + + expect(document.body.textContent).toContain('Failed to load changes'); + }); + + it('shows a per-file error when a file diff fails to load', async () => { + workspaceGitDiff.mockResolvedValue(diffPayload()); + workspaceGitDiffFile.mockRejectedValue(new Error('file fetch failed')); + mount(); + await flush(); + + const header = document.body.querySelector( + 'button[aria-expanded="false"]', + ) as HTMLButtonElement; + expect(header).not.toBeNull(); + await act(async () => { + header.click(); + }); + await flush(); + + expect(workspaceGitDiffFile).toHaveBeenCalledWith('src/a.ts', undefined); + expect(document.body.textContent).toContain('Failed to load this diff'); + }); + + it('labels a capped file diff as truncated', async () => { + workspaceGitDiff.mockResolvedValue(diffPayload()); + workspaceGitDiffFile.mockResolvedValue({ + v: 1, + workspaceCwd: '/repo', + path: 'src/a.ts', + available: true, + hunks: [ + { + oldStart: 0, + oldLines: 0, + newStart: 1, + newLines: 1, + lines: ['+the visible head of a capped file'], + }, + ], + truncated: true, + }); + mount(); + await flush(); + + const header = document.body.querySelector( + 'button[aria-expanded="false"]', + ) as HTMLButtonElement; + expect(header).not.toBeNull(); + await act(async () => { + header.click(); + }); + await flush(); + + expect(document.body.textContent).toContain('Diff truncated'); + // The visible window still renders above the note. + expect(document.body.textContent).toContain('visible head'); + }); + + it('shows the per-file error when row building rejects on malformed hunks', async () => { + workspaceGitDiff.mockResolvedValue(diffPayload()); + workspaceGitDiffFile.mockResolvedValue({ + v: 1, + workspaceCwd: '/repo', + path: 'src/a.ts', + available: true, + // `lines: null` makes buildRows throw while iterating — the shape a + // buggy daemon could emit. Without the .catch this is an unhandled + // rejection and the diff area silently stays empty. + hunks: [ + { oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: null }, + ], + }); + mount(); + await flush(); + + const header = document.body.querySelector( + 'button[aria-expanded="false"]', + ) as HTMLButtonElement; + expect(header).not.toBeNull(); + await act(async () => { + header.click(); + }); + await flush(); + + expect(document.body.textContent).toContain('Failed to load this diff'); + }); + + it('renders Shiki tokens per side when highlighting succeeds', async () => { + // Steer buildRows onto the highlighter path with a fake tokenizer that + // emits one colored token per line, so the add row pulls from the new-side + // tokens and the del row from the old-side tokens. + shikiState.resolvedLang = 'ts'; + shikiState.highlighter = { + codeToTokens: (code: string) => ({ + tokens: code + .split('\n') + .map((line) => [{ content: line, color: '#ff0000' }]), + }), + }; + workspaceGitDiff.mockResolvedValue(diffPayload()); + workspaceGitDiffFile.mockResolvedValue({ + v: 1, + workspaceCwd: '/repo', + path: 'src/a.ts', + available: true, + hunks: [ + { + oldStart: 1, + oldLines: 1, + newStart: 1, + newLines: 1, + lines: ['-const a = 1', '+const a = 2'], + }, + ], + }); + mount(); + await flush(); + + const header = document.body.querySelector( + 'button[aria-expanded="false"]', + ) as HTMLButtonElement; + expect(header).not.toBeNull(); + await act(async () => { + header.click(); + }); + await flush(); + + const colored = Array.from( + document.body.querySelectorAll('span[style]'), + ).filter((el) => (el as HTMLElement).style.color !== ''); + const texts = colored.map((el) => el.textContent); + // Both sides tokenized: the del row from the old side, the add row from + // the new side — not the plain-text fallback. + expect(texts).toContain('const a = 1'); + expect(texts).toContain('const a = 2'); + }); +}); diff --git a/packages/web-shell/client/components/dialogs/GitDiffDialog.tsx b/packages/web-shell/client/components/dialogs/GitDiffDialog.tsx new file mode 100644 index 00000000000..91dbbd57803 --- /dev/null +++ b/packages/web-shell/client/components/dialogs/GitDiffDialog.tsx @@ -0,0 +1,445 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useEffect, useRef, useState, type ReactNode } from 'react'; +import { useWorkspace } from '@qwen-code/webui/daemon-react-sdk'; +import type { + DaemonDiffHunk, + DaemonWorkspaceGitDiff, + DaemonWorkspaceGitDiffFile, +} from '@qwen-code/sdk/daemon'; +import type { BundledLanguage, ThemedToken } from 'shiki'; +import { useI18n } from '../../i18n'; +import { useTheme, WebShellThemeId } from '../../themeContext'; +import { + getCodeHighlighter, + isTooLargeToHighlight, +} from '../messages/codeHighlighter'; +import { resolveFenceLanguage } from '../messages/Markdown'; +import { languageForPath } from '../messages/ToolGroup'; +import { sanitizeControlChars } from '../messages/toolFormatting'; +import { DialogShell } from './DialogShell'; +import styles from './GitDiffDialog.module.css'; + +type RowType = 'add' | 'del' | 'context' | 'meta'; + +interface DiffRow { + type: RowType; + oldNo: number | null; + newNo: number | null; + text: string; + tokens: ThemedToken[] | null; +} + +const ROW_CLASS: Record = { + add: styles.diffLineAdd, + del: styles.diffLineDel, + context: styles.diffLineContext, + meta: styles.diffLineMeta, +}; + +function shikiThemeFor(theme: ReturnType): string { + return theme === WebShellThemeId.Light + ? 'github-light-default' + : 'github-dark-default'; +} + +// Build the unified-diff rows for a file's hunks, highlighting each side +// (context+added / context+removed) as its own code block so multi-line tokens +// (a comment or string crossing an add/delete boundary) still tokenize +// correctly. Each rendered line then pulls its tokens from the matching side: +// `+` from the new side, `-` from the old side, context from either (identical). +async function buildRows( + hunks: DaemonDiffHunk[], + path: string, + theme: string, +): Promise { + const { resolvedLang } = resolveFenceLanguage(languageForPath(path)); + let highlighter: Awaited> | null = null; + if (resolvedLang !== 'text') { + try { + highlighter = await getCodeHighlighter(resolvedLang); + } catch { + highlighter = null; + } + } + + const rows: DiffRow[] = []; + for (const hunk of hunks) { + const newSide: string[] = []; + const oldSide: string[] = []; + for (const line of hunk.lines) { + const prefix = line[0]; + const body = line.slice(1); + if (prefix === '+') newSide.push(body); + else if (prefix === '-') oldSide.push(body); + else if (prefix === ' ') { + newSide.push(body); + oldSide.push(body); + } + } + const newCode = newSide.join('\n'); + const oldCode = oldSide.join('\n'); + let newTokens: ThemedToken[][] | null = null; + let oldTokens: ThemedToken[][] | null = null; + if (highlighter) { + // resolvedLang is a real Shiki language id here ('text' was filtered out + // before the highlighter was loaded). + const lang = resolvedLang as BundledLanguage; + // Highlight each side independently so a small side keeps its tokens even + // when the other side exceeds the size cap. + if (!isTooLargeToHighlight(newCode)) { + try { + newTokens = highlighter.codeToTokens(newCode, { lang, theme }).tokens; + } catch { + newTokens = null; + } + } + if (!isTooLargeToHighlight(oldCode)) { + try { + oldTokens = highlighter.codeToTokens(oldCode, { lang, theme }).tokens; + } catch { + oldTokens = null; + } + } + } + + let ni = 0; + let oi = 0; + let oldNo = hunk.oldStart; + let newNo = hunk.newStart; + for (const line of hunk.lines) { + const prefix = line[0]; + const body = line.slice(1); + if (prefix === '+') { + rows.push({ + type: 'add', + oldNo: null, + newNo, + text: body, + tokens: newTokens?.[ni] ?? null, + }); + ni++; + newNo++; + } else if (prefix === '-') { + rows.push({ + type: 'del', + oldNo, + newNo: null, + text: body, + tokens: oldTokens?.[oi] ?? null, + }); + oi++; + oldNo++; + } else if (prefix === ' ') { + rows.push({ + type: 'context', + oldNo, + newNo, + text: body, + tokens: newTokens?.[ni] ?? null, + }); + ni++; + oi++; + oldNo++; + newNo++; + } else { + // e.g. "\ No newline at end of file" — a neutral marker, no line number. + rows.push({ + type: 'meta', + oldNo: null, + newNo: null, + text: line, + tokens: null, + }); + } + } + } + return rows; +} + +function renderContent(row: DiffRow): ReactNode { + if (!row.tokens || row.tokens.length === 0) return row.text; + return row.tokens.map((token, index) => ( + + {token.content} + + )); +} + +function DiffHunks({ hunks, path }: { hunks: DaemonDiffHunk[]; path: string }) { + const { t } = useI18n(); + const theme = useTheme(); + const shikiTheme = shikiThemeFor(theme); + const [rows, setRows] = useState(null); + const [failed, setFailed] = useState(false); + + useEffect(() => { + let cancelled = false; + setRows(null); + setFailed(false); + buildRows(hunks, path, shikiTheme) + .then((built) => { + if (!cancelled) setRows(built); + }) + // Highlighter failures degrade to plain text inside buildRows; this + // catches the unexpected (e.g. malformed hunk lines), which would + // otherwise be an unhandled rejection leaving `rows` stuck at null with + // no feedback. + .catch(() => { + if (!cancelled) setFailed(true); + }); + return () => { + cancelled = true; + }; + }, [hunks, path, shikiTheme]); + + if (failed) { + return ( +
{t('gitDiff.fileError')}
+ ); + } + + // null while the rows are first built and again while re-tokenizing after a + // theme switch; show a placeholder instead of an empty, jumpily-resized box. + if (rows === null) { + return
{t('gitDiff.loading')}
; + } + + return ( +
+ {(rows ?? []).map((row, index) => ( +
+ {row.oldNo ?? ''} + {row.newNo ?? ''} + + {row.type === 'add' + ? '+' + : row.type === 'del' + ? '-' + : row.type === 'meta' + ? '' + : ' '} + + {renderContent(row)} +
+ ))} +
+ ); +} + +function DiffFileRow({ + workspaceCwd, + file, +}: { + workspaceCwd: string; + file: DaemonWorkspaceGitDiffFile; +}) { + const { t } = useI18n(); + const { client } = useWorkspace(); + const [open, setOpen] = useState(false); + const [hunks, setHunks] = useState(null); + const [truncated, setTruncated] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); + // Guard the in-flight fetch so closing the dialog before it resolves doesn't + // settle state on an unmounted row (matching DiffHunks / GitDiffDialog). + const cancelledRef = useRef(false); + useEffect(() => { + // Reset on mount: StrictMode replays mount→unmount→mount and the ref + // persists across the replay, so without this reset the flag would stick at + // true and suppress every post-fetch state update (row stuck on "Loading"). + cancelledRef.current = false; + return () => { + cancelledRef.current = true; + }; + }, []); + + const toggle = () => { + const next = !open; + setOpen(next); + if (next && hunks === null && !loading && !file.isBinary) { + setLoading(true); + setError(false); + client + .workspaceByCwd(workspaceCwd) + // Pass the pre-rename path so a renamed file diffs old→new (rename + // detection) instead of showing the new path as fully added. + .workspaceGitDiffFile(file.path, file.oldPath) + .then((result) => { + if (cancelledRef.current) return; + setHunks(result.hunks); + setTruncated(result.truncated === true); + }) + .catch(() => { + if (!cancelledRef.current) setError(true); + }) + .finally(() => { + if (!cancelledRef.current) setLoading(false); + }); + } + }; + + const displayName = sanitizeControlChars(file.path); + + return ( +
+ + {open && ( +
+ {file.isBinary ? ( +
{t('gitDiff.binary')}
+ ) : loading ? ( +
{t('gitDiff.loading')}
+ ) : error ? ( +
+ {t('gitDiff.fileError')} +
+ ) : hunks && hunks.length > 0 ? ( + <> + + {truncated && ( +
+ {t('gitDiff.truncated')} +
+ )} + + ) : ( +
{t('gitDiff.noDiff')}
+ )} +
+ )} +
+ ); +} + +export function GitDiffDialog({ + workspaceCwd, + onClose, +}: { + workspaceCwd: string; + onClose: () => void; +}) { + const { t } = useI18n(); + const { client } = useWorkspace(); + const [diff, setDiff] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(false); + client + .workspaceByCwd(workspaceCwd) + .workspaceGitDiff() + .then((result) => { + if (!cancelled) setDiff(result); + }) + .catch(() => { + if (!cancelled) setError(true); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [client, workspaceCwd]); + + const subtitle = + diff && diff.available + ? t('gitDiff.summary', { + count: diff.filesCount, + added: diff.linesAdded, + removed: diff.linesRemoved, + }) + : undefined; + + let body: ReactNode; + if (loading) { + body =
{t('gitDiff.loading')}
; + } else if (error) { + body =
{t('gitDiff.error')}
; + } else if (!diff || !diff.available) { + body =
{t('gitDiff.unavailable')}
; + } else if (diff.files.length === 0) { + body =
{t('gitDiff.empty')}
; + } else { + body = ( +
+ {diff.files.map((file) => ( + + ))} + {diff.hiddenCount > 0 && ( +
+ {t('gitDiff.hidden', { count: diff.hiddenCount })} +
+ )} +
+ ); + } + + return ( + + {body} + + ); +} diff --git a/packages/web-shell/client/components/messages/toolFormatting.test.ts b/packages/web-shell/client/components/messages/toolFormatting.test.ts index c812899a650..eb63714a1b2 100644 --- a/packages/web-shell/client/components/messages/toolFormatting.test.ts +++ b/packages/web-shell/client/components/messages/toolFormatting.test.ts @@ -48,6 +48,15 @@ describe('toolFormatting', () => { ); expect(sanitizeControlChars('a\tb\nc')).toBe('a\tb\nc'); }); + + it('escapes Unicode bidi embedding/isolate controls', () => { + // RLO/LRE (U+202A–202E) and LRI/PDI (U+2066–2069) can visually reorder + // a filename to spoof its extension (mirrors the CLI-side coverage). + expect(sanitizeControlChars('a\u202eb')).toBe('a\\u202eb'); + expect(sanitizeControlChars('a\u202ab')).toBe('a\\u202ab'); + expect(sanitizeControlChars('a\u2066b')).toBe('a\\u2066b'); + expect(sanitizeControlChars('a\u2069b')).toBe('a\\u2069b'); + }); }); it('normalizes web fetch display names', () => { diff --git a/packages/web-shell/client/components/messages/toolFormatting.ts b/packages/web-shell/client/components/messages/toolFormatting.ts index 89c2ba36b88..49b1fb2ade4 100644 --- a/packages/web-shell/client/components/messages/toolFormatting.ts +++ b/packages/web-shell/client/components/messages/toolFormatting.ts @@ -69,9 +69,13 @@ export const TOOL_DISPLAY_NAMES: Record = { * collapse whitespace before rendering single-line labels. */ // Matches bare C0/C1 control bytes but not `\n`/`\t` (mirrors the CLI's -// MULTILINE_CONTROL_CHARS_REGEX). -// eslint-disable-next-line no-control-regex -const CONTROL_CHARS_REGEX = /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g; +// MULTILINE_CONTROL_CHARS_REGEX), plus the Unicode bidi embedding/isolate +// controls (U+202A–202E, U+2066–2069) so a crafted filename can't visually +// reorder or spoof its extension (bidi/"trojan source" style attacks). +/* eslint-disable no-control-regex */ +const CONTROL_CHARS_REGEX = + /[\x00-\x08\x0b-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g; +/* eslint-enable no-control-regex */ export function sanitizeControlChars(text: string): string { return text.replace(CONTROL_CHARS_REGEX, (ch) => { diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx index 22cae18d5d6..9e3f31a624f 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx @@ -235,6 +235,11 @@ interface WebShellSidebarProps { */ selectedWorkspaceCwd?: string; onSelectWorkspace?: (workspaceCwd: string | undefined) => void; + /** + * Open the working-tree Changes dialog for a workspace. Forwarded to each + * trusted workspace's folder header, where a live git chip fires it on click. + */ + onOpenGitDiff?: (workspaceCwd: string) => void; workspaces?: DaemonWorkspaceCapability[]; lockedWorkspaceCwd?: string; lockedWorkspace?: WebShellSidebarLockedWorkspace; @@ -422,6 +427,7 @@ export function WebShellSidebar({ sessionListReloadToken, selectedWorkspaceCwd, onSelectWorkspace, + onOpenGitDiff, workspaces: providedWorkspaces, lockedWorkspaceCwd, lockedWorkspace: lockedWorkspaceOptions, @@ -3493,6 +3499,7 @@ export function WebShellSidebar({ deleteGroupLabel={t('sidebar.groupDelete')} groupActionsDisabled={groupBusy} excludePinned + onOpenGitDiff={onOpenGitDiff} formatTime={(iso) => formatRelativeTime(iso, t)} searchQuery={searchQuery} expanded={ws.primary ? projectExpanded : undefined} diff --git a/packages/web-shell/client/components/sidebar/WorkspaceSection.module.css b/packages/web-shell/client/components/sidebar/WorkspaceSection.module.css index 3d3e6da4b69..bfdd67b914b 100644 --- a/packages/web-shell/client/components/sidebar/WorkspaceSection.module.css +++ b/packages/web-shell/client/components/sidebar/WorkspaceSection.module.css @@ -76,6 +76,25 @@ border-radius: 4px; } +/* Live git chip in the folder header — icon-only (the chip's `compact` form): a + status dot on the branch icon conveys dirty / conflict / in-progress at a + glance, while the branch name + ahead/behind live in the hover tooltip. + Rendered as a sibling of the header button (buttons can't nest), kept snug + after the folder name. The chip is a fixed 28px box, so the pill grows only to + carry the spare width to the hover actions on the right. */ +.gitPill { + display: inline-flex; + flex: 1 1 auto; + min-width: 0; +} + +/* With a chip present, let the pill — not the folder name — own the spare width, + so the icon sits right after the name and the hover actions stay pinned to the + right edge. Without a chip the header keeps growing as before. */ +.headerRow:has(.gitPill) .header { + flex-grow: 0; +} + /* Trusted sessions use the sidebar's shared row with its own left indent; read-only rows apply the same indent below. */ .sessions { diff --git a/packages/web-shell/client/components/sidebar/WorkspaceSection.test.tsx b/packages/web-shell/client/components/sidebar/WorkspaceSection.test.tsx new file mode 100644 index 00000000000..278ff271a9f --- /dev/null +++ b/packages/web-shell/client/components/sidebar/WorkspaceSection.test.tsx @@ -0,0 +1,236 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import type { ReactNode } from 'react'; +import type { + DaemonClient, + DaemonSessionSummary, + DaemonWorkspaceCapability, + DaemonWorkspaceGitStatus, +} from '@qwen-code/sdk/daemon'; +import gitStyles from '../ChatEditor.module.css'; + +const { workspaceGit } = vi.hoisted(() => ({ + workspaceGit: vi.fn(), +})); + +// A stable client whose `workspaceByCwd` always returns the same `workspaceGit` +// mock, so call assertions accumulate regardless of how often the component +// re-resolves the workspace handle. +function makeClient(): DaemonClient { + return { + workspaceByCwd: vi.fn(() => ({ + workspaceGit, + listWorkspaceSessions: vi.fn().mockResolvedValue([]), + listSessionGroups: vi.fn().mockResolvedValue({ groups: [] }), + })), + } as unknown as DaemonClient; +} + +const { I18nProvider } = await import('../../i18n'); +const { WorkspaceSection } = await import('./WorkspaceSection'); + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; +if (!globalThis.PointerEvent) { + globalThis.PointerEvent = MouseEvent as typeof PointerEvent; +} +if (!Element.prototype.hasPointerCapture) { + Element.prototype.hasPointerCapture = () => false; +} +if (!Element.prototype.setPointerCapture) { + Element.prototype.setPointerCapture = () => {}; +} +if (!Element.prototype.releasePointerCapture) { + Element.prototype.releasePointerCapture = () => {}; +} + +const trustedWorkspace: DaemonWorkspaceCapability = { + id: 'primary', + cwd: '/tmp/project', + primary: true, + trusted: true, + removable: false, +}; + +const untrustedWorkspace: DaemonWorkspaceCapability = { + id: 'danger', + cwd: '/tmp/danger', + primary: false, + trusted: false, + removable: true, +}; + +let root: Root; +let container: HTMLDivElement; + +function renderSection( + overrides: Partial<{ + workspace: DaemonWorkspaceCapability; + onOpenGitDiff: (cwd: string) => void; + client: DaemonClient; + reloadToken: number; + }> = {}, +): void { + act(() => { + root.render( + + ''} + renderSession={(session: DaemonSessionSummary): ReactNode => ( +
{session.displayName}
+ )} + onOpenGitDiff={overrides.onOpenGitDiff} + /> +
, + ); + }); +} + +async function flush(): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +function gitChip(): HTMLElement | null { + return container.querySelector('[data-web-shell-git-branch]'); +} + +beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + workspaceGit.mockReset(); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.restoreAllMocks(); +}); + +describe('WorkspaceSection git chip', () => { + it('renders a clickable git chip for a trusted repo and opens its diff', async () => { + const status: DaemonWorkspaceGitStatus = { + v: 2, + workspaceCwd: '/tmp/project', + branch: 'main', + unstaged: 1, + }; + workspaceGit.mockResolvedValue(status); + const onOpenGitDiff = vi.fn(); + + renderSection({ onOpenGitDiff }); + await flush(); + + const chip = gitChip(); + expect(chip).not.toBeNull(); + expect(chip?.tagName).toBe('BUTTON'); + expect(chip?.getAttribute('data-dirty')).toBe('true'); + // Icon-only (compact) form: the branch name is not shown as inline text but + // stays reachable via the accessible name (the hover tooltip). + expect(chip?.className).toContain(gitStyles.gitBranchChipCompact); + expect(chip?.getAttribute('aria-label')).toContain('main'); + + act(() => { + chip?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect(onOpenGitDiff).toHaveBeenCalledWith('/tmp/project'); + }); + + it('hides the chip for an untrusted workspace and never queries git', async () => { + workspaceGit.mockResolvedValue({ + v: 2, + workspaceCwd: '/tmp/danger', + branch: 'main', + }); + + renderSection({ + workspace: untrustedWorkspace, + onOpenGitDiff: vi.fn(), + }); + await flush(); + + expect(gitChip()).toBeNull(); + expect(workspaceGit).not.toHaveBeenCalled(); + }); + + it('skips the git poll when the workspace cwd is not a real path', async () => { + // A synthetic fallback workspace carries a display name in `cwd`; polling + // would qualify the route with it and 400, so no request fires and the chip + // stays hidden. + workspaceGit.mockResolvedValue({ + v: 2, + workspaceCwd: 'Project', + branch: 'main', + }); + + renderSection({ + workspace: { ...trustedWorkspace, cwd: 'Project' }, + onOpenGitDiff: vi.fn(), + }); + await flush(); + + expect(workspaceGit).not.toHaveBeenCalled(); + expect(gitChip()).toBeNull(); + }); + + it('re-fetches git status when reloadToken changes', async () => { + // reloadToken is in the polling effect's dependency array so agent activity + // (which bumps it) refreshes the chip immediately instead of waiting for the + // next 60s tick. A stable client isolates the re-fetch to the token change. + workspaceGit.mockResolvedValue({ + v: 2, + workspaceCwd: '/tmp/project', + branch: 'main', + }); + const client = makeClient(); + const onOpenGitDiff = vi.fn(); + + renderSection({ client, reloadToken: 0, onOpenGitDiff }); + await flush(); + expect(workspaceGit).toHaveBeenCalledTimes(1); + + renderSection({ client, reloadToken: 1, onOpenGitDiff }); + await flush(); + expect(workspaceGit).toHaveBeenCalledTimes(2); + }); + + it('hides the chip when the workspace is not a git repo (null branch)', async () => { + workspaceGit.mockResolvedValue({ + v: 2, + workspaceCwd: '/tmp/project', + branch: null, + }); + + renderSection({ onOpenGitDiff: vi.fn() }); + await flush(); + + expect(workspaceGit).toHaveBeenCalled(); + expect(gitChip()).toBeNull(); + }); + + it('omits the chip when no diff handler is provided', async () => { + workspaceGit.mockResolvedValue({ + v: 2, + workspaceCwd: '/tmp/project', + branch: 'main', + }); + + renderSection({ onOpenGitDiff: undefined }); + await flush(); + + expect(gitChip()).toBeNull(); + }); +}); diff --git a/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx b/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx index 09f108a0281..851382fd578 100644 --- a/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx +++ b/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, + useRef, useState, type ReactNode, } from 'react'; @@ -10,8 +11,10 @@ import type { DaemonSessionGroup, DaemonSessionSummary, DaemonWorkspaceCapability, + DaemonWorkspaceGitStatus, } from '@qwen-code/sdk/daemon'; import { FolderClosedIcon, FolderOpenIcon } from 'lucide-react'; +import { GitBranchIndicator } from '../GitBranchIndicator'; import { SESSION_LIST_PAGE_SIZE } from '../../constants/sessions'; import { readWorkspaceCollapsedGroupIds, @@ -29,6 +32,16 @@ function getWorkspaceName(cwd: string): string { return parts.at(-1) ?? cwd; } +// The cwd-qualified daemon route only accepts a workspace id or absolute path. +// A synthetic fallback workspace (daemon reports no workspaces and the +// connection has no cwd) carries a display name in `cwd`, which is neither, so +// qualifying a request with it would only ever 400. +function isAbsolutePath(cwd: string): boolean { + return ( + cwd.startsWith('/') || cwd.startsWith('\\') || /^[a-zA-Z]:[\\/]/.test(cwd) + ); +} + function getSessionLabel(session: DaemonSessionSummary): string { const displayName = session.displayName?.trim(); return displayName || session.sessionId.slice(0, 8); @@ -78,6 +91,12 @@ interface WorkspaceSectionProps { deleteGroupLabel?: string; groupActionsDisabled?: boolean; excludePinned?: boolean; + /** + * Open the working-tree Changes dialog for this workspace. When provided, the + * folder header shows a live git chip (branch + dirty/ahead-behind state) that + * fires this on click. Omitted for untrusted workspaces (no git surface). + */ + onOpenGitDiff?: (workspaceCwd: string) => void; } export function WorkspaceSection({ @@ -106,6 +125,7 @@ export function WorkspaceSection({ deleteGroupLabel, groupActionsDisabled, excludePinned = false, + onOpenGitDiff, }: WorkspaceSectionProps) { const [sessions, setSessions] = useState([]); const [groups, setGroups] = useState([]); @@ -115,6 +135,7 @@ export function WorkspaceSection({ readWorkspaceCollapsedGroupIds(workspace.id), ); const [actionsVisible, setActionsVisible] = useState(false); + const [gitStatus, setGitStatus] = useState(); const expanded = controlledExpanded ?? internalExpanded; const readOnly = !workspace.primary && !workspace.trusted; const disabled = workspace.primary && !workspace.trusted; @@ -201,6 +222,58 @@ export function WorkspaceSection({ searchQuery, ]); + // Undefined when `cwd` is not a real path (synthetic fallback workspace), so + // the poll — which qualifies the route with the cwd — is skipped entirely. + const gitPollCwd = isAbsolutePath(workspace.cwd) ? workspace.cwd : undefined; + + // Log a poll failure only on the success→failure transition, not on every + // 60s/focus tick, so an unreachable workspace doesn't spam a long-lived tab. + const gitPollFailed = useRef(false); + const loadGitStatus = useCallback(async () => { + if (!onOpenGitDiff || !workspace.trusted || !gitPollCwd) return; + try { + const status = await client.workspaceByCwd(gitPollCwd).workspaceGit(); + gitPollFailed.current = false; + setGitStatus(status); + } catch (err) { + // Keep the last known status on a transient failure so a brief network + // or daemon blip doesn't blank the chip for a whole poll interval; log + // only on the success→failure transition. + if (!gitPollFailed.current) { + console.warn('[WorkspaceSection] git status poll failed:', err); + gitPollFailed.current = true; + } + } + }, [client, gitPollCwd, onOpenGitDiff, workspace.trusted]); + + // The git chip lives in the always-visible folder header, so it polls + // independently of session expansion: on mount/trust, on window focus, and on + // a visibility-gated 60s tick (the daemon recomputes the working-tree summary + // per call, so the cadence stays gentle). Skipped entirely when no diff + // handler is wired, since the chip — its only consumer — would not render. + useEffect(() => { + if (!onOpenGitDiff || !workspace.trusted || !gitPollCwd) { + setGitStatus(undefined); + return; + } + void loadGitStatus(); + const onFocus = () => void loadGitStatus(); + window.addEventListener('focus', onFocus); + const timer = window.setInterval(() => { + if (document.visibilityState === 'visible') void loadGitStatus(); + }, 60_000); + return () => { + window.removeEventListener('focus', onFocus); + window.clearInterval(timer); + }; + }, [ + gitPollCwd, + loadGitStatus, + onOpenGitDiff, + reloadToken, + workspace.trusted, + ]); + const visibleSessions = useMemo(() => { const query = searchQuery.trim().toLowerCase(); return sessions.filter((session) => { @@ -278,6 +351,16 @@ export function WorkspaceSection({ )} + {onOpenGitDiff && workspace.trusted && gitStatus?.branch && ( + + onOpenGitDiff(workspace.cwd)} + /> + + )} {headerActions?.(actionsVisible)} {renderSessions && diff --git a/packages/web-shell/client/components/ui/alert-dialog.tsx b/packages/web-shell/client/components/ui/alert-dialog.tsx index 88699e1b5b7..8bc2ac754ea 100644 --- a/packages/web-shell/client/components/ui/alert-dialog.tsx +++ b/packages/web-shell/client/components/ui/alert-dialog.tsx @@ -42,7 +42,9 @@ const AlertDialogOverlay = React.forwardRef< ref={ref} data-slot="alert-dialog-overlay" className={cn( - 'fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0', + // No backdrop-blur: see DialogOverlay — blurring the whole backdrop on + // open freezes the page when a long transcript sits behind it. + 'fixed inset-0 z-50 bg-black/10 duration-100 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0', className, )} {...props} diff --git a/packages/web-shell/client/components/ui/dialog.tsx b/packages/web-shell/client/components/ui/dialog.tsx index 26360c9e2f4..acee8c4cc42 100644 --- a/packages/web-shell/client/components/ui/dialog.tsx +++ b/packages/web-shell/client/components/ui/dialog.tsx @@ -49,7 +49,11 @@ const DialogOverlay = React.forwardRef< ref={ref} data-slot="dialog-overlay" className={cn( - 'fixed inset-0 isolate z-[var(--web-shell-dialog-backdrop-z-index,50)] bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0', + // No backdrop-blur: it forces the browser to rasterize and blur the + // entire content behind the overlay on open, which freezes the page + // when a long transcript sits behind it. The bg-black/10 scrim keeps + // the visual separation without that cost. + 'fixed inset-0 isolate z-[var(--web-shell-dialog-backdrop-z-index,50)] bg-black/10 duration-100 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0', className, )} {...props} diff --git a/packages/web-shell/client/components/ui/tooltip.tsx b/packages/web-shell/client/components/ui/tooltip.tsx index f7819dd10f6..fc8b6e485df 100644 --- a/packages/web-shell/client/components/ui/tooltip.tsx +++ b/packages/web-shell/client/components/ui/tooltip.tsx @@ -42,13 +42,13 @@ function TooltipContent({ data-slot="tooltip-content" sideOffset={sideOffset} className={cn( - 'z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95', + 'z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md border border-border bg-popover px-3 py-1.5 text-xs text-popover-foreground has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95', className, )} {...props} > {children} - + ); diff --git a/packages/web-shell/client/constants/localCommands.ts b/packages/web-shell/client/constants/localCommands.ts index 00516e672bf..909b39f943d 100644 --- a/packages/web-shell/client/constants/localCommands.ts +++ b/packages/web-shell/client/constants/localCommands.ts @@ -89,6 +89,7 @@ export function getLocalCommands(t: Translate): CommandInfo[] { description: t('local.branch'), argumentHint: '[]', }, + { name: 'diff', description: t('local.diff') }, { name: 'fork', description: t('local.fork'), diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 6172d1bd71a..cbc5d5bc48c 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -17,6 +17,36 @@ type Messages = Record; const EN: Messages = { 'git.currentBranch': (v) => `Current Git branch: ${v?.branch ?? ''}`, + 'git.detached': 'Detached HEAD', + 'git.clean': 'Working tree clean', + 'git.operation.merge': 'Merging', + 'git.operation.rebase': 'Rebasing', + 'git.operation.cherry-pick': 'Cherry-picking', + 'git.operation.revert': 'Reverting', + 'git.operation.bisect': 'Bisecting', + 'git.conflicted': (v) => `${v?.count ?? 0} conflicted`, + 'git.staged': (v) => `${v?.count ?? 0} staged`, + 'git.unstaged': (v) => `${v?.count ?? 0} modified`, + 'git.untracked': (v) => `${v?.count ?? 0} untracked`, + 'git.ahead': (v) => `${v?.count ?? 0} ahead`, + 'git.behind': (v) => `${v?.count ?? 0} behind`, + 'git.stash': (v) => `${v?.count ?? 0} stashed`, + 'gitDiff.title': 'Changes', + 'gitDiff.summary': (v) => + `${v?.count ?? 0} files · +${v?.added ?? 0} −${v?.removed ?? 0}`, + 'gitDiff.loading': 'Loading changes…', + 'gitDiff.empty': 'No changes in the working tree', + 'gitDiff.unavailable': 'Git is not available for this workspace', + 'gitDiff.error': 'Failed to load changes', + 'gitDiff.binary': 'Binary', + 'gitDiff.untracked': 'Untracked', + 'gitDiff.deleted': 'Deleted', + 'gitDiff.noDiff': 'No changes to display', + 'gitDiff.fileError': 'Failed to load this diff', + 'gitDiff.truncated': 'Diff truncated — the file is too large to show in full', + 'gitDiff.hidden': (v) => `${v?.count ?? 0} more file(s) not shown`, + 'gitDiff.expand': (v) => `Show changes for ${v?.path ?? 'file'}`, + 'gitDiff.collapse': (v) => `Hide changes for ${v?.path ?? 'file'}`, 'workspace.paneLabel': (v) => `Workspace: ${v?.name ?? ''}`, 'about.auth': 'Auth', 'about.baseUrl': 'Base URL', @@ -1061,6 +1091,8 @@ const EN: Messages = { 'language.usage': 'Usage: /language ui [en|zh-CN]', 'localCommand.noSession': 'No active session yet. Send your first message before using this command.', + 'localCommand.diffNoWorkspace': + 'No workspace is available yet to show changes for.', 'local.agents': 'Manage subagents', 'local.bug': 'Submit a bug report', 'local.compress': 'Compress the context into a summary', @@ -2080,6 +2112,36 @@ const EN: Messages = { const ZH: Messages = { ...EN, 'git.currentBranch': (v) => `当前 Git 分支:${v?.branch ?? ''}`, + 'git.detached': '游离 HEAD', + 'git.clean': '工作区干净', + 'git.operation.merge': '合并中', + 'git.operation.rebase': '变基中', + 'git.operation.cherry-pick': '拣选中', + 'git.operation.revert': '回退中', + 'git.operation.bisect': '二分中', + 'git.conflicted': (v) => `${v?.count ?? 0} 个冲突`, + 'git.staged': (v) => `${v?.count ?? 0} 已暂存`, + 'git.unstaged': (v) => `${v?.count ?? 0} 已修改`, + 'git.untracked': (v) => `${v?.count ?? 0} 未跟踪`, + 'git.ahead': (v) => `领先 ${v?.count ?? 0}`, + 'git.behind': (v) => `落后 ${v?.count ?? 0}`, + 'git.stash': (v) => `${v?.count ?? 0} 个 stash`, + 'gitDiff.title': '变更', + 'gitDiff.summary': (v) => + `${v?.count ?? 0} 个文件 · +${v?.added ?? 0} −${v?.removed ?? 0}`, + 'gitDiff.loading': '加载变更中…', + 'gitDiff.empty': '工作区无变更', + 'gitDiff.unavailable': '此工作区无 Git', + 'gitDiff.error': '加载变更失败', + 'gitDiff.binary': '二进制', + 'gitDiff.untracked': '未跟踪', + 'gitDiff.deleted': '已删除', + 'gitDiff.noDiff': '无差异可显示', + 'gitDiff.fileError': '加载此差异失败', + 'gitDiff.truncated': '差异已截断——文件过大,未完整显示', + 'gitDiff.hidden': (v) => `还有 ${v?.count ?? 0} 个文件未显示`, + 'gitDiff.expand': (v) => `显示 ${v?.path ?? '文件'} 的变更`, + 'gitDiff.collapse': (v) => `隐藏 ${v?.path ?? '文件'} 的变更`, 'workspace.paneLabel': (v) => `工作区:${v?.name ?? ''}`, // Tool display names (chat-stream badge labels). Keyed by `toolName.`; // a wire name with no entry here falls back to the English display name via @@ -3123,6 +3185,7 @@ const ZH: Messages = { 'language.usage': '用法:/language ui [en|zh-CN]', 'localCommand.noSession': '当前还没有会话。请先发送第一条消息,再使用这个命令。', + 'localCommand.diffNoWorkspace': '当前还没有可用于查看变更的工作区。', 'local.agents': '管理智能体', 'local.bug': '提交错误报告', 'local.compress': '将上下文压缩为摘要', diff --git a/scripts/prepare.js b/scripts/prepare.js index e55623afb81..ddd8d9af844 100644 --- a/scripts/prepare.js +++ b/scripts/prepare.js @@ -13,7 +13,15 @@ const skipPrepare = ['1', 'true'].includes( ); if (skipPrepare) { - console.log('Skipping prepare because QWEN_SKIP_PREPARE is set.'); + // The heavy build/bundle/husky are skipped, but git-commit.ts (gitignored, + // imported by e.g. cli's systemInfo) is still required to build or typecheck + // the packages that import it. Generate it here so a later per-workspace + // build/typecheck — such as the review tooling's — doesn't fail on the + // missing module. The non-skip path generates it via `npm run build`. + run('npm', ['run', 'generate']); + console.log( + 'Skipping prepare build/bundle/husky because QWEN_SKIP_PREPARE is set.', + ); process.exit(0); } diff --git a/scripts/tests/package-scripts.test.js b/scripts/tests/package-scripts.test.js index 5236a4160d4..e04387f7005 100644 --- a/scripts/tests/package-scripts.test.js +++ b/scripts/tests/package-scripts.test.js @@ -104,26 +104,64 @@ describe('package scripts', () => { expect(vscodePackageJson.scripts['test:ci']).toContain('--coverage'); }); - it('can skip root prepare work for CI installs that build explicitly', () => { + it('skips build/bundle/husky but still generates git-commit info when CI builds explicitly', () => { const packageJson = readPackageJson(); expect(packageJson.scripts.prepare).toBe('node scripts/prepare.js'); - const result = spawnSync( - process.execPath, - [path.join(root, 'scripts/prepare.js')], - { - cwd: root, - encoding: 'utf8', - env: { - ...process.env, - QWEN_SKIP_PREPARE: '1', + const binDir = mkdtempSync(path.join(tmpdir(), 'qwen-prepare-skip-')); + const logFile = path.join(binDir, 'commands.log'); + writeFileSync(logFile, ''); + + try { + if (process.platform === 'win32') { + writeFileSync( + path.join(binDir, 'husky.cmd'), + '@echo husky >> "%PREPARE_LOG_FILE%"\r\n', + ); + writeFileSync( + path.join(binDir, 'npm.cmd'), + '@echo npm %* >> "%PREPARE_LOG_FILE%"\r\n', + ); + } else { + writeFileSync( + path.join(binDir, 'husky'), + '#!/bin/sh\necho husky >> "$PREPARE_LOG_FILE"\n', + ); + writeFileSync( + path.join(binDir, 'npm'), + '#!/bin/sh\necho "npm $*" >> "$PREPARE_LOG_FILE"\n', + ); + chmodSync(path.join(binDir, 'husky'), 0o755); + chmodSync(path.join(binDir, 'npm'), 0o755); + } + + const result = spawnSync( + process.execPath, + [path.join(root, 'scripts/prepare.js')], + { + cwd: root, + encoding: 'utf8', + env: { + ...process.env, + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}`, + PREPARE_LOG_FILE: logFile, + QWEN_SKIP_PREPARE: '1', + }, }, - }, - ); + ); - expect(result.status).toBe(0); - expect(result.stdout).toContain('Skipping prepare'); + expect(result.status).toBe(0); + expect(result.stdout).toContain('Skipping prepare'); + // git-commit info is still generated so a later per-workspace build or + // typecheck (e.g. the review tooling's) doesn't fail on the missing + // module; the heavy build/bundle/husky are skipped. + expect(readFileSync(logFile, 'utf8').trim().split(/\r?\n/)).toEqual([ + 'npm run generate', + ]); + } finally { + rmSync(binDir, { recursive: true, force: true }); + } }); it('runs prepare steps in order when CI does not skip prepare', () => {