* feat(web-shell): git status chip, visual working-tree diff, and sidebar git status
Bring working-tree Git awareness to the Web Shell (browser daemon session UI):
- Toolbar branch chip becomes a live status indicator: dirty (staged/unstaged/
untracked), ahead/behind upstream, stash count, detached HEAD, in-progress
operation (merge/rebase/cherry-pick/revert/bisect), and conflict count, each
with a non-color cue.
- Read-only "Changes" dialog: working-tree-vs-HEAD file list with per-file,
line-level, per-side syntax-highlighted diffs; opens via /diff or a dirty
chip; untracked files expand as fully-added and deleted files still diff.
- Per-workspace git status in the sidebar: a compact icon-only chip per trusted
workspace (status dot + hover tooltip); click opens that workspace's dialog.
All git access goes through the daemon REST API with per-workspace trust
gating; new SDK status fields are optional and additive (v2).
* fix(web-shell): themed tooltips and git-chip review follow-ups
Tooltips now render on the themed popover surface (bg-popover /
text-popover-foreground / border + fill-popover arrow) instead of the
inverted bg-foreground default, so they read dark-on-dark rather than a
bright box on the dark theme. Fixing the shared primitive corrects the
git branch tooltip in the composer toolbar and sidebar, plus every other
tooltip, at once.
Also addressing review feedback on the git integration:
- Replace the hand-drawn detached/conflict/stash SVG icons with
lucide-react (CircleDot / TriangleAlert / Layers) per the web-shell
icon convention.
- Gate the tooltip "Working tree clean" message on an enriched status
(computedAt) so a branch-only status no longer asserts clean.
- Include the file path in the diff dialog row aria-label so screen
readers can distinguish files.
- Reset the toolbar git chip on workspace switch so it never shows the
previous repo's branch/counts while the new fetch resolves.
- Log a sidebar git poll failure only on the success->failure transition
to avoid spamming a long-lived tab.
- Correct the SDK doc for DaemonWorkspaceGitDiffFile.added/removed
(0, not undefined, for binary files).
* fix(web-shell): address git-integration review suggestions
Follow-ups from the /review pass on the git integration:
- GitBranchIndicator: include the short SHA in the detached-HEAD tooltip
title, and add the "Working tree clean" status to the aria-label (gated
on an enriched status, matching the tooltip) so the two never drift.
- WorkspaceSection: keep the last known git status on a transient poll
failure instead of blanking the chip for a whole interval.
- App: surface a toast for `/diff` when no workspace is available instead
of silently consuming the composer input.
- Tests: cover the diff dialog's list-load and per-file load error paths,
and detectGitOperation's revert/bisect branches.
- Design doc: align the getGitWorkingTreeStatus spec text with the
decision (transient states return status with `operation`; null is
reserved for non-repo / git failure).
* fix(web-shell): focus-visible ring for git chip button; align doc poll interval
- Add a :focus-visible outline to .gitBranchChipButton so keyboard users
get a visible focus indicator (the chip resets UA button chrome).
- Design doc: align the active-workspace poll-interval references at 30s
to match the implementation.
* fix(web-shell): surface capped diffs, catch row-build failures, cover degradation paths
Address the remaining review findings on the git integration:
- Truncation is no longer silent: fetchGitDiffHunksForFile now returns
{ hunks, truncated } — the parser records files that actually lost
lines to MAX_LINES_PER_FILE (tracked path), and the untracked
synthesis reports its byte/line caps. The route forwards an additive
`truncated` flag on the hunks response (absent when not truncated, so
older clients and daemons are unaffected), and the Changes dialog
renders a "Diff truncated" note under the visible window.
- DiffHunks catches an unexpected buildRows rejection (e.g. malformed
hunk lines) and shows the per-file error instead of leaving an
unhandled rejection and a silently empty diff area.
- New tests: untracked and tracked truncation at the core caps, the
route's truncated passthrough (and its absence when clean), the
branch-only degradation when the working-tree summary throws, the
malformed-hunks error path, and the Shiki success path (a fake
tokenizer proving add rows pull new-side tokens and del rows pull
old-side tokens, not the plain-text fallback).
* fix(web-shell): drop dialog backdrop-blur that froze the page on open
The dialog and alert-dialog overlays applied `backdrop-blur-xs`, which
forces the browser to rasterize and blur the entire content behind the
overlay when a dialog opens. With a long transcript behind it, that
main-thread paint+blur froze the whole page — e.g. clicking the git
branch chip to open the Changes dialog. Keep the bg-black/10 scrim for
separation and drop the blur.
* fix(core): guard synthesizeUntrackedHunk against non-regular files
synthesizeUntrackedHunk opened an untracked path before checking its
type, so an untracked FIFO (listed by `ls-files --others`) would block
on open() forever waiting on a writer — hanging the daemon's event loop
and leaving the Web Shell Changes dialog stuck on a permanent loading
state. lstat-gate on regular files before opening, matching the existing
guard in countUntrackedLines. Adds a FIFO regression test.
* fix(web-shell,core): rename expansion, no-newline marker, chip measurement
Round-5 review Criticals:
- core: key renamed diff entries by the real (post-rename) path and carry
the old path for display, so renamed rows can be expanded — the synthetic
`old => new` key was sent to git as a nonexistent literal path. The diff
dialog renders the rename as `old → new`.
- core: preserve Git's `\ No newline at end of file` marker through the hunk
parser so a trailing-newline-only edit isn't shown as identical
removed/added lines (the viewer already renders it as a meta row).
- web-shell: the toolbar's hidden git-chip measurement replica now renders
the full chip content via the extracted GitBranchChipContent, so the
expanded width includes the status indicators and the compact/expanded
toggle no longer oscillates near the responsive threshold.
* fix(build): generate git-commit info even when prepare build is skipped
The review tooling runs `npm ci` with QWEN_SKIP_PREPARE=1 (to skip the
heavy prepare build) and then builds only the changed workspaces. Because
`prepare` exited before generating the gitignored git-commit.ts, a
per-workspace build of packages/cli failed at the unchanged systemInfo.ts
on the missing `../generated/git-commit.js` module. Generate the git-commit
info in the skip path too — it is cheap and never fails hard — so a later
per-workspace build or typecheck finds the module. The non-skip path still
generates it via `npm run build`.
* fix(web-shell,cli): address round-6 review suggestions
- cli: carry the pre-rename path (oldPath) through DiffRenderRow and show
renamed files as `old → new` in both the Ink and plain-text renderers.
The rename-keying fix updated the daemon and web-shell dialog but not the
CLI `/diff` renderer, which silently dropped the old path.
- web-shell: key DiffFileRow by workspace + path so switching workspace
remounts the row instead of reusing another workspace's hunks/open state
for a path both workspaces share.
- web-shell: show a loading placeholder in DiffHunks while rows are (re)built
(e.g. after a theme switch) instead of an empty, jumpily-resized box.
- web-shell: cover the /diff local intercept in App.test.tsx (opens the
Changes dialog and is not forwarded to the agent).
* fix(web-shell,cli,core): address round-7 review suggestions
- cli: sanitize the rendered filename (and pre-rename oldPath) in the Ink
DiffStatsDisplay via sanitizeFilenameForDisplay, matching the plain-text
renderer so a crafted path can't inject into the interactive view.
- cli: apply the read headers before awaiting the per-file diff fetch (as
handleDiffList does) so error responses also carry no-store/nosniff.
- cli + web-shell: strip Unicode bidi embedding/isolate controls
(U+202A-202E, U+2066-2069) in the filename/control-char sanitizers so a
crafted filename can't visually spoof its extension.
- core: guard countStashEntries with an lstat type check before readFile, so
a symlink-to-FIFO at logs/refs/stash can't block the event loop (the same
hazard already guarded in the untracked-file readers).
- core: cover fetchGitDiffHunksForFile's transient-state guard with a test
(the sibling helpers already had one).
* fix(web-shell,cli,core): address round-8 review suggestions
- core: pass --no-optional-locks to the ls-files call in
fetchGitDiffHunksForFile, matching the other runGit calls so it doesn't
contend for an optional index-refresh lock alongside concurrent git
add/commit.
- cli: add a route test asserting a rename's oldPath survives serialization
end-to-end (keyed by the new path, old path carried alongside).
- web-shell: add a GitDiffDialog test for the hiddenCount>0 "N more files
not shown" note (every payload previously used hiddenCount: 0).
- web-shell: drop the nonexistent primaryLabel prop from the WorkspaceSection
test (it is not a WorkspaceSectionProps member).
- docs: correct the plan doc — large-diff virtual scrolling was explicitly
descoped (core caps + per-file lazy loading), not implemented in Phase 2.
* fix(cli,web-shell): address round-9 review findings
- cli: propagate the pre-rename oldPath through DiffDialog's
perFileToUnified and render renamed files as `old → new` in the
interactive diff viewer (the rename-keying fix had updated the daemon,
the web-shell dialog, and the /diff stats, but not this viewer).
- cli: cover DiffStatsDisplay's rename (`old → new`) rendering and the
sanitizeFilenameForDisplay path for hostile filenames carrying control
characters.
- web-shell: guard the GitBranchIndicator test afterEach against
double-unmounting an already-unmounted root (the localization tests
assert on getTranslator without calling render()).
* fix(core,cli,web-shell): rename-aware single-file diff (old→new)
fetchGitDiffHunksForFile pathspec-limited the diff to the new path, which
defeats git's rename detection — a renamed file was reported as fully
added (every line +) instead of its actual edit. Thread an optional
pre-rename path through the single-file endpoint (core → route → SDK →
dialog) and diff old→new with -M when it is present, so expanding a
renamed file shows its real content change.
* fix(cli): address round-10 review suggestions
- DiffDialog: split the path-width budget between old and new paths for a
rename (reserving the " → " separator) so the combined width stays within
maxPathChars instead of overflowing the row layout.
- textUtils: extend MULTILINE_CONTROL_CHARS_REGEX with the Unicode bidi
ranges (matching FILENAME_CONTROL_CHARS_REGEX) and add a test that
sanitizeFilenameForDisplay strips bidi embedding/isolate controls.
- workspace-git-diff route: add a test that ?oldPath= is parsed and
forwarded to fetchGitDiffHunksForFile.
* test(sdk),docs: cover diff client methods; align design doc
- sdk: add DaemonClient unit tests for workspaceGitDiff() and
workspaceGitDiffFile(path, oldPath?) — URL construction (incl. urlEncode
on path/oldPath, with and without oldPath, plus the workspace-qualified
route) and response deserialization, mirroring the existing workspaceGit()
test.
- docs: add the oldPath? param to the workspaceGitDiffFile API spec; record
that the diff client methods now have unit tests (correcting the claim
that workspaceGit() had none); attribute the bundle-limit bump to
packages/sdk-typescript/scripts/build.js; clarify ahead/behind are relative
to upstream (0, and ↑N/↓N not shown, without one).
* fix(web-shell,core): address round-11 review suggestions
- GitBranchIndicator: count conflicted entries as dirty — a merge where every
changed file is conflicted (staged=unstaged=untracked=0) is still
uncommitted, so the expanded chip's dirty dot / data-dirty now reflect it.
- core: split the status branch line at the last "..." (the branch/upstream
separator) so a dotted branch name isn't truncated at the first "...".
- GitDiffDialog: guard DiffFileRow's in-flight fetch against unmount via a
cancelled ref, matching DiffHunks / GitDiffDialog.
- tests: forward oldPath when expanding a renamed file in the web-shell
dialog; bidi-strip coverage for the web-shell sanitizeControlChars;
untrusted-guard coverage on the single-file diff route; conflicted-only
dirty; branch-line "..." split.
* fix(web-shell,cli): address round-12 review suggestions
- DiffDialog: only render the rename "old → new" when there's room for both
sides (≥19 cols, so each gets ≥8); otherwise fall back to the new path
alone, so a narrow terminal no longer overflows the row (the Math.max(8,…)
floor could exceed maxPathChars).
- GitBranchIndicator test: guard afterEach container.remove() for non-render
tests run in isolation, and make the compact-mode ↑-suppression assertion
non-vacuous by giving the fixture an ahead count.
- App: compute the active workspace once (useMemo) and share it between the
git-status effect and the Changes-dialog entry point, so the chip and the
dialog can't drift onto different repos.
* fix(core,docs): address round-13 review suggestions
- core: add a rebase-apply detection test (git am / an interrupted
`rebase --apply` creates rebase-apply, which detectGitOperation also maps
to 'rebase'); previously only rebase-merge was exercised.
- docs: correct section 5 to describe the actual diff-dialog mechanism
(diffWorkspaceCwd state, not the stale activePanel design).
* test(core): cover stray no-newline marker before any hunk header
parseGitDiff's pre-hunk guard already skips a "\ No newline at end of
file" marker that appears before any @@ header, so a malformed/truncated
diff can't throw on a null currentHunk and lose subsequent files' hunks;
add a regression test pinning that behavior.
* fix(web-shell): unstick per-file diff loading and skip non-path git poll
- DiffFileRow: reset the cancelled-fetch flag on mount so StrictMode's
mount/unmount/mount replay no longer leaves it latched at true, which
dropped the fetched hunks and froze the row on "Loading changes…" despite
a 200 response.
- WorkspaceSection: skip the git status poll when the workspace cwd is not an
absolute path. A synthetic fallback workspace carries a display name there,
which the cwd-qualified route rejects with a 400.
* fix(web-shell,cli): address review suggestions on the git diff surface
- GitDiffDialog: highlight each diff side independently so a small side
keeps syntax highlighting even when the other side exceeds the size cap
(the old guard dropped both as soon as either was too large).
- ChatEditor: complete the .gitBranchChipButton reset (font/color/padding/
margin) so the clickable dirty-tree chip matches the read-only output chip
instead of picking up UA button styling.
- DiffDialog: cover the interactive rename display (old to new on a wide
terminal), mirroring the rename tests DiffStatsDisplay and GitDiffDialog
already have.
* test(web-shell,cli): cover git chip clean/reload/traversal paths, fix doc
- GitDiffDialog: add the missing expect(header).not.toBeNull() guard to the
three expand-file tests that lacked it, matching the others in the block.
- GitBranchIndicator: cover the known-clean aria-label branch (computedAt set
and every change counter zero).
- WorkspaceSection: verify a reloadToken change re-fetches git status instead
of waiting for the next 60s poll.
- workspace-git-diff route: verify a traversal oldPath is forwarded to core
and surfaced as available:false rather than escaping the workspace.
- Design doc: /diff is handled via setDiffWorkspaceCwd, not setActivePanel.
* fix(core): allow literal `..foo` paths in diff normalization
- toRepoRelativePath: reject only a real climb-out (`..` or `../…`), not a
literal `..foo` filename at the repo root, which the bare startsWith('..')
over-rejected, leaving the diff viewer unable to render such a file.
- parseGitDiff: cover the truncatedPaths output set directly (it was only
exercised indirectly through fetchGitDiffHunksForFile).
---------
Co-authored-by: wenshao <wenshao@example.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
55 KiB
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(监听<gitDir>/logs/HEADreflog)、readGitHead(区分 branch / detached)。gitDiff.ts:fetchGitDiff(工作区 vs HEAD 的 per-file 统计)、fetchGitDiffHunks(Map<string, Hunk[]>行级 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):多个订阅者共享一个对<gitDir>/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<string, Hunk[]>,内部执行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+buildDiffRenderModelrenderDiffModelText的纯文本。
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(来自 SSEgit_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 组件:
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 测量逻辑)。
现在(只有分支名):
┌─────────────────────────────────────────────┐
│ [⑂ main] [@ ⏎ 发送] │ ← 输入框工具栏
└─────────────────────────────────────────────┘
之后 · compact(空间不够时):
[⑂ main •]
└─ dirty 小圆点:有未提交改动时出现
之后 · expanded(空间够时,显示完整状态):
[⑂ main • ↑2 ↓1 ⧉3]
│ │ │ │ └─ stash 数量(3 个 stash)
│ │ │ └───── behind:落后 upstream 1 个 commit
│ │ └───────── ahead:领先 upstream 2 个 commit
│ └──────────── dirty 点
└──────────────── 分支名
几种特殊状态:
[⑂ 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 可滚动):
┌─ 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),与 CLIDiffDialog行为一致。- 非 git 仓库 / merge 进行中 → 弹窗显示占位文案(如“当前不是 git 仓库,或正在 merge / rebase”),不报错。
数据结构
第一层:enriched git status
扩展 WorkspaceGitStatus(daemon 端)与 DaemonWorkspaceGitStatus(SDK 端)。
新字段全部可选,v 升到 2,保证旧 daemon / 旧 client 互相兼容:
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)塞进单个响应:
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<GitWorkingTreeStatus | null>- 复用
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 数量:优先直读
<gitDir>/logs/refs/stash行数(与gitDirect.ts的直读哲学一致,避免第二个子进程);读不到则记 0。 - detached 由
readGitHead或 branch header(HEAD (no branch)/No commits yet)判定。 - 返回结构对齐
DaemonWorkspaceGitStatus的 v2 字段。
- 复用
fetchGitDiffHunksForFile(cwd, filePath): Promise<Hunk[] | null>- 执行
git --no-optional-locks diff --no-ext-diff --no-textconv HEAD -- <filePath>,复用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,合并出 enrichedWorkspaceGitStatus(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<DaemonWorkspaceGitDiff>workspaceGitDiffFile(path: string, oldPath?: string): Promise<DaemonWorkspaceGitDiffHunks>(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(<active cwd>), 不再发给 daemon。getLocalCommands中补diff的补全项与local.diff文案。
6. 刷新策略(第一层的新鲜度)
- branch:保持现状,
watchRepoBranch经git_branch_changed实时推送,热路径 直读,零额外成本。 - 重字段(dirty / ahead / behind / stash)在以下时机重新拉取
workspaceGit():- 用户打开 status popover 或
GitDiffDialog时(按需,权威)。 - 收到
git_branch_changed(commit / reset / 切分支都会同时改变这些值)。 - 标签页
visibilitychange重新可见时。 - 仅对当前选中 / 可见的 workspace做一次低速轮询(如 30s,可配置), 保证 dirty 点在编辑后“足够新”。不对所有 workspace 轮询。
- 用户打开 status popover 或
- 明确取舍:不对工作区文件树建立 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 客户端的纯文本输出不变(daemonrenderDiffModelText路径不动)。- 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 显示路径。
数据流:
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 ✅ 已完成
GitOperation类型 +GitWorkingTreeStatus(含conflicted/operation)getGitWorkingTreeStatus:git status --porcelain=v1 --branch -z一次调用- 直读 stash reflog +
detectGitOperation;transient 不再返回 null
- 直读 stash reflog +
parseStatusBranchLine/parseStatusEntries(含 unmerged → conflicted)- 单测:解析 + clean/dirty/detached/stash/ahead-behind/非仓库/merge/rebase/ cherry-pick(79 个通过)
Task 2 · daemon ✅ 已完成
WorkspaceGitStatus升级到 v2 + enriched 可选字段(dirty 部分)getStatus合并getGitWorkingTreeStatus(branch 用 watcher 缓存,重字段 每次现算)- 接口加
operation?/conflicted?;getStatus透传这两个字段 - 测试:enriched 输出用例补
operation/conflicted(14 个通过)
Task 3 · SDK ✅ 已完成
DaemonWorkspaceGitStatus加 v2 可选字段(detached/staged/unstaged/ untracked/conflicted/hasUpstream/ahead/behind/stashCount/operation/ computedAt),v: 1 | 2;新增DaemonGitOperation类型并从src/index.ts/src/daemon/index.ts导出workspaceGit()无需改(返回更多字段即可)- SDK 类型单测(v1 mock)仍通过
Task 4 · Web Shell ✅ 已完成
GitBranchIndicator入参从branch扩展为接收 enriched status;渲染: dirty 点、↑N/↓M、stash 角标、detached 换图标、operation 徽标 (REBASING/MERGING/…)、conflicted 数(非颜色兜底:形状+数字)- compact(图标角标)/ expanded 两种形态;保留
data-web-shell-git-branch App.tsx新增gitStatus状态,经workspaceGit()拉取;branch 仍用connection.gitBranch(SSE,实时)- 刷新策略(focus + branch 变化 + 仅当前 workspace 30s 可见性轮询)
ChatEditor透传 enriched status 到GitBranchIndicator- i18n(en + zh-CN):aria-label / tooltip / operation 文案
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 · 验证 ✅ 已完成
npm run build && npm run typecheck(全仓通过)npm run lint(改动文件全过)- 单测: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 点击联动。纯增量、只读、向后兼容。
数据流:
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) corefetchGitDiffHunksForFile把 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 助手 (已自动发响应);缺pathquery 返回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 ✅ 已完成
fetchGitDiffHunksForFile(cwd, filePath): Promise<Hunk[] | null>git --no-optional-locks diff --no-ext-diff --no-textconv HEAD -- <filePath>, 复用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。
- 单测:真实仓库改动单文件、未改文件、untracked 文件全新增、越界 path
(
gitDiff.test.ts共 88 用例通过)。
Task 2 · daemon ✅ 已完成
- 新建
routes/workspace-git-diff.ts,导出registerWorkspaceGitDiffRoutes+registerWorkspaceQualifiedGitDiffRoutes。 GET /workspace/git/diff:调fetchGitDiff→ 映射DaemonWorkspaceGitDiff(files 列表 + 统计 +hiddenCount);available反映 null(非仓库/ transient)。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-filesgate 四层约束(见「调研修正」与路由顶部注释)。- qualified 版本:
resolveWorkspaceRuntimeFromParam+requireTrustedWorkspaceRuntime(仿workspace-git.ts的resolveTrustedRuntime)。 server.ts:import + 两处注册调用(紧邻registerWorkspaceGitRoutes)。- 路由单测:列表、单文件、越界 path 拒绝、非仓库占位(8 用例通过)。
Task 3 · SDK ✅ 已完成
types.ts:DaemonWorkspaceGitDiffFile/DaemonWorkspaceGitDiff/DaemonDiffHunk/DaemonWorkspaceGitDiffHunks(结构见「第二层 diff payload」)。daemon/index.ts+src/index.ts导出新类型。DaemonClient:workspaceGitDiff()/workspaceGitDiffFile(path)(path 作为 query,urlEncode,对齐workspaceMcpTools写法); bound 与 workspace-qualified 两个 client 类各加一对方法。- 浏览器 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 ✅ 已完成
GitDiffDialog.tsx(+.module.css):- 打开时
workspaceGitDiff()拉列表 + 统计;headerN 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聚合。
- 打开时
GitBranchIndicator:提供onOpenDiff时 chip 渲染为<button>(否则保持只读<output>);保留data-web-shell-git-branch,加data-clickable;新增gitBranchChipButton重置样式。ChatEditor:透传onOpenGitDiff回调到 chip。App.tsx:showGitDiffDialog状态 + 渲染GitDiffDialog+ 纳入dialogOpen;/diff命令分发处本地拦截打开弹窗(不发给 daemon); chip 回调连通(仅当存在 active workspace cwd 时提供)。localCommands.ts:diff补全项(local.diff文案已存在)。- i18n(en + zh-CN):
gitDiff.*header / 列标记 / 占位 / aria。 GitDiffDialog.test.tsx:列表渲染、点开 hunk、占位、untracked/binary 标记(5 用例通过);GitBranchIndicator.test.tsx补可点击用例(9 用例)。
Shiki 高亮策略(Phase 2 首版即做 per-side 精确高亮):对每个 hunk,分别构造
new 侧(context + added,按序)与 old 侧(context + removed,按序)的纯代码文本,
各自用 Shiki 高亮(language 按扩展名解析);再遍历 hunk lines 维护 new/old 两个
游标,按行类型从对应侧取已高亮行回填——+ 取 new 侧、- 取 old 侧、 两侧
同内容取 new 侧。这样多行注释 / 字符串跨越增删边界也能正确着色。实施用
getCodeHighlighter(codeHighlighter.ts,异步懒加载语言 + 去重)取得 highlighter
后调 codeToTokens 取结构化 token,按行渲染为带 token 颜色的 <span>(避免解析
codeToHtml 输出);isTooLargeToHighlight 或高亮失败时降级为纯文本。
Untracked 文件(展开为全新增 diff):git diff HEAD -- <path> 不含 untracked。
core fetchGitDiffHunksForFile 在判定文件 untracked(git diff 无输出且文件确为
untracked)时,读取文件内容合成一个全新增 hunk
({ oldStart: 0, oldLines: 0, newStart: 1, newLines: N, lines: ['+'+行…] }),
受 MAX_LINES_PER_FILE / MAX_DIFF_SIZE_BYTES 约束;二进制 / 超上限按既有语义
处理。前端 thus 能把 untracked 文件像普通新增文件一样展开行级 diff。
Task 5 · 验证 ✅ 已完成(自动化部分)
npm run typecheck(全 package 通过;先npm run buildcore+sdk 刷新 dist,避免跨包 import 解析到旧 dist)。npm run lint(改动文件 ESLint 干净)+ Prettier 格式化。- 单测:core
gitDiff.test.ts88 通过、cliworkspace-git-diff.test.ts8 通过、web-shellGitDiffDialog.test.tsx5 +GitBranchIndicator.test.tsx9 通过。 - 浏览器验收(待人工):dirty 仓库打开弹窗 → 文件列表正确 → 点开单文件
hunk 高亮正确 →
/diff打开同一弹窗 → 非仓库 / 空 diff 占位 → 越界 path 被拒。
Phase 2.5 · 侧栏 workspace git 下放(已实现)
目标:左侧 WebShellSidebar 每个 workspace 文件夹行显示一个 git 图标 +
状态点(dirty/冲突/进行中),分支名 + ahead/behind 收于 hover tooltip;点击
直接打开该 workspace 的 Changes 弹窗(复用 Phase 2 的 GitDiffDialog)。
多仓库 git 状态无需逐个切换即可一览。纯增量、只读、向后兼容。
数据流:
WorkspaceSection(已用 client.workspaceByCwd(cwd) 拉 session)
+ workspaceGit() → 文件夹行渲染 GitBranchIndicator(兄弟节点,compact 图标态)
点击 chip → onOpenGitDiff(cwd) → App.setDiffWorkspaceCwd(cwd) → GitDiffDialog
关键设计决策:
- 数据获取在
WorkspaceSection:复用 Phase 1workspaceGit();仅 trusted workspace 拉取(untrusted 走 qualified 路由 403,与现有语义一致)。刷新: mount + window focus +reloadToken(session 活动后)+ 仅可见时慢轮询(60s, 比活跃 workspace 30s 更轻,属后台感知)。- 实现细化:拉取还额外 gate 在
onOpenGitDiff是否传入上(effect 与loadGitStatus双重判断!onOpenGitDiff || !workspace.trusted)。chip 是该 状态的唯一消费者,未接入 diff handler 时(如未透传该 prop 的消费方)拉取纯属 浪费,故直接跳过——既省一次请求,也避免给不完整的 mock 制造噪声。
- 实现细化:拉取还额外 gate 在
- 复用
GitBranchIndicator,作为 header 按钮的「兄弟」:文件夹行header本身是<button>,可点击 chip 也是<button>,按钮不能嵌套(无效 HTML)。 故 chip 放在headerRow内、header按钮旁(与headerActions同级)。因是 兄弟节点而非子节点,点 chip 不会冒泡到header按钮,无需stopPropagation; 点行其他处仍展开/折叠。复用 Phase 1 chip → 相同 tooltip / dirty 语义 / 点击行为,零重复逻辑。- 形态:compact 图标态(最终决策):经用户两轮调整定稿。最初用「非 compact
显示分支名」,但窄侧栏(220–420px)下分支名既挤又常被截断;用户遂要求
只显示图标 + 状态点,分支名移到 hover。
GitBranchIndicator的compact形态恰为此而生(固定 28px、gitBranchText由 CSSdisplay:none隐藏、 右上角gitBranchBadgeDot按 tone 表达 dirty/冲突/进行中、tooltip 仍显示 分支名 + ahead/behind),故直接传compact,无需新增 prop。脏/干净仍一眼 可见,分支名 hover 可得,活跃 workspace 的分支另由工具栏 chip 显示。 - 左对齐挨着名称:chip 默认会被
header按钮(flex-grow)挤到行最右。 改为「有 chip 时让 pill 占据余宽、header不撑」——.headerRow:has(.gitPill) .header { flex-grow: 0 }+.gitPill { flex: 1 1 auto },图标即紧贴名称、hover 操作仍贴右缘;无 chip 时header照旧撑满 (操作按钮仍右对齐)。chip 自身是 28px 定宽,pill 仅用余宽把操作推到右缘, 图标与操作间的空白透明、非交互,静止态视觉干净。
- 形态:compact 图标态(最终决策):经用户两轮调整定稿。最初用「非 compact
显示分支名」,但窄侧栏(220–420px)下分支名既挤又常被截断;用户遂要求
只显示图标 + 状态点,分支名移到 hover。
- 弹窗状态重构(统一两入口):App 由
showGitDiffDialog: boolean+ 派生 cwd 改为diffWorkspaceCwd: string | undefined(有值 = 打开)。工具栏 chip →openGitDiff(活跃 cwd);侧栏 chip →openGitDiff(该 cwd)。同一弹窗、 同一 handler;dialogOpen聚合改判diffWorkspaceCwd !== undefined。 - trust 兜底:untrusted workspace 不显示 git pill。
文件清单:
| 操作 | 文件 | 说明 |
|---|---|---|
| 修改 | packages/web-shell/client/components/sidebar/WorkspaceSection.tsx |
拉 git 状态 + header 行渲染 GitBranchIndicator(兄弟)+ onOpenGitDiff prop |
| 修改 | packages/web-shell/client/components/sidebar/WorkspaceSection.module.css |
git pill 在 headerRow 的布局(间距 / 收缩) |
| 修改 | packages/web-shell/client/components/sidebar/WebShellSidebar.tsx |
新增 onOpenGitDiff prop,透传给每个 WorkspaceSection |
| 修改 | packages/web-shell/client/App.tsx |
showGitDiffDialog→diffWorkspaceCwd 重构;传 onOpenGitDiff 给 sidebar;工具栏 chip 改用同一 handler |
| 未改 | packages/web-shell/client/i18n.tsx |
复用既有 git.* 键,无需新增 |
| 新增 | packages/web-shell/client/components/sidebar/WorkspaceSection.test.tsx |
git pill 渲染 + 点击回调 + untrusted/非仓库/无 handler 不显示 |
任务拆解(全部完成 ✅):
- ✅
WorkspaceSection:trusted 时拉workspaceGit()(mount/focus/reloadToken/ 慢轮询)+ header 行渲染GitBranchIndicator(兄弟,onOpenDiff回调)。 - ✅ App 弹窗状态重构
diffWorkspaceCwd+ 两入口统一(工具栏 + 侧栏)。 - ✅ prop 透传 App →
WebShellSidebar→WorkspaceSection。 - ✅ i18n 复用
git.*(无需新键)+ 新增WorkspaceSection.test.tsx(4 例: chip 渲染/点击、untrusted 不显示且不请求、非仓库 null branch 不显示、无 handler 不显示)。 - ✅ 验证:typecheck / lint / Prettier / 单测全绿。
- 浏览器人工验收待补。
实施顺序
- core:新增
getGitWorkingTreeStatus与fetchGitDiffHunksForFile,补单测。 - daemon:扩展
WorkspaceGitState.getStatus输出 enriched status;新增GET /workspace/git/diff与.../diff/file(+ qualified 版本)路由与单测。 - SDK:扩展
DaemonWorkspaceGitStatus,新增 diff 类型与workspaceGitDiff/workspaceGitDiffFile方法。 - Web Shell 第一层:扩展
GitBranchIndicator显示 dirty / ahead-behind / stash / detached,接入workspaceGit()与刷新策略。 - Web Shell 第二层:新增
GitDiffDialog,/diff本地化打开弹窗,dirty chip 点击联动。 - i18n 文案补齐(en + zh-CN)。
- 浏览器验收 + 单测全绿。
PR 描述要点
- 动机:Web Shell 当前 git 集成只有 branch chip +
/diff纯文本,图形界面 应提供更好的状态感知与 diff review 体验;这对“review agent 改了什么”尤其关键。 - 范围:只做只读的状态感知(第一层)与可视化 diff(第二层);提交 / 分支 / GitHub / 远程同步均为后续增量。
- 复用:完全基于 core 已有的
fetchGitDiff/fetchGitDiffHunks/resolveBranchName等能力,不重写 git 解析。 - 兼容:v2 字段全部可选,旧 daemon / 旧 client 互相兼容;非仓库 / detached / transient state 优雅降级。
- 取舍:dirty 不逐键实时刷新,是有意的成本取舍(避免全树 watcher),通过 focus / 打开弹窗 / 低速轮询保证“足够新”。