From 6b02285742327ddee3cf3bbad7e02719b4c947b1 Mon Sep 17 00:00:00 2001 From: haruhi Date: Mon, 7 Sep 2026 23:51:05 +0800 Subject: [PATCH 1/4] =?UTF-8?q?docs(diff):=20git=20=E6=8A=98=E5=8F=A0?= =?UTF-8?q?=E8=A1=8C=E6=8C=89=E9=9C=80=E7=9C=9F=E5=AE=9E=E5=B1=95=E5=BC=80?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 docs/plans/2026-09-07-git-fold-expand-design.md:bug 证据链、切片算法、两 lens 取数映射与边缘 case 清单 - §8 仓库规范补「风格对齐」条款;文末补实施偏差记录 7 条 背景:changes 的 git lens 折叠行显示「点击展开」却无交互(-U3 裁剪使 gap fold 无行文本),文档定稿按需拉取两侧内容真实展开的修复方案。 --- .../2026-09-07-git-fold-expand-design.md | 216 ++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 docs/plans/2026-09-07-git-fold-expand-design.md diff --git a/docs/plans/2026-09-07-git-fold-expand-design.md b/docs/plans/2026-09-07-git-fold-expand-design.md new file mode 100644 index 0000000..eb20249 --- /dev/null +++ b/docs/plans/2026-09-07-git-fold-expand-design.md @@ -0,0 +1,216 @@ +# Git 折叠行真实展开——修复「点击展开」无反应 + +> 日期:2026-09-07 · 状态:计划已定,待实施 · 分支(拟):`fix/diff-fold-expand` +> 关联:上游 issue(待提)、上游 PR(待提,Closes issue)· 上游仓库 `omdsh-dev/DSH-better-sidebar`,本地 fork `LaoYueHanNi/DSH-better-sidebar`(main `6fbfeed` 与上游零差异) + +## 1. 背景与 bug 确认(已核实) + +**现象**:changes 组件点击 git 提交(或工作区 diff)后,diff 视图中 hunk 之间的折叠行显示「n 行…点击展开」,点击无反应(无 pointer/hover)。session(会话文件操作)lens 的折叠行可正常展开——仅 git lens 永远不可展开。 + +**证据链**: +- `src/client/diff/rows.ts` L407-433(`unifiedSegments`):git `-U3` 不输出 hunk 之间的未改动行,gap fold 段**只有行号范围、没有 `rows` 字段**(注释明确 "rows are unknown (non-expandable)"); +- `src/client/diff/DiffRows.tsx` L193-228:`expandable = segment.rows !== undefined && segment.count >= MIN_FOLD`;`rows === undefined` 走静态分支(L197-202,**无 onClick、无 data-expandable**),但文案复用 `t('changesFold')` = zh「{count} 行…点击展开」/ en「{count} lines…click to expand」——承诺了不存在的交互; +- `src/client/diff/diff.module.css` L50-51:仅 `[data-expandable='true']` 有 pointer/hover,静态分支视觉上也不可点,唯独文字撒谎; +- session 路径(`diffLines` + `buildDiffSegments` → `foldOf`)的 fold 总有 `rows`,可正常 toggle 展开。 + +**上游调研结论**(2026-09-07,9 组中英文关键词 + PR 列表 + `git fetch` 源码比对):无 issue 报告过此问题(最接近的 #346 是「文件级默认折叠要多点一下」,方向相反),无已合并/进行中的修复——**全新发现**。 + +**已排除的轻量方案**: +- 纯改文案(原方案 A):不解决「看不到被折叠内容」的本质,用户拍板不做; +- 调大 `-U` 上下文:大 gap 依旧折叠且依旧不可展开,治标且 diff 变吵。 + +## 2. 目标与成功标准 + +点击 git 折叠行时**按需拉取两侧完整内容、按行号切片填充真实上下文行**,让「点击展开」名副其实。 + +1. git 提交/工作区 diff 折叠行点击后展开未改动上下文行(带双侧行号),再点可折叠; +2. session lens 行为完全不变;二进制/untracked 文件不受影响; +3. 内容拉取失败降级为无点击暗示的中性文案; +4. `pnpm typecheck` / `pnpm test` 全绿,CI 三 job(挂载冒烟/typecheck/test)绿; +5. 上游 issue(问题+预计方案)与 PR(实际修复,`Closes #N`)按仓库规范挂接。 + +## 3. 已确认的实现地基 + +- **服务端 `git.show` 路由已存在**(`src/index.ts` L474-480):payload `{rev, path}`(rev 可为 `:0` / `HEAD` / `^` / ``),返回 `{content: string | null}`,失败(null)内建于 `git.ts show()` 的 try/catch → **服务端零改动**; +- `src/git.ts` `diff()`(L382-388):unstaged = `git diff -- path`(**index vs worktree**),staged = `git diff --cached`(**HEAD vs index**)——决定展开时两侧 rev 取法; +- 渲染链:`DiffPane`(底部预览)/ `DiffTab`(独立 tab)→ `DiffFiles`(per-file `unifiedSegments`)→ `DiffRows`;op 路径直接 `DiffRows`(`buildDiffSegments`)。客户端 `api.ts` 尚无 `git.show` 封装(需补一行 call); +- `SidebarDiffRef`(`src/client/state.ts` L24-26):`worktree{path, staged, untracked?, worktree?, repoRoot?}` | `commit{hash, hashFull, subject, worktree?, repoRoot?}`; +- spawn 数组参数不经 shell,`^` 的 `^` 无需转义;`git show :0:` 取 index 版本合法。 + +## 4. 实现设计(按子系统) + +### 4.1 `src/client/api.ts` — gitShow 客户端封装 +仿 `gitCommitDiff`(L317-318): +```ts +gitShow: (scope: SessionScope, rev: string, path: string, worktree?: string, signal?: AbortSignal) => + call<{ content: string | null }>('git.show', gitPayload(scope, worktree, { rev, path }), signal) +``` +走既有 RPC 通道,不触构建纯度门。 + +### 4.2 `src/client/diff/rows.ts` — 切片纯函数 +```ts +export function foldRowsFromContents(fold: FoldSegment, oldContent: string, newContent: string): readonly DiffRow[] +``` +- 以 old 侧驱动 `[oldStart..oldEnd]` 逐行生成 `{kind:'context', oldLine, newLine?, text}`,`newLine = oldLine - oldStart + newStart`(仅当落在 `[newStart..newEnd]` 内才带);再以 new 侧驱动补纯新增段(old 映射不到的行,只带 `newLine`); +- 内容 `split('\n')` 切;空内容 = 空数组;行尾 `\r` 保留(与 git diff ctx 行行为一致)。 + +### 4.3 `src/client/diff/DiffRows.tsx` — 异步展开状态机 +- 新 prop:`resolveFold?: (segment: FoldSegment) => Promise`(不传 = 现状,op 路径不变); +- 新 state:`foldData: Map`、`foldLoading: Set`、`foldFailed: Set`,随现有 useEffect(L119-123)在 segments 变化时整体重置; +- expandable 判定放宽:`(segment.rows !== undefined || resolveFold !== undefined) && segment.count >= MIN_FOLD`; +- 点击未填充的 git fold:置 loading → `resolveFold(segment)` → 成功存 `foldData`(渲染优先于 `segment.rows`)、失败标 `foldFailed`; +- 渲染三态:加载中 `t('changesFoldLoading')`;失败 `t('changesFoldUnavailable')`(无 onClick/pointer);正常沿用 `changesFold`(此时承诺为真);已展开的 fold 渲染 rows,再点折叠回。 + +### 4.4 `src/client/diff/DiffFiles.tsx` — loader 下传 + Promise 缓存 +- 新可选 prop `resolveFold?: (file: DiffFile, segment: FoldSegment) => Promise`,转发 `DiffRows`; +- 实例级 `useRef>>`,key = `displayPath(newPath)|oldStart|newStart`,同文件多 fold 共享一次内容拉取、并发点击天然去重;`parsed` 变化时重置。 + +### 4.5 `src/client/changes/DiffPane.tsx` / `src/client/DiffTab.tsx` — lens loader +按 `SidebarDiffRef` 构造传给 `DiffFiles`: +- **commit lens**:旧侧 `gitShow(scope, '^', displayPath(file.oldPath))`(root commit → null → ''),新侧 `gitShow(scope, hashFull, displayPath(file.newPath))`;与 `commitDiff` 的 `-m --first-parent` 语义一致;rename 由 `oldPath/newPath` 天然覆盖; +- **worktree lens**:`staged=true` → 旧 `HEAD` / 新 `:0`;`staged=false` → 旧 `:0` / 新侧 `api.fsRead` 工作区(`resolveSidebarPath(repoRoot ?? worktree ?? cwd, path)`,同现有 untracked fallback L196/L68 的写法); +- **staged 侧漂移修正(关键边缘)**:DiffPane L182-188 / DiffTab L49-57 在请求侧 diff 为空时 fallback 渲染另一侧,但 `gitRef.staged` 不变——把实际生效侧记为 state(如 `effectiveStaged`),loader 依它取数,否则切片行号错位; +- untracked 文件走 `untrackedFile` 全加渲染,无 fold,loader 不涉及。 + +### 4.6 i18n — 两个新 key,18 个语言文件全补 +- `changesFoldLoading`:zh `展开中…` / en `Expanding…` +- `changesFoldUnavailable`:zh `上下文未加载` / en `Context unavailable` +- `locales.ts` 为基准;`locales-ja.ts` 同步是 AGENTS 硬性底线;其余 15 个 `locales-*.ts` 照 `changesFold` 现状全量补译;纯 `t()` 通道,不涉 `markdownTextProps()`。 + +### 4.7 测试(vitest,沿用现有模式) +- `tests/diff-rows.spec.ts` 增补 `foldRowsFromContents`:对称区间 / 纯删除 gap(old 有 new 无)/ 纯新增 gap / `\r\n` 行尾 / 空内容 / 行号越界截断; +- 新组件测试(参照 `tests/diff-files-collapse.spec.tsx`):mock `api.gitShow` → 点击 git fold 断言展开行渲染、再点折叠、失败降级文案、session fold 路径不受影响; +- 回归:`diff-rows` / `diff-files-collapse` / `diff-highlight` / `builtins` 全绿。 + +## 5. 边缘 case 清单(实现时逐条对照) + +| 场景 | 处置 | +|---|---| +| root commit 无父 | `git.show` 返 null → 空串(旧侧视为空) | +| rename | `parseUnifiedDiff` 保留 oldPath/newPath,两侧各取各的 | +| 二进制文件 | 无 hunks,不渲染 DiffRows,不涉及 | +| untracked | `untrackedFile` 全加渲染,无 fold | +| 无换行文件 | `count` 与切片行数可差 1,展开后以实际行为准 | +| `\r\n` 行尾 | blob 行与 diff ctx 行同样保留 `\r`,一致 | +| staged fallback 侧漂移 | `effectiveStaged` state(§4.5) | +| 并发/重复点击 | Promise 缓存去重 | +| refresh / 切目标 | tick + segments 变化重置展开态 | +| 大文件 | 按需拉取不预取,失败降级兜底 | + +## 6. 流程步骤(issue / PR 全文草稿) + +### 步骤 1:向上游提 issue + +```bash +gh issue create --repo omdsh-dev/DSH-better-sidebar \ + --title "[Bug] Git 提交/工作区 diff 折叠行显示「n 行…点击展开」,点击无反应" \ + --body-file <下方正文> +``` + +模板字段:环境 = `DSH Web(浏览器访问)`;类别 = `🐛 Bug`;DSH 版本可留空;插件版本 = `main(6fbfeed)`。 + +**描述字段正文**: + +> **现象**:在「变更」面板点击历史提交(或查看工作区 diff)后,diff 视图中 hunk 之间的折叠行显示「n 行…点击展开」,但点击无任何反应(无 pointer 光标、无 hover);而会话文件操作(文件追踪)lens 的折叠行可以正常展开——仅 git lens 永远不可展开。 +> +> **期望行为**:要么点击真实展开被折叠的上下文行,要么不显示「点击展开」字样。 +> +> **复现步骤(Bug 必填)**:打开侧边栏 → 变更 → 历史 → 点击任一含多个 hunk 的提交 → 观察文件 diff 中 hunk 之间的折叠行 → 点击。 +> +> **根因(代码定位)**:git 的 unified diff(`-U3`)不输出 hunk 之间的未改动行,`unifiedSegments`(`src/client/diff/rows.ts`)为此产生的 fold 段只有行号范围、没有 `rows`;`DiffRows`(`src/client/diff/DiffRows.tsx`)对 `rows === undefined` 渲染静态分支(无 onClick),但复用了含「点击展开」的 `changesFold` 文案,形成「承诺了不存在的交互」。 + +**补充信息字段正文**: + +> **预计修正方案**:不采用纯改文案或调大 `-U` 上下文(治标),按需真实展开——点击折叠行时经既有 `git.show` 服务路由拉取该文件两侧完整内容(commit:`^` 与 ``;worktree staged:`HEAD` 与 `:0`;unstaged:`:0` 与工作区文件),按 fold 已知行号范围切片为 context 行填充渲染;per 文件 Promise 缓存,失败降级为中性文案。将在 fork 修好后提 PR(Closes 本 issue)。 +> +> 截图:<用户提供的截图,展示「11 行…点击展开」行> + +### 步骤 2:分支与实现 + +```bash +git checkout -b fix/diff-fold-expand # 基于与上游一致的 main(6fbfeed) +``` + +按 §4 落地;同时新增本设计文档的正式版(本文件即底稿,文末补「实施偏差」节,惯例参照 `docs/plans/2026-09-06-changes-op-previews-design.md`)。 + +**提交拆分**(oyw-commit-style:`type(scope): 中文概括` + 无序列表 body + 背景动机段 + 决策记录伴随行;禁 AI 署名、禁 `--no-verify`): +1. `docs(diff): git 折叠上下文按需展开设计文档` +2. `feat(api): 暴露 gitShow 单文件版本内容读取` +3. `feat(diff): 折叠行异步展开状态机与内容切片`(rows.ts + DiffRows + DiffFiles + 测试) +4. `fix(diff): git lens 折叠行接入按需展开,修复点击无反应`(DiffPane/DiffTab + i18n + 组件测试) + +每个 commit 保持 typecheck/test 可绿。 + +### 步骤 3:验证 + +```bash +pnpm typecheck && pnpm test # 全量门禁 +pnpm build && pnpm pack && pnpm test:mount # 挂载冒烟(CI 同款) +``` + +真机点开含多 hunk 提交,验证展开/折叠/失败降级,截图用于 PR。 + +### 步骤 4:发 PR + +```bash +git push -u origin fix/diff-fold-expand +gh pr create --repo omdsh-dev/DSH-better-sidebar --head LaoYueHanNi:fix/diff-fold-expand \ + --title "fix(diff): git 折叠上下文按需展开,修复「点击展开」无反应" --body-file <下方正文> +``` + +**PR body**: + +> ## 问题 +> Closes #。git 提交/工作区 diff 的折叠行显示「n 行…点击展开」但点击无反应:`-U3` 裁剪使 `unifiedSegments` 的 gap fold 无行文本,渲染分支复用了含点击承诺的文案。 +> +> ## 方案 +> 点击时按需经 `git.show` 拉取两侧完整内容,按 fold 行号切片为 context 行填充;不预取(大 lockfile 场景零开销),per 文件 Promise 缓存去重并发,失败降级为「上下文未加载」中性文案。session lens 行为不变,服务端零改动(`git.show` 路由已存在)。 +> +> ## 改动清单 +> - `src/client/api.ts`:gitShow 封装 +> - `src/client/diff/rows.ts`:`foldRowsFromContents` 纯函数 +> - `src/client/diff/DiffRows.tsx`:`resolveFold` prop + 展开/加载/失败状态机 +> - `src/client/diff/DiffFiles.tsx`:loader 下传 + Promise 缓存 +> - `src/client/changes/DiffPane.tsx` / `src/client/DiffTab.tsx`:两种 lens 的取数映射(含 staged fallback 侧修正) +> - `src/client/locales*.ts` × 18:`changesFoldLoading` / `changesFoldUnavailable` +> - `tests/diff-rows.spec.ts` + 新组件测试 +> - `docs/plans/…`:设计文档 +> +> ## 测试 +> - 新增:切片纯函数单测(对称/纯删/纯增/\r\n/越界);组件测试(mock gitShow:展开、折叠、降级) +> - 回归:`pnpm typecheck`、`pnpm test` 全绿;`pnpm test:mount` 冒烟通过 +> - 截图:<展开前后对比> + +### 步骤 5:review 跟进 + +按上游惯例(#499 经验)预判 review 关注点:皮肤令牌(本改动无颜色,不涉)、约定对齐(prop 命名/状态机形状)、i18n 完整性;整改意见在同分支追加 commit,重跑门禁。 + +## 7. 明确不做 + +- 不做纯文案拆分的「方案 A」单独 PR(失败降级文案已含在 B 内); +- 不调大 `-U` 上下文; +- 不改服务端; +- 不动 `changesFold` 现有文案(B 落地后「点击展开」对 git fold 也为真)。 + +## 8. 仓库规范备忘(实施时遵守) + +- 代码改动必须走 PR(AGENTS §1);fork 侧开发,push 到 `LaoYueHanNi` 后向上游发 PR; +- 风格对齐:prop 命名、状态机形状、组件拆分与 CSS 模块组织以 `src/client/diff/` 现有实现为准,不引入新依赖/新工具/新交互模式;有歧义时参照上游最近合入的同类改动; +- i18n:zh 新 key 必须同步 ja(其余语言全补); +- 皮肤契约:无硬编码颜色(本改动无颜色); +- 提交信息遵循 oyw-commit-style,正文无序列表列改动点 + 背景动机段; +- 仅在用户明确说「提交」时才 commit;PR 创建前等用户批准。 + +--- + +## 实施偏差记录 + +> 实施过程中与本文设计的任何偏离,逐条记录在此(惯例:文档以本节为准)。 + +1. **语言文件数量**:§4.6 / §6 的「18 个语言文件」计数有误——实际变更点为 **20 个文件 21 处**(`locales.ts` 内 zh/en 双块 + 19 个 `locales-*.ts`:ar/de/fr/hi/id/ko/it/ja/pl/nl/pt/ru/sv/th/tr/vi/zh-HK/zh-MO/zh-TW),已全量补齐,PR body 计数随之更正。 +2. **缓存拆为两层**:§4.4 把「同文件多 fold 共享一次内容拉取」归给 DiffFiles 的 fold 级 Promise 缓存,但 fold 级 key 含行号(`path|oldStart|newStart`),无法去重内容请求;实施时把内容拉取缓存上移到 lens 层(`DiffPane`/`DiffTab` 的 `foldContents`,key = `displayPath`),DiffFiles 的 fold 级缓存保留(价值:文件头折叠再展开导致 DiffRows 重挂载后秒开、并发点击去重,reject 时摘除)。两层各司其职。 +3. **loading 态交互与竞态防护**:§4.3 三态渲染之外,加载中的 fold 不响应点击且无 pointer(`data-expandable` 移除),防 loading 期间重复触发;另以 `foldEpoch` ref 使 segments 重置后 still-in-flight 的 resolve 回调失效,防「旧 fold 的迟到结果写进新 segments 同下标」竞态(§4.3 未展开)。 +4. **unstaged 新侧读取失败的宽容降级**:§4.5 未细化——工作区侧 `fsRead` 失败(如文件已删除)或返回二进制时降级为空串(catch → null → `''`)而非整体 failed,让旧侧(index)上下文依然可展开。 +5. **DiffPane 组件级 gitScope**:fold loader 需要 SessionScope,新增组件级 `gitScope`(useMemo);load effect 的局部 `paneScope` 保持原样不动,最小侵入。 +6. **本机测试基线**:Windows 本机全量跑 `tests/git.spec.ts` / `tests/git-worktree.spec.ts` 中 3 个真实 git 子进程集成用例会并发抖动失败(stash 后基线复现同样失败,与本次改动无关;CI Linux 绿)。diff 相关 18/18 全绿(新增 `foldRowsFromContents` 6 用例 + 组件测试 4 用例)。 +7. **本地挂载冒烟未执行**:Windows 本地 Git Bash 下 `e2e-mount.sh` 将 tarball 规范为 MSYS 风格 `/d/...`,`file:` scheme 前缀使 MSYS 自动参数转换失效,Windows node 解析为 `C:\d\...` 而 ENOENT(经 `DSH_CMD` 注入 cygpath wrapper 可绕过,再因依赖下载网络受限放弃);`pnpm build` 产物构建已绿,挂载冒烟由 CI `plugin-mount` job 同款覆盖。 From d990c10e59fd896da7940214724fa89543cde2f5 Mon Sep 17 00:00:00 2001 From: haruhi Date: Mon, 7 Sep 2026 23:51:06 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(diff):=20=E6=8A=98=E5=8F=A0=E8=A1=8C?= =?UTF-8?q?=E5=BC=82=E6=AD=A5=E5=B1=95=E5=BC=80=E7=8A=B6=E6=80=81=E6=9C=BA?= =?UTF-8?q?=E4=B8=8E=E5=86=85=E5=AE=B9=E5=88=87=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/client/api.ts: 仿 gitCommitDiff 新增 gitShow 封装,走既有 git.show 服务路由(payload {rev, path},返回 content 或 null),服务端零改动 - src/client/diff/rows.ts: 新增 foldRowsFromContents 纯函数——old 侧按 fold 行号切片为 context 行(new 侧经区间偏移映射,落界才带行号),new 侧补纯增行;越界截断、\r 保留 - src/client/diff/DiffRows.tsx: 新增 resolveFold prop;foldData/foldLoading/foldFailed 三态,加载中不可点,失败降级静态标记;foldEpoch 使 segments 重置后 in-flight 回调失效 - src/client/diff/DiffFiles.tsx: resolveFold 下传各文件 DiffRows,fold 级 Promise 缓存去重并发点击与重挂载 - tests/diff-rows.spec.ts: foldRowsFromContents 六用例(对称/纯删/纯增/\r\n/空内容/越界) 背景:git -U3 不输出 hunk 间未改动行,gap fold 只有行号范围没有 rows,渲染复用「点击展开」文案却无交互;展开能力建为独立状态机(含 api 封装),git lens 只需注入取数 loader。 设计 docs/plans/2026-09-07-git-fold-expand-design.md §4.1-4.4。 --- src/client/api.ts | 5 +++ src/client/diff/DiffFiles.tsx | 33 ++++++++++++-- src/client/diff/DiffRows.tsx | 84 ++++++++++++++++++++++++++++------- src/client/diff/rows.ts | 35 +++++++++++++++ tests/diff-rows.spec.ts | 61 +++++++++++++++++++++++-- 5 files changed, 195 insertions(+), 23 deletions(-) diff --git a/src/client/api.ts b/src/client/api.ts index 3235a6f..cf0cacb 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -316,6 +316,11 @@ export const api = { /** Full patch text of one commit (diff display for the history rows). */ gitCommitDiff: (scope: SessionScope, hash: string, worktree?: string, signal?: AbortSignal) => call<{ diff: string }>('git.commit-diff', gitPayload(scope, worktree, { hash }), signal), + /** One file's content at a revision (`git show :`); null when the + * revision has no such path. The diff views' on-demand hunk-fold expansion + * reads both sides' full contents through this. */ + gitShow: (scope: SessionScope, rev: string, path: string, worktree?: string, signal?: AbortSignal) => + call<{ content: string | null }>('git.show', gitPayload(scope, worktree, { rev, path }), signal), /** The session's file-tool events for the changes tab's session lens: the * `tool/call` + `tool/result` rows past `afterSeq` (0 = whole window), * capped to the recent window host-side. The client runtime exposes no diff --git a/src/client/diff/DiffFiles.tsx b/src/client/diff/DiffFiles.tsx index ce03cbf..7f6e4ee 100644 --- a/src/client/diff/DiffFiles.tsx +++ b/src/client/diff/DiffFiles.tsx @@ -7,10 +7,10 @@ * Untracked files produce no `git diff` output; the caller passes their * content to render as a full-file addition instead. */ -import { useEffect, useMemo, useState, type ReactNode } from 'react' +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import clsx from 'clsx' import { t } from '../locales.ts' -import { diffStats, displayPath, parseUnifiedDiff, unifiedSegments, untrackedFile, type DiffFile } from './rows.ts' +import { diffStats, displayPath, parseUnifiedDiff, unifiedSegments, untrackedFile, type DiffFile, type DiffRow, type FoldSegment } from './rows.ts' import { langOfPath } from './highlight.ts' import { DiffRows } from './DiffRows.tsx' import css from './diff.module.css' @@ -51,9 +51,14 @@ export interface DiffFilesProps { /** Untracked-file content: when present, renders as a full-file addition instead of parsing. */ untrackedPath?: string untrackedContent?: string + /** Fetch a git gap fold's hidden rows on demand (both sides' contents by + * the fold's line ranges); forwarded to every file's DiffRows. Absent + * folds without `rows` stay non-expandable (session-op diffs and + * untracked additions always carry theirs). */ + resolveFold?: (file: DiffFile, segment: FoldSegment) => Promise } -export function DiffFiles({ diff, untrackedPath, untrackedContent }: DiffFilesProps) { +export function DiffFiles({ diff, untrackedPath, untrackedContent, resolveFold }: DiffFilesProps) { const parsed = useMemo(() => { if (untrackedPath !== undefined) { return { files: [untrackedFile(untrackedPath, untrackedContent ?? '')] } @@ -63,6 +68,26 @@ export function DiffFiles({ diff, untrackedPath, untrackedContent }: DiffFilesPr const [expandedFiles, setExpandedFiles] = useState>(() => defaultExpandedFiles(parsed.files)) useEffect(() => { setExpandedFiles(defaultExpandedFiles(parsed.files)) }, [parsed]) + // One in-flight/resolved promise per file+fold key, so a fold re-clicked + // after a remount (file header collapsed and re-expanded) resolves without + // a second fetch and concurrent clicks join the same request. The cache + // dies with `parsed` (a new diff text); rejected promises are dropped so a + // later retry can go through. + const foldCache = useRef(new Map>()) + useEffect(() => { foldCache.current = new Map() }, [parsed]) + const loadFold = (file: DiffFile, segment: FoldSegment): Promise => { + if (resolveFold === undefined) return Promise.resolve([]) + const path = displayPath(file.newPath === '/dev/null' ? file.oldPath : file.newPath) + const key = `${path}|${String(segment.oldStart)}|${String(segment.newStart)}` + let promise = foldCache.current.get(key) + if (promise === undefined) { + promise = resolveFold(file, segment) + foldCache.current.set(key, promise) + promise.catch(() => { foldCache.current.delete(key) }) + } + return promise + } + // Segments and header stats computed once per file. const files = useMemo( () => parsed.files.map((file) => { @@ -107,7 +132,7 @@ export function DiffFiles({ diff, untrackedPath, untrackedContent }: DiffFilesPr )} {expandable && fileExpanded && ( - + loadFold(file, segment) : undefined} /> )} ) diff --git a/src/client/diff/DiffRows.tsx b/src/client/diff/DiffRows.tsx index e40b877..6127ac2 100644 --- a/src/client/diff/DiffRows.tsx +++ b/src/client/diff/DiffRows.tsx @@ -4,11 +4,14 @@ * line gutters, rewrite (mod) tinting with intra-line character highlights, * lightweight syntax coloring, and long-line folding. Presentational — the * segments arrive precomputed (session ops via buildDiffSegments, git diffs - * via unifiedSegments) so both producers render identically. + * via unifiedSegments) so both producers render identically; the one + * exception is git gap folds, whose rows git never emitted and a caller + * supplies on demand through resolveFold (first click fetches, then they + * toggle like any fold). */ -import { useEffect, useMemo, useState, type ReactElement, type ReactNode } from 'react' +import { useEffect, useMemo, useRef, useState, type ReactElement, type ReactNode } from 'react' import { t } from '../locales.ts' -import { coalesceInline, diffInline, MIN_FOLD, type DiffRow, type DiffSegment, type InlineDiff } from './rows.ts' +import { coalesceInline, diffInline, MIN_FOLD, type DiffRow, type DiffSegment, type FoldSegment, type InlineDiff } from './rows.ts' import { hasBlockComment, isColored, scanLine, type CodeToken, type TokenType } from './highlight.ts' import css from './diff.module.css' @@ -105,21 +108,36 @@ export interface DiffRowsProps { segments: readonly DiffSegment[] /** Syntax language id (langOfPath); undefined renders plain text. */ lang?: string + /** Fetch a git gap fold's hidden rows on demand (both sides' contents + * sliced by the fold's line ranges). Absent folds without `rows` — git's + * unemitted gaps without a resolver — stay non-expandable markers. */ + resolveFold?: (segment: FoldSegment) => Promise } /** One file's diff rows: fold chips between hunks, highlighted code rows. */ -export function DiffRows({ segments, lang }: DiffRowsProps) { +export function DiffRows({ segments, lang, resolveFold }: DiffRowsProps) { // Long diff lines fold to one ellipsized row; the set holds expanded row keys. const [expandedLines, setExpandedLines] = useState>(new Set()) // Hunk-fold segments expanded by index; default collapsed. const [expandedFolds, setExpandedFolds] = useState>(new Set()) // Row-count cap expanded: a huge file renders head rows plus this button. const [expandedAll, setExpandedAll] = useState(false) + // On-demand fold expansion (git gaps): resolved rows per segment index, + // the loading/failed markers, and an epoch that invalidates in-flight + // resolves when the segments identity changes. + const [foldData, setFoldData] = useState>(new Map()) + const [foldLoading, setFoldLoading] = useState>(new Set()) + const [foldFailed, setFoldFailed] = useState>(new Set()) + const foldEpoch = useRef(0) // Reset all folding when the segments identity changes (new target). useEffect(() => { setExpandedLines(new Set()) setExpandedFolds(new Set()) setExpandedAll(false) + setFoldData(new Map()) + setFoldLoading(new Set()) + setFoldFailed(new Set()) + foldEpoch.current += 1 }, [segments]) const inlineMap = useMemo(() => buildInlineMap(segments), [segments]) @@ -190,7 +208,19 @@ export function DiffRows({ segments, lang }: DiffRowsProps) { ) } - const expandable = segment.rows !== undefined && segment.count >= MIN_FOLD + if (foldFailed.has(segIndex)) { + // The on-demand fetch failed: a quiet, non-clickable marker — the + // expansion promise is gone, so no interaction is promised either. + return ( +
+ + {t('changesFoldUnavailable')} + +
+ ) + } + const resolvedRows = foldData.get(segIndex) + const expandable = (segment.rows !== undefined || resolvedRows !== undefined || resolveFold !== undefined) && segment.count >= MIN_FOLD if (!expandable) { // A tiny fold (or a git gap with no rows to reveal): a quiet marker. return ( @@ -201,27 +231,49 @@ export function DiffRows({ segments, lang }: DiffRowsProps) { ) } + const loading = foldLoading.has(segIndex) const isExpanded = expandedFolds.has(segIndex) + const revealed = segment.rows ?? resolvedRows return (
{ - setExpandedFolds(prev => { - const next = new Set(prev) - if (next.has(segIndex)) next.delete(segIndex) - else next.add(segIndex) - return next - }) + onClick={loading ? undefined : () => { + if (revealed !== undefined) { + setExpandedFolds(prev => { + const next = new Set(prev) + if (next.has(segIndex)) next.delete(segIndex) + else next.add(segIndex) + return next + }) + return + } + // A git gap fold's first click: fetch its rows, then expand. + if (resolveFold === undefined) return + const epoch = foldEpoch.current + setFoldLoading(prev => new Set(prev).add(segIndex)) + resolveFold(segment).then( + (rows) => { + if (foldEpoch.current !== epoch) return + setFoldData(prev => new Map(prev).set(segIndex, rows)) + setFoldLoading(prev => { const next = new Set(prev); next.delete(segIndex); return next }) + setExpandedFolds(prev => new Set(prev).add(segIndex)) + }, + () => { + if (foldEpoch.current !== epoch) return + setFoldLoading(prev => { const next = new Set(prev); next.delete(segIndex); return next }) + setFoldFailed(prev => new Set(prev).add(segIndex)) + }, + ) }} > - {isExpanded - ? segment.rows!.map((row, index) => renderDiffRow(row, `${segIndex}-${String(index)}`)) + {isExpanded && revealed !== undefined + ? revealed.map((row, index) => renderDiffRow(row, `${segIndex}-${String(index)}`)) : ( - {t('changesFold', { count: segment.count })} + {loading ? t('changesFoldLoading') : t('changesFold', { count: segment.count })} )}
diff --git a/src/client/diff/rows.ts b/src/client/diff/rows.ts index bfd7e68..218f6e0 100644 --- a/src/client/diff/rows.ts +++ b/src/client/diff/rows.ts @@ -451,6 +451,41 @@ export function untrackedFile(path: string, content: string): DiffFile { return { oldPath: '/dev/null', newPath: `b/${path}`, binary: false, hunks: [{ oldStart: 0, newStart: 1, header: '', lines }] } } +/** + * Materialize a git gap fold's hidden rows from the two sides' full file + * contents, by the fold's known line ranges: the old side drives context + * rows (each mapped onto the new side through the fold's offset — a gap is + * an unchanged run, so the sides align), and new-side lines the old range + * never reaches become pure additions. Line numbers clip to the actual + * content (a no-newline file's ranges can overrun by one); `\r` endings + * survive verbatim, like git's own context lines. + */ +export function foldRowsFromContents(fold: FoldSegment, oldContent: string, newContent: string): DiffRow[] { + const oldLines = oldContent.length === 0 ? [] : oldContent.split('\n') + const newLines = newContent.length === 0 ? [] : newContent.split('\n') + const offset = fold.newStart - fold.oldStart + const rows: DiffRow[] = [] + const oldFrom = Math.max(fold.oldStart, 1) + const oldTo = Math.min(fold.oldEnd, oldLines.length) + for (let oldLine = oldFrom; oldLine <= oldTo; oldLine += 1) { + const text = oldLines[oldLine - 1]! + const newLine = oldLine + offset + rows.push( + newLine >= fold.newStart && newLine <= fold.newEnd && newLine <= newLines.length + ? { kind: 'context', oldLine, newLine, text } + : { kind: 'context', oldLine, text }, + ) + } + // New-side lines beyond what the old range reached (a pure-addition gap): + // rows carrying only the new-side number, exactly like added lines. + const newFrom = Math.max(fold.newStart, 1) + const newTo = Math.min(fold.newEnd, newLines.length) + for (let newLine = Math.max(newFrom, oldTo + offset + 1); newLine <= newTo; newLine += 1) { + rows.push({ kind: 'add', newLine, text: newLines[newLine - 1]! }) + } + return rows +} + /** Strip the `a/` / `b/` prefix git puts on diff paths (not on /dev/null). */ export function displayPath(path: string): string { if (path === '/dev/null') return path diff --git a/tests/diff-rows.spec.ts b/tests/diff-rows.spec.ts index 47d7620..857c200 100644 --- a/tests/diff-rows.spec.ts +++ b/tests/diff-rows.spec.ts @@ -2,11 +2,12 @@ * The unified-diff adapter: git's own hunks (from parseUnifiedDiff) convert * into the shared DiffRow segments — rewrite pairing applies inside git * hunks exactly like session-op diffs, the unemitted context gaps between - * hunks become non-expandable folds carrying their line ranges, and the - * header stats (+n −m) count mods on both sides. + * hunks become folds carrying their line ranges (expandable on demand via + * foldRowsFromContents), and the header stats (+n −m) count mods on both + * sides. */ import { describe, expect, it } from 'vitest' -import { parseUnifiedDiff, unifiedSegments, diffLines, pairMods, diffStats, untrackedFile } from '../src/client/diff/rows.ts' +import { parseUnifiedDiff, unifiedSegments, diffLines, pairMods, diffStats, untrackedFile, foldRowsFromContents, type FoldSegment } from '../src/client/diff/rows.ts' const twoHunks = [ 'diff --git a/a.ts b/a.ts', @@ -121,3 +122,57 @@ describe('pairMods', () => { expect(rows.map(r => r.kind)).toEqual(['mod', 'mod']) }) }) + +describe('foldRowsFromContents', () => { + it('slices an aligned gap into context rows carrying both line numbers', () => { + const fold: FoldSegment = { kind: 'fold', count: 4, oldStart: 4, oldEnd: 7, newStart: 6, newEnd: 9 } + const rows = foldRowsFromContents(fold, 'a\nb\nc\nd\ne\nf\ng\nh\n', 'A\nB\nC\nD\nE\nF\nG\nH\nI\n') + expect(rows).toEqual([ + { kind: 'context', oldLine: 4, newLine: 6, text: 'd' }, + { kind: 'context', oldLine: 5, newLine: 7, text: 'e' }, + { kind: 'context', oldLine: 6, newLine: 8, text: 'f' }, + { kind: 'context', oldLine: 7, newLine: 9, text: 'g' }, + ]) + }) + + it('keeps the old-side number alone when the new range cannot map (pure deletion gap)', () => { + const fold: FoldSegment = { kind: 'fold', count: 2, oldStart: 3, oldEnd: 4, newStart: 3, newEnd: 2 } + const rows = foldRowsFromContents(fold, 'a\nb\nc\nd\n', 'a\nb\n') + expect(rows).toEqual([ + { kind: 'context', oldLine: 3, text: 'c' }, + { kind: 'context', oldLine: 4, text: 'd' }, + ]) + }) + + it('emits pure additions for new lines the old range never reached (pure addition gap)', () => { + const fold: FoldSegment = { kind: 'fold', count: 3, oldStart: 3, oldEnd: 2, newStart: 3, newEnd: 5 } + const rows = foldRowsFromContents(fold, 'a\nb\n', 'a\nb\nc\nd\ne\n') + expect(rows).toEqual([ + { kind: 'add', newLine: 3, text: 'c' }, + { kind: 'add', newLine: 4, text: 'd' }, + { kind: 'add', newLine: 5, text: 'e' }, + ]) + }) + + it('keeps \\r endings verbatim like git context lines', () => { + const fold: FoldSegment = { kind: 'fold', count: 2, oldStart: 1, oldEnd: 2, newStart: 1, newEnd: 2 } + const rows = foldRowsFromContents(fold, 'a\r\nb\r\n', 'a\r\nb\r\n') + expect(rows.map(r => r.text)).toEqual(['a\r', 'b\r']) + }) + + it('returns no rows for empty contents', () => { + const fold: FoldSegment = { kind: 'fold', count: 5, oldStart: 1, oldEnd: 5, newStart: 1, newEnd: 5 } + expect(foldRowsFromContents(fold, '', '')).toEqual([]) + }) + + it('clips line numbers to the actual content (no-newline overrun)', () => { + // A file without a trailing newline: the fold's ranges can overrun the + // actual line count by one; the slice stops at the real last line. + const fold: FoldSegment = { kind: 'fold', count: 3, oldStart: 2, oldEnd: 4, newStart: 2, newEnd: 4 } + const rows = foldRowsFromContents(fold, 'a\nb\nc', 'a\nb\nc') + expect(rows).toEqual([ + { kind: 'context', oldLine: 2, newLine: 2, text: 'b' }, + { kind: 'context', oldLine: 3, newLine: 3, text: 'c' }, + ]) + }) +}) From 900654b8fd3995980188a9d9f6eae4cdb8a92831 Mon Sep 17 00:00:00 2001 From: haruhi Date: Mon, 7 Sep 2026 23:51:10 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(diff):=20git=20lens=20=E6=8A=98?= =?UTF-8?q?=E5=8F=A0=E8=A1=8C=E6=8E=A5=E5=85=A5=E6=8C=89=E9=9C=80=E5=B1=95?= =?UTF-8?q?=E5=BC=80=EF=BC=8C=E4=BF=AE=E5=A4=8D=E7=82=B9=E5=87=BB=E6=97=A0?= =?UTF-8?q?=E5=8F=8D=E5=BA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/client/changes/DiffPane.tsx / src/client/DiffTab.tsx: 按 SidebarDiffRef 构造 fold loader——commit 侧 ^ 与 ;staged 侧 HEAD 与 :0;unstaged 侧 :0 与工作区 fsRead(失败/二进制降级空串);per 文件内容 Promise 缓存,目标或刷新 tick 变化时重置 - fallback 换侧时记录 effectiveStaged,loader 依实际渲染侧取数,修正切片行号错位 - src/client/locales.ts 及 19 个 locales-*.ts: 新增 changesFoldLoading / changesFoldUnavailable 全量补译(共 20 文件 21 处) - tests/diff-fold-expand.spec.tsx: 展开/折叠/缓存去重、失败降级、无 loader 保持 inert、session 路径回归 背景:git 提交/工作区 diff 折叠行承诺「点击展开」却永远不可点,与 session lens 行为不一致;接入状态机后承诺为真,session 路径与现有文案零变化。 设计 docs/plans/2026-09-07-git-fold-expand-design.md §4.5-4.6。 --- src/client/DiffTab.tsx | 73 +++++++++++++++- src/client/changes/DiffPane.tsx | 78 ++++++++++++++++- src/client/locales-ar.ts | 2 + src/client/locales-de.ts | 2 + src/client/locales-fr.ts | 2 + src/client/locales-hi.ts | 2 + src/client/locales-id.ts | 2 + src/client/locales-it.ts | 2 + src/client/locales-ja.ts | 2 + src/client/locales-ko.ts | 2 + src/client/locales-nl.ts | 2 + src/client/locales-pl.ts | 2 + src/client/locales-pt.ts | 2 + src/client/locales-ru.ts | 2 + src/client/locales-sv.ts | 2 + src/client/locales-th.ts | 2 + src/client/locales-tr.ts | 2 + src/client/locales-vi.ts | 2 + src/client/locales-zh-HK.ts | 2 + src/client/locales-zh-MO.ts | 2 + src/client/locales-zh-TW.ts | 2 + src/client/locales.ts | 4 + tests/diff-fold-expand.spec.tsx | 143 ++++++++++++++++++++++++++++++++ 23 files changed, 331 insertions(+), 5 deletions(-) create mode 100644 tests/diff-fold-expand.spec.tsx diff --git a/src/client/DiffTab.tsx b/src/client/DiffTab.tsx index 2720abc..fc3a500 100644 --- a/src/client/DiffTab.tsx +++ b/src/client/DiffTab.tsx @@ -8,12 +8,13 @@ * very content it shows. Rendering goes through the shared {@link DiffFiles} * renderer — the same one the changes tab's inline preview uses. */ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import type { SessionScope } from './api.ts' import { api } from './api.ts' import type { SidebarDiffRef } from './state.ts' import { DiffFiles } from './diff/DiffFiles.tsx' +import { displayPath, foldRowsFromContents, type DiffFile, type DiffRow, type FoldSegment } from './diff/rows.ts' import { t } from './locales.ts' import { resolveSidebarPath } from './produced-files.ts' import css from './sidebar.module.css' @@ -30,6 +31,11 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif const [error, setError] = useState(null) const [data, setData] = useState(null) const [tick, setTick] = useState(0) + // The staged flag of the side ACTUALLY rendered: when the requested side's + // diff came back empty the load falls back to the other side, and the fold + // expansion must read that side's revisions (else the sliced line numbers + // land on the wrong contents). + const [effectiveStaged, setEffectiveStaged] = useState(null) const refresh = useCallback((): void => { setTick(value => value + 1) }, []) @@ -39,6 +45,7 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif setLoading(true) setError(null) setData(null) + setEffectiveStaged(null) const load = async (): Promise => { try { if (diff.kind === 'commit') { @@ -53,7 +60,10 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif // file staged after its tab opened). Both sides empty means the // file genuinely has no text changes. const other = await api.gitDiff(scope, diff.path, !diff.staged, diff.worktree) - if (other.diff !== '') result = other + if (other.diff !== '') { + result = other + if (!cancelled) setEffectiveStaged(!diff.staged) + } } if (result.diff !== '') { if (!cancelled) setData({ diff: result.diff }) @@ -82,6 +92,63 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif return () => { cancelled = true } }, [sessionId, cwd, diff, tick]) + // ── On-demand git fold expansion: a fold's hidden rows come from both + // sides' full contents (git.show / fsRead), fetched ONCE per file so + // sibling folds share the request, then sliced by each fold's line + // ranges. The cache dies with the ref or a refresh tick. ────────────── + const foldContents = useRef(new Map>()) + useEffect(() => { foldContents.current = new Map() }, [diff, tick]) + const foldLoader = useMemo(() => { + const sidesOf = (file: DiffFile): Promise<{ old: string; new: string }> => { + const fetchSides = async (): Promise<{ old: string; new: string }> => { + const scope: SessionScope = { sessionId, cwd, ...(diff.repoRoot !== undefined ? { repoRoot: diff.repoRoot } : {}) } + if (diff.kind === 'commit') { + // The patch's -m --first-parent shape: old side from the parent, + // new side from the commit (a root commit's parent read fails → ''). + const [oldSide, newSide] = await Promise.all([ + file.oldPath === '/dev/null' + ? Promise.resolve({ content: null }) + : api.gitShow(scope, `${diff.hashFull}^`, displayPath(file.oldPath), diff.worktree), + file.newPath === '/dev/null' + ? Promise.resolve({ content: null }) + : api.gitShow(scope, diff.hashFull, displayPath(file.newPath), diff.worktree), + ]) + return { old: oldSide.content ?? '', new: newSide.content ?? '' } + } + // Worktree change: staged is HEAD vs index, unstaged is index vs + // worktree (the worktree side reads the live file). + const staged = effectiveStaged ?? diff.staged + if (staged) { + const [oldSide, newSide] = await Promise.all([ + file.oldPath === '/dev/null' + ? Promise.resolve({ content: null }) + : api.gitShow(scope, 'HEAD', displayPath(file.oldPath), diff.worktree), + file.newPath === '/dev/null' + ? Promise.resolve({ content: null }) + : api.gitShow(scope, ':0', displayPath(file.newPath), diff.worktree), + ]) + return { old: oldSide.content ?? '', new: newSide.content ?? '' } + } + const [oldSide, worktree] = await Promise.all([ + file.oldPath === '/dev/null' + ? Promise.resolve({ content: null }) + : api.gitShow(scope, ':0', displayPath(file.oldPath), diff.worktree), + api.fsRead(scope, resolveSidebarPath(diff.repoRoot ?? diff.worktree ?? cwd, displayPath(file.newPath))).catch(() => null), + ]) + return { old: oldSide.content ?? '', new: worktree !== null && worktree.kind === 'text' ? worktree.content : '' } + } + const path = displayPath(file.newPath === '/dev/null' ? file.oldPath : file.newPath) + let promise = foldContents.current.get(path) + if (promise === undefined) { + promise = fetchSides() + foldContents.current.set(path, promise) + } + return promise + } + return (file: DiffFile, segment: FoldSegment): Promise => + sidesOf(file).then(sides => foldRowsFromContents(segment, sides.old, sides.new)) + }, [sessionId, cwd, diff, effectiveStaged]) + return (
@@ -104,7 +171,7 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif <> {data.untracked !== undefined ? - : } + : } {data.diff === '' && data.untracked === undefined && (
{t('diffEmpty')}
)} diff --git a/src/client/changes/DiffPane.tsx b/src/client/changes/DiffPane.tsx index c609433..be2b84a 100644 --- a/src/client/changes/DiffPane.tsx +++ b/src/client/changes/DiffPane.tsx @@ -21,7 +21,7 @@ import { DiffRows, ReadRows } from '../diff/DiffRows.tsx' import { PdfView } from '../PdfView.tsx' import { DiffFiles } from '../diff/DiffFiles.tsx' import { langOfPath } from '../diff/highlight.ts' -import { buildDiffSegments, diffLines, diffStats, parseUnifiedDiff, unifiedSegments, type DiffRow } from '../diff/rows.ts' +import { buildDiffSegments, diffLines, diffStats, displayPath, foldRowsFromContents, parseUnifiedDiff, unifiedSegments, type DiffFile, type DiffRow, type FoldSegment } from '../diff/rows.ts' import { parseReadContent, parseReadLines, type FileOp } from './ops.ts' import { redactText } from '../redact.ts' import { rewriteLocalImageUrls } from '../markdown-images.ts' @@ -158,7 +158,19 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp const [error, setError] = useState(null) const [diffText, setDiffText] = useState(null) const [untracked, setUntracked] = useState(undefined) + // The staged flag of the side ACTUALLY rendered: when the requested side's + // diff came back empty the load falls back to the other side, and the fold + // expansion must read that side's revisions (else the sliced line numbers + // land on the wrong contents). + const [effectiveStaged, setEffectiveStaged] = useState(null) const gitRef = target.kind === 'git' ? target.ref : null + // The scope every git call of this target shares (repoRoot folded in when + // the ref carries one, exactly like the load effect's paneScope). + const gitScope = useMemo(() => ({ + sessionId: scope.sessionId, + cwd: scope.cwd, + ...(gitRef?.repoRoot !== undefined ? { repoRoot: gitRef.repoRoot } : {}), + }), [scope.sessionId, scope.cwd, gitRef?.repoRoot]) useEffect(() => { if (gitRef === null) return @@ -172,6 +184,7 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp setError(null) setDiffText(null) setUntracked(undefined) + setEffectiveStaged(null) const load = async (): Promise => { try { if (gitRef.kind === 'commit') { @@ -184,7 +197,10 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp // The requested side is empty — try the OTHER side once (the change // may have moved sides after the preview target was minted). const other = await api.gitDiff(paneScope, gitRef.path, !gitRef.staged, gitRef.worktree) - if (other.diff !== '') result = other + if (other.diff !== '') { + result = other + if (!cancelled) setEffectiveStaged(!gitRef.staged) + } } if (result.diff !== '') { if (!cancelled) setDiffText(result.diff) @@ -211,6 +227,63 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp return () => { cancelled = true } }, [gitRef, scope.sessionId, scope.cwd, tick]) + // ── On-demand git fold expansion: a fold's hidden rows come from both + // sides' full contents (git.show / fsRead), fetched ONCE per file so + // sibling folds share the request, then sliced by each fold's line + // ranges. The cache dies with the target or a refresh tick. ─────────── + const foldContents = useRef(new Map>()) + useEffect(() => { foldContents.current = new Map() }, [gitRef, tick]) + const foldLoader = useMemo(() => { + if (gitRef === null) return undefined + const sidesOf = (file: DiffFile): Promise<{ old: string; new: string }> => { + const fetchSides = async (): Promise<{ old: string; new: string }> => { + if (gitRef.kind === 'commit') { + // The patch's -m --first-parent shape: old side from the parent, + // new side from the commit (a root commit's parent read fails → ''). + const [oldSide, newSide] = await Promise.all([ + file.oldPath === '/dev/null' + ? Promise.resolve({ content: null }) + : api.gitShow(gitScope, `${gitRef.hashFull}^`, displayPath(file.oldPath), gitRef.worktree), + file.newPath === '/dev/null' + ? Promise.resolve({ content: null }) + : api.gitShow(gitScope, gitRef.hashFull, displayPath(file.newPath), gitRef.worktree), + ]) + return { old: oldSide.content ?? '', new: newSide.content ?? '' } + } + // Worktree change: staged is HEAD vs index, unstaged is index vs + // worktree (the worktree side reads the live file). + const staged = effectiveStaged ?? gitRef.staged + if (staged) { + const [oldSide, newSide] = await Promise.all([ + file.oldPath === '/dev/null' + ? Promise.resolve({ content: null }) + : api.gitShow(gitScope, 'HEAD', displayPath(file.oldPath), gitRef.worktree), + file.newPath === '/dev/null' + ? Promise.resolve({ content: null }) + : api.gitShow(gitScope, ':0', displayPath(file.newPath), gitRef.worktree), + ]) + return { old: oldSide.content ?? '', new: newSide.content ?? '' } + } + const [oldSide, worktree] = await Promise.all([ + file.oldPath === '/dev/null' + ? Promise.resolve({ content: null }) + : api.gitShow(gitScope, ':0', displayPath(file.oldPath), gitRef.worktree), + api.fsRead(gitScope, resolveSidebarPath(gitRef.repoRoot ?? gitRef.worktree ?? scope.cwd, displayPath(file.newPath))).catch(() => null), + ]) + return { old: oldSide.content ?? '', new: worktree !== null && worktree.kind === 'text' ? worktree.content : '' } + } + const path = displayPath(file.newPath === '/dev/null' ? file.oldPath : file.newPath) + let promise = foldContents.current.get(path) + if (promise === undefined) { + promise = fetchSides() + foldContents.current.set(path, promise) + } + return promise + } + return (file: DiffFile, segment: FoldSegment): Promise => + sidesOf(file).then(sides => foldRowsFromContents(segment, sides.old, sides.new)) + }, [gitRef, gitScope, effectiveStaged, scope]) + // ── Op target material (pure snapshots; the prior content came with the // target so a running op shows what is already known). ──────────────── const opRaw = target.kind === 'op' ? target.op : null @@ -508,6 +581,7 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp {diffText !== null && diffText !== '' && ( diff --git a/src/client/locales-ar.ts b/src/client/locales-ar.ts index 90c20b5..605f467 100644 --- a/src/client/locales-ar.ts +++ b/src/client/locales-ar.ts @@ -31,6 +31,8 @@ export const ar: Record = { changesRunning: 'قيد التنفيذ', changesError: 'خطأ', changesFold: '{count} أسطر… انقر للتوسيع', + changesFoldLoading: 'جارٍ التوسيع…', + changesFoldUnavailable: 'السياق غير متوفر', changesContext: 'سياق', changesPriorUnknown: 'المحتوى السابق خارج النافذة؛ يُعرض كما لو أُضيف بالكامل', explorer: 'المستكشف', diff --git a/src/client/locales-de.ts b/src/client/locales-de.ts index 799d1b7..0a3495c 100644 --- a/src/client/locales-de.ts +++ b/src/client/locales-de.ts @@ -16,6 +16,8 @@ export const de: Record = { changesRunning: 'läuft', changesError: 'Fehler', changesFold: '{count} Zeilen… zum Aufklappen klicken', + changesFoldLoading: 'Wird aufgeklappt…', + changesFoldUnavailable: 'Kontext nicht verfügbar', changesContext: 'Kontext', changesPriorUnknown: 'Inhalt vor der Änderung liegt außerhalb des Fensters; als vollständig neu angezeigt', explorer: 'Explorer', diff --git a/src/client/locales-fr.ts b/src/client/locales-fr.ts index 0f9b757..9e4c23c 100644 --- a/src/client/locales-fr.ts +++ b/src/client/locales-fr.ts @@ -23,6 +23,8 @@ export const fr: Record = { changesRunning: 'en cours', changesError: 'erreur', changesFold: '{count} lignes… cliquer pour déplier', + changesFoldLoading: 'Dépliage…', + changesFoldUnavailable: 'Contexte indisponible', changesContext: 'contexte', changesPriorUnknown: 'Le contenu antérieur est hors de la fenêtre ; affiché comme entièrement ajouté', explorer: 'Explorateur', diff --git a/src/client/locales-hi.ts b/src/client/locales-hi.ts index 02f932b..f78e1c7 100644 --- a/src/client/locales-hi.ts +++ b/src/client/locales-hi.ts @@ -30,6 +30,8 @@ export const hi: Record = { changesRunning: 'चल रहा है', changesError: 'त्रुटि', changesFold: '{count} पंक्तियाँ… विस्तार के लिए क्लिक करें', + changesFoldLoading: 'विस्तार हो रहा है…', + changesFoldUnavailable: 'संदर्भ अनुपलब्ध', changesContext: 'संदर्भ', changesPriorUnknown: 'बदलाव से पहले की सामग्री विंडो में नहीं है; पूरी नई जोड़ी गई दिखाई जाती है', explorer: 'एक्सप्लोरर', diff --git a/src/client/locales-id.ts b/src/client/locales-id.ts index 796bf0c..33fc653 100644 --- a/src/client/locales-id.ts +++ b/src/client/locales-id.ts @@ -28,6 +28,8 @@ export const id: Record = { changesRunning: 'berjalan', changesError: 'error', changesFold: '{count} baris… klik untuk meluaskan', + changesFoldLoading: 'Meluaskan…', + changesFoldUnavailable: 'Konteks tidak tersedia', changesContext: 'konteks', changesPriorUnknown: 'Konten sebelum perubahan berada di luar jendela; ditampilkan sebagai sepenuhnya baru', explorer: 'Explorer', diff --git a/src/client/locales-it.ts b/src/client/locales-it.ts index 59385cb..f2629ce 100644 --- a/src/client/locales-it.ts +++ b/src/client/locales-it.ts @@ -21,6 +21,8 @@ export const it: Record = { changesRunning: 'in esecuzione', changesError: 'errore', changesFold: '{count} righe… clicca per espandere', + changesFoldLoading: 'Espansione…', + changesFoldUnavailable: 'Contesto non disponibile', changesContext: 'contesto', changesPriorUnknown: 'Il contenuto precedente è fuori dalla finestra; mostrato come interamente aggiunto', explorer: 'Esplora risorse', diff --git a/src/client/locales-ja.ts b/src/client/locales-ja.ts index aca6e71..7f91f50 100644 --- a/src/client/locales-ja.ts +++ b/src/client/locales-ja.ts @@ -30,6 +30,8 @@ export const ja: Record = { changesRunning: '実行中', changesError: 'エラー', changesFold: '{count} 行…クリックで展開', + changesFoldLoading: '展開中…', + changesFoldUnavailable: 'コンテキスト未読み込み', changesContext: 'コンテキスト', changesPriorUnknown: '変更前の内容がウィンドウ内になく、すべて新規追加として表示されます', explorer: 'エクスプローラー', diff --git a/src/client/locales-ko.ts b/src/client/locales-ko.ts index 42b5733..f27bf13 100644 --- a/src/client/locales-ko.ts +++ b/src/client/locales-ko.ts @@ -22,6 +22,8 @@ export const ko: Record = { changesRunning: '실행 중', changesError: '오류', changesFold: '{count}줄… 클릭하여 펼치기', + changesFoldLoading: '펼치는 중…', + changesFoldUnavailable: '컨텍스트를 불러올 수 없음', changesContext: '컨텍스트', changesPriorUnknown: '변경 전 내용이 창에 없어 모두 새로 추가된 것으로 표시됩니다', explorer: '탐색기', diff --git a/src/client/locales-nl.ts b/src/client/locales-nl.ts index 589029c..96d976e 100644 --- a/src/client/locales-nl.ts +++ b/src/client/locales-nl.ts @@ -28,6 +28,8 @@ export const nl: Record = { changesRunning: 'actief', changesError: 'fout', changesFold: '{count} regels… klik om uit te klappen', + changesFoldLoading: 'Uitklappen…', + changesFoldUnavailable: 'Context niet beschikbaar', changesContext: 'context', changesPriorUnknown: 'Eerdere inhoud valt buiten het venster; getoond als volledig nieuw', explorer: 'Verkenner', diff --git a/src/client/locales-pl.ts b/src/client/locales-pl.ts index 2138899..f9d5999 100644 --- a/src/client/locales-pl.ts +++ b/src/client/locales-pl.ts @@ -32,6 +32,8 @@ export const pl: Record = { changesRunning: 'w toku', changesError: 'błąd', changesFold: '{count} wierszy… kliknij, aby rozwinąć', + changesFoldLoading: 'Rozwijanie…', + changesFoldUnavailable: 'Kontekst niedostępny', changesContext: 'kontekst', changesPriorUnknown: 'Zawartość sprzed zmiany jest poza oknem; pokazana jako w całości nowa', explorer: 'Eksplorator', diff --git a/src/client/locales-pt.ts b/src/client/locales-pt.ts index 1417abe..03e022a 100644 --- a/src/client/locales-pt.ts +++ b/src/client/locales-pt.ts @@ -13,6 +13,8 @@ export const pt: Record = { changesRunning: 'em execução', changesError: 'erro', changesFold: '{count} linhas… clique para expandir', + changesFoldLoading: 'Expandindo…', + changesFoldUnavailable: 'Contexto indisponível', changesContext: 'contexto', changesPriorUnknown: 'O conteúdo anterior está fora da janela; mostrado como totalmente adicionado', explorer: 'Explorador', diff --git a/src/client/locales-ru.ts b/src/client/locales-ru.ts index e1d01cf..c75518c 100644 --- a/src/client/locales-ru.ts +++ b/src/client/locales-ru.ts @@ -26,6 +26,8 @@ export const ru: Record = { changesRunning: 'выполняется', changesError: 'ошибка', changesFold: '{count} строк… нажмите, чтобы развернуть', + changesFoldLoading: 'Разворачивание…', + changesFoldUnavailable: 'Контекст недоступен', changesContext: 'контекст', changesPriorUnknown: 'Содержимое до изменения вне окна; показано как полностью добавленное', explorer: 'Проводник', diff --git a/src/client/locales-sv.ts b/src/client/locales-sv.ts index c57f2fd..5b08a7b 100644 --- a/src/client/locales-sv.ts +++ b/src/client/locales-sv.ts @@ -13,6 +13,8 @@ export const sv: Record = { changesRunning: 'körs', changesError: 'fel', changesFold: '{count} rader… klicka för att expandera', + changesFoldLoading: 'Expanderar…', + changesFoldUnavailable: 'Kontext ej tillgänglig', changesContext: 'kontext', changesPriorUnknown: 'Innehållet före ändringen ligger utanför fönstret; visas som helt nytt', explorer: 'Utforskare', diff --git a/src/client/locales-th.ts b/src/client/locales-th.ts index 9bf3462..009ceac 100644 --- a/src/client/locales-th.ts +++ b/src/client/locales-th.ts @@ -30,6 +30,8 @@ export const th: Record = { changesRunning: 'กำลังทำงาน', changesError: 'ข้อผิดพลาด', changesFold: '{count} บรรทัด… คลิกเพื่อขยาย', + changesFoldLoading: 'กำลังขยาย…', + changesFoldUnavailable: 'ไม่สามารถโหลดบริบทได้', changesContext: 'บริบท', changesPriorUnknown: 'เนื้อหาก่อนแก้ไขอยู่นอกหน้าต่าง แสดงเป็นเพิ่มใหม่ทั้งหมด', explorer: 'ตัวสำรวจ', diff --git a/src/client/locales-tr.ts b/src/client/locales-tr.ts index e18ee3c..633374a 100644 --- a/src/client/locales-tr.ts +++ b/src/client/locales-tr.ts @@ -30,6 +30,8 @@ export const tr: Record = { changesRunning: 'çalışıyor', changesError: 'hata', changesFold: '{count} satır… genişletmek için tıklayın', + changesFoldLoading: 'Genişletiliyor…', + changesFoldUnavailable: 'Bağlam yüklenemedi', changesContext: 'bağlam', changesPriorUnknown: 'Değişiklik öncesi içerik pencere dışında; tamamen yeni eklenmiş olarak gösteriliyor', explorer: 'Gezgin', diff --git a/src/client/locales-vi.ts b/src/client/locales-vi.ts index 42b803e..03df336 100644 --- a/src/client/locales-vi.ts +++ b/src/client/locales-vi.ts @@ -30,6 +30,8 @@ export const vi: Record = { changesRunning: 'đang chạy', changesError: 'lỗi', changesFold: '{count} dòng… bấm để mở rộng', + changesFoldLoading: 'Đang mở rộng…', + changesFoldUnavailable: 'Không tải được ngữ cảnh', changesContext: 'ngữ cảnh', changesPriorUnknown: 'Nội dung trước thay đổi nằm ngoài cửa sổ; hiển thị như toàn bộ mới thêm', explorer: 'Trình khám phá', diff --git a/src/client/locales-zh-HK.ts b/src/client/locales-zh-HK.ts index 9e9af6a..ba55f2e 100644 --- a/src/client/locales-zh-HK.ts +++ b/src/client/locales-zh-HK.ts @@ -45,6 +45,8 @@ export const zhHK: Record = { changesRunning: '執行中', changesError: '出錯', changesFold: '{count} 行…點擊展開', + changesFoldLoading: '展開中…', + changesFoldUnavailable: '上下文未載入', changesContext: '上下文', changesPriorUnknown: '變更前的內容不在視窗內,顯示為全部新增', explorer: '檔案總管', diff --git a/src/client/locales-zh-MO.ts b/src/client/locales-zh-MO.ts index 1d17d13..21de407 100644 --- a/src/client/locales-zh-MO.ts +++ b/src/client/locales-zh-MO.ts @@ -45,6 +45,8 @@ export const zhMO: Record = { changesRunning: '執行中', changesError: '出錯', changesFold: '{count} 行…點擊展開', + changesFoldLoading: '展開中…', + changesFoldUnavailable: '上下文未載入', changesContext: '上下文', changesPriorUnknown: '變更前的內容不在視窗內,顯示為全部新增', explorer: '檔案總管', diff --git a/src/client/locales-zh-TW.ts b/src/client/locales-zh-TW.ts index 235624d..a1dfd60 100644 --- a/src/client/locales-zh-TW.ts +++ b/src/client/locales-zh-TW.ts @@ -45,6 +45,8 @@ export const zhTW: Record = { changesRunning: '執行中', changesError: '出錯', changesFold: '{count} 行…點擊展開', + changesFoldLoading: '展開中…', + changesFoldUnavailable: '上下文未載入', changesContext: '上下文', changesPriorUnknown: '變更前的內容不在視窗內,顯示為全部新增', explorer: '檔案總管', diff --git a/src/client/locales.ts b/src/client/locales.ts index 7a3f92b..07fca34 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -42,6 +42,8 @@ export const zh = { changesRedactOffLabel: '脱敏关', changesRedactBanner: '已脱敏', changesFold: '{count} 行…点击展开', + changesFoldLoading: '展开中…', + changesFoldUnavailable: '上下文未加载', changesContext: '上下文', changesPriorUnknown: '变更前的内容不在窗口内,显示为全新增', explorer: '资源管理器', @@ -489,6 +491,8 @@ export const en: Record = { changesRedactOffLabel: 'Redaction off', changesRedactBanner: 'Redacted', changesFold: '{count} lines…click to expand', + changesFoldLoading: 'Expanding…', + changesFoldUnavailable: 'Context unavailable', changesContext: 'context', changesPriorUnknown: 'Prior content is outside the loaded window; shown as all-added', explorer: 'Explorer', diff --git a/tests/diff-fold-expand.spec.tsx b/tests/diff-fold-expand.spec.tsx new file mode 100644 index 0000000..bd17542 --- /dev/null +++ b/tests/diff-fold-expand.spec.tsx @@ -0,0 +1,143 @@ +// @vitest-environment jsdom +/** + * The git fold expansion path: DiffFiles forwards a resolveFold loader to + * DiffRows, whose first click on a rows-less git gap fold fetches the hidden + * rows (loading/failed markers in between) and later clicks toggle; folds + * that carry rows (the session-op path) expand directly, and gaps without a + * loader stay inert markers — the pre-fix behavior for op diffs. + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { act } from 'react-dom/test-utils' +import { DiffFiles } from '../src/client/diff/DiffFiles.tsx' +import { DiffRows } from '../src/client/diff/DiffRows.tsx' +import { buildDiffSegments, diffLines, type DiffFile, type FoldSegment } from '../src/client/diff/rows.ts' + +import { setupReactAct } from './test-utils.ts' +setupReactAct() + +// A git-shaped two-hunk patch: the gap between the hunks (old/new lines +// 2..11) is a fold WITHOUT rows — expandable only through a resolveFold. +const gapDiff = [ + 'diff --git a/src/a.ts b/src/a.ts', + '--- a/src/a.ts', + '+++ b/src/a.ts', + '@@ -1 +1 @@', + '-old', + '+new', + '@@ -12 +12 @@', + '-tail', + '+tail2', +].join('\n') + +// The hidden context rows a loader slices out of both sides' contents. +const hiddenRows = Array.from({ length: 10 }, (_, i) => ({ + kind: 'context' as const, + oldLine: i + 2, + newLine: i + 2, + text: `ctx-${String(i + 2)}`, +})) + +afterEach(() => { document.body.innerHTML = '' }) + +describe('DiffFiles on-demand fold expansion', () => { + it('expands a git gap fold through resolveFold, then collapses back', async () => { + const resolveFold = vi.fn((_file: DiffFile, _segment: FoldSegment) => Promise.resolve(hiddenRows)) + const container = document.createElement('div') + document.body.append(container) + const root: Root = createRoot(container) + try { + act(() => { root.render(createElement(DiffFiles, { diff: gapDiff, resolveFold })) }) + const fold = container.querySelector('[data-expandable="true"]') + expect(fold).not.toBeNull() + expect(container.textContent).not.toContain('ctx-5') + + await act(async () => { fold!.click() }) + expect(resolveFold).toHaveBeenCalledOnce() + expect(container.textContent).toContain('ctx-5') + // The loader received the gap fold itself (line ranges on board). + const segment = resolveFold.mock.calls[0]![1] + expect(segment.kind).toBe('fold') + expect(segment.oldStart).toBe(2) + + // Collapse: the marker is back; the fetch never repeats. + const expanded = container.querySelector('[data-expandable="true"]') + await act(async () => { expanded!.click() }) + expect(container.textContent).not.toContain('ctx-5') + expect(resolveFold).toHaveBeenCalledOnce() + + // Re-expanding resolves from the per-fold cache (no second fetch). + const collapsed = container.querySelector('[data-expandable="true"]') + await act(async () => { collapsed!.click() }) + expect(container.textContent).toContain('ctx-5') + expect(resolveFold).toHaveBeenCalledOnce() + } finally { + act(() => { root.unmount() }) + container.remove() + } + }) + + it('degrades to the unavailable marker when the loader rejects', async () => { + const resolveFold = vi.fn(() => Promise.reject(new Error('boom'))) + const container = document.createElement('div') + document.body.append(container) + const root: Root = createRoot(container) + try { + act(() => { root.render(createElement(DiffFiles, { diff: gapDiff, resolveFold })) }) + const fold = container.querySelector('[data-expandable="true"]') + await act(async () => { fold!.click() }) + // The degraded fold promises no interaction. + expect(container.querySelector('[data-expandable]')).toBeNull() + expect(container.textContent).not.toContain('ctx-5') + // jsdom's navigator.language is en-US → the en copy. + expect(container.textContent).toContain('Context unavailable') + } finally { + act(() => { root.unmount() }) + container.remove() + } + }) + + it('keeps git gap folds inert without a resolver (session surfaces unchanged)', () => { + const container = document.createElement('div') + document.body.append(container) + const root: Root = createRoot(container) + try { + act(() => { root.render(createElement(DiffFiles, { diff: gapDiff })) }) + expect(container.querySelector('[data-expandable]')).toBeNull() + // The quiet marker still shows the hidden line count. + expect(container.textContent).toContain('10 lines') + } finally { + act(() => { root.unmount() }) + container.remove() + } + }) + + it('expands session folds (rows carried) directly, without any loader', async () => { + // buildDiffSegments output — the session-op path: folds carry their rows, + // so they toggle on click exactly as before the git expansion existed. + const rows = diffLines( + 'a\nb\nc\nd\ne\nX\nf\ng\nh\ni\nj\nk\nl\n', + 'a\nb\nc\nd\ne\nY\nf\ng\nh\ni\nj\nk\nl\n', + ) + const segments = buildDiffSegments(rows) + const container = document.createElement('div') + document.body.append(container) + const root: Root = createRoot(container) + try { + act(() => { root.render(createElement(DiffRows, { segments })) }) + const fold = container.querySelector('[data-expandable="true"]') + expect(fold).not.toBeNull() + // 'j' is hidden in the tail fold (the marker copy carries no 'j'). + expect(container.textContent).not.toContain('j') + await act(async () => { fold!.click() }) + expect(container.textContent).toContain('j') + const expanded = container.querySelector('[data-expandable="true"]') + await act(async () => { expanded!.click() }) + expect(container.textContent).not.toContain('j') + } finally { + act(() => { root.unmount() }) + container.remove() + } + }) +}) From 5bf7dfe3c4c8ded999215b89d4972893de19b80a Mon Sep 17 00:00:00 2001 From: haruhi Date: Tue, 8 Sep 2026 00:04:25 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(git):=20git.show=20=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E6=94=B9=E7=94=A8=E4=BB=93=E5=BA=93=E7=9B=B8=E5=AF=B9=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=EF=BC=8C=E4=BF=AE=E5=A4=8D=E6=8A=98=E5=8F=A0=E5=B1=95?= =?UTF-8?q?=E5=BC=80=E7=A9=BA=E7=99=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/index.ts: git.show 的 path 不再经 resolveGitPath 转绝对路径——rev:path 语法只认仓库相对路径(diff 输出剥 a// b/ 前缀的形态),绝对路径恒 fatal 返 null,折叠展开渲染空行;透传后暴露面与 git.diff/git.log 一致(仅本仓库版本树) - src/client/changes/DiffPane.tsx / src/client/DiffTab.tsx: 两侧内容皆空时抛错走「上下文未加载」降级,不再静默空白展开 背景:真机 link 测试发现展开后 marker 消失但零行渲染——git.show 路由此前无真实调用方,resolveGitPath 的工作区语义与 rev:path 树寻址不匹配的缝隙首次暴露。 设计 docs/plans/2026-09-07-git-fold-expand-design.md 实施偏差第 8 条同提交。 --- docs/plans/2026-09-07-git-fold-expand-design.md | 1 + src/client/DiffTab.tsx | 14 +++++++++++--- src/client/changes/DiffPane.tsx | 14 +++++++++++--- src/index.ts | 8 +++++++- 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/docs/plans/2026-09-07-git-fold-expand-design.md b/docs/plans/2026-09-07-git-fold-expand-design.md index eb20249..b2e2c04 100644 --- a/docs/plans/2026-09-07-git-fold-expand-design.md +++ b/docs/plans/2026-09-07-git-fold-expand-design.md @@ -214,3 +214,4 @@ gh pr create --repo omdsh-dev/DSH-better-sidebar --head LaoYueHanNi:fix/diff-fol 5. **DiffPane 组件级 gitScope**:fold loader 需要 SessionScope,新增组件级 `gitScope`(useMemo);load effect 的局部 `paneScope` 保持原样不动,最小侵入。 6. **本机测试基线**:Windows 本机全量跑 `tests/git.spec.ts` / `tests/git-worktree.spec.ts` 中 3 个真实 git 子进程集成用例会并发抖动失败(stash 后基线复现同样失败,与本次改动无关;CI Linux 绿)。diff 相关 18/18 全绿(新增 `foldRowsFromContents` 6 用例 + 组件测试 4 用例)。 7. **本地挂载冒烟未执行**:Windows 本地 Git Bash 下 `e2e-mount.sh` 将 tarball 规范为 MSYS 风格 `/d/...`,`file:` scheme 前缀使 MSYS 自动参数转换失效,Windows node 解析为 `C:\d\...` 而 ENOENT(经 `DSH_CMD` 注入 cygpath wrapper 可绕过,再因依赖下载网络受限放弃);`pnpm build` 产物构建已绿,挂载冒烟由 CI `plugin-mount` job 同款覆盖。 +8. **服务端零改动前提被证伪(真机修正)**:§3/§5 的「git.show 路由已存在、服务端零改动」不成立——该路由此前无真实调用方,其 path 经 `resolveGitPath` 解析为绝对文件系统路径(工作区命令语义),而 `git show :` 只接受仓库相对路径,恒 fatal 返 null,两侧空串切片 0 行,展开表现为「marker 消失、零行渲染」(link 真机首测发现)。修正:路由透传仓库相对路径(diff 输出剥前缀的形态,暴露面与 git.diff/git.log 一致,仅本仓库版本树),另在两 lens 加「两侧皆空 → 降级『上下文未加载』」防御,取数异常不再静默空白展开。 diff --git a/src/client/DiffTab.tsx b/src/client/DiffTab.tsx index fc3a500..345c2fb 100644 --- a/src/client/DiffTab.tsx +++ b/src/client/DiffTab.tsx @@ -100,6 +100,14 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif useEffect(() => { foldContents.current = new Map() }, [diff, tick]) const foldLoader = useMemo(() => { const sidesOf = (file: DiffFile): Promise<{ old: string; new: string }> => { + // Both sides empty cannot cover a non-empty fold — treat it as a failed + // fetch so the fold degrades to the unavailable marker instead of + // silently expanding to nothing (the symptom of a bad rev or path + // reading null on both sides). + const ofSides = (oldContent: string | null, newContent: string | null): { old: string; new: string } => { + if ((oldContent ?? '') === '' && (newContent ?? '') === '') throw new Error('no content on either side') + return { old: oldContent ?? '', new: newContent ?? '' } + } const fetchSides = async (): Promise<{ old: string; new: string }> => { const scope: SessionScope = { sessionId, cwd, ...(diff.repoRoot !== undefined ? { repoRoot: diff.repoRoot } : {}) } if (diff.kind === 'commit') { @@ -113,7 +121,7 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif ? Promise.resolve({ content: null }) : api.gitShow(scope, diff.hashFull, displayPath(file.newPath), diff.worktree), ]) - return { old: oldSide.content ?? '', new: newSide.content ?? '' } + return ofSides(oldSide.content, newSide.content) } // Worktree change: staged is HEAD vs index, unstaged is index vs // worktree (the worktree side reads the live file). @@ -127,7 +135,7 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif ? Promise.resolve({ content: null }) : api.gitShow(scope, ':0', displayPath(file.newPath), diff.worktree), ]) - return { old: oldSide.content ?? '', new: newSide.content ?? '' } + return ofSides(oldSide.content, newSide.content) } const [oldSide, worktree] = await Promise.all([ file.oldPath === '/dev/null' @@ -135,7 +143,7 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif : api.gitShow(scope, ':0', displayPath(file.oldPath), diff.worktree), api.fsRead(scope, resolveSidebarPath(diff.repoRoot ?? diff.worktree ?? cwd, displayPath(file.newPath))).catch(() => null), ]) - return { old: oldSide.content ?? '', new: worktree !== null && worktree.kind === 'text' ? worktree.content : '' } + return ofSides(oldSide.content, worktree !== null && worktree.kind === 'text' ? worktree.content : null) } const path = displayPath(file.newPath === '/dev/null' ? file.oldPath : file.newPath) let promise = foldContents.current.get(path) diff --git a/src/client/changes/DiffPane.tsx b/src/client/changes/DiffPane.tsx index be2b84a..e7c1d80 100644 --- a/src/client/changes/DiffPane.tsx +++ b/src/client/changes/DiffPane.tsx @@ -236,6 +236,14 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp const foldLoader = useMemo(() => { if (gitRef === null) return undefined const sidesOf = (file: DiffFile): Promise<{ old: string; new: string }> => { + // Both sides empty cannot cover a non-empty fold — treat it as a failed + // fetch so the fold degrades to the unavailable marker instead of + // silently expanding to nothing (the symptom of a bad rev or path + // reading null on both sides). + const ofSides = (oldContent: string | null, newContent: string | null): { old: string; new: string } => { + if ((oldContent ?? '') === '' && (newContent ?? '') === '') throw new Error('no content on either side') + return { old: oldContent ?? '', new: newContent ?? '' } + } const fetchSides = async (): Promise<{ old: string; new: string }> => { if (gitRef.kind === 'commit') { // The patch's -m --first-parent shape: old side from the parent, @@ -248,7 +256,7 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp ? Promise.resolve({ content: null }) : api.gitShow(gitScope, gitRef.hashFull, displayPath(file.newPath), gitRef.worktree), ]) - return { old: oldSide.content ?? '', new: newSide.content ?? '' } + return ofSides(oldSide.content, newSide.content) } // Worktree change: staged is HEAD vs index, unstaged is index vs // worktree (the worktree side reads the live file). @@ -262,7 +270,7 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp ? Promise.resolve({ content: null }) : api.gitShow(gitScope, ':0', displayPath(file.newPath), gitRef.worktree), ]) - return { old: oldSide.content ?? '', new: newSide.content ?? '' } + return ofSides(oldSide.content, newSide.content) } const [oldSide, worktree] = await Promise.all([ file.oldPath === '/dev/null' @@ -270,7 +278,7 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp : api.gitShow(gitScope, ':0', displayPath(file.oldPath), gitRef.worktree), api.fsRead(gitScope, resolveSidebarPath(gitRef.repoRoot ?? gitRef.worktree ?? scope.cwd, displayPath(file.newPath))).catch(() => null), ]) - return { old: oldSide.content ?? '', new: worktree !== null && worktree.kind === 'text' ? worktree.content : '' } + return ofSides(oldSide.content, worktree !== null && worktree.kind === 'text' ? worktree.content : null) } const path = displayPath(file.newPath === '/dev/null' ? file.oldPath : file.newPath) let promise = foldContents.current.get(path) diff --git a/src/index.ts b/src/index.ts index 51d76ba..be48e16 100644 --- a/src/index.ts +++ b/src/index.ts @@ -474,7 +474,13 @@ function buildApi( 'git.show': async (payload) => { const { cwd } = await gitCwdOf(payload) const repoRoot = selectedRepoOf(payload) - const path = await resolveGitPath(cwd, requireString(payload, 'path'), repoRoot) + // `git show :` addresses the path inside the revision TREE: + // repository-relative, exactly the unified diff's own path form (after + // the a// b/ prefix). The absolute filesystem paths resolveGitPath + // produces would break the rev:path syntax and fail every read, so the + // path passes through as-is — it can only address blobs of this repo's + // own revisions, the same surface git.diff/git.log already expose. + const path = requireString(payload, 'path') const rev = requireString(payload, 'rev') return { content: await git.show(cwd, rev, path, repoRoot) } },