- 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 条同提交。
17 KiB
Git 折叠行真实展开——修复「点击展开」无反应
日期:2026-09-07 · 状态:计划已定,待实施 · 分支(拟):
fix/diff-fold-expand关联:上游 issue(待提)、上游 PR(待提,Closes issue)· 上游仓库omdsh-dev/DSH-better-sidebar,本地 forkLaoYueHanNi/DSH-better-sidebar(main6fbfeed与上游零差异)
1. 背景与 bug 确认(已核实)
现象:changes 组件点击 git 提交(或工作区 diff)后,diff 视图中 hunk 之间的折叠行显示「n 行…点击展开」,点击无反应(无 pointer/hover)。session(会话文件操作)lens 的折叠行可正常展开——仅 git lens 永远不可展开。
证据链:
src/client/diff/rows.tsL407-433(unifiedSegments):git-U3不输出 hunk 之间的未改动行,gap fold 段只有行号范围、没有rows字段(注释明确 "rows are unknown (non-expandable)");src/client/diff/DiffRows.tsxL193-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.cssL50-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 折叠行时按需拉取两侧完整内容、按行号切片填充真实上下文行,让「点击展开」名副其实。
- git 提交/工作区 diff 折叠行点击后展开未改动上下文行(带双侧行号),再点可折叠;
- session lens 行为完全不变;二进制/untracked 文件不受影响;
- 内容拉取失败降级为无点击暗示的中性文案;
pnpm typecheck/pnpm test全绿,CI 三 job(挂载冒烟/typecheck/test)绿;- 上游 issue(问题+预计方案)与 PR(实际修复,
Closes #N)按仓库规范挂接。
3. 已确认的实现地基
- 服务端
git.show路由已存在(src/index.tsL474-480):payload{rev, path}(rev 可为:0/HEAD/<hash>^/<hashFull>),返回{content: string | null},失败(null)内建于git.ts show()的 try/catch → 服务端零改动; src/git.tsdiff()(L382-388):unstaged =git diff -- path(index vs worktree),staged =git diff --cached(HEAD vs index)——决定展开时两侧 rev 取法;- 渲染链:
DiffPane(底部预览)/DiffTab(独立 tab)→DiffFiles(per-fileunifiedSegments)→DiffRows;op 路径直接DiffRows(buildDiffSegments)。客户端api.ts尚无git.show封装(需补一行 call); SidebarDiffRef(src/client/state.tsL24-26):worktree{path, staged, untracked?, worktree?, repoRoot?}|commit{hash, hashFull, subject, worktree?, repoRoot?};- spawn 数组参数不经 shell,
<hash>^的^无需转义;git show :0:<path>取 index 版本合法。
4. 实现设计(按子系统)
4.1 src/client/api.ts — gitShow 客户端封装
仿 gitCommitDiff(L317-318):
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 — 切片纯函数
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<readonly DiffRow[]>(不传 = 现状,op 路径不变); - 新 state:
foldData: Map<number, readonly DiffRow[]>、foldLoading: Set<number>、foldFailed: Set<number>,随现有 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<readonly DiffRow[]>,转发DiffRows; - 实例级
useRef<Map<string, Promise<readonly DiffRow[]>>>,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, '<hashFull>^', 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展开中…/ enExpanding…changesFoldUnavailable:zh上下文未加载/ enContext unavailablelocales.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):mockapi.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
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:<hash>^与<hash>;worktree staged:HEAD与:0;unstaged::0与工作区文件),按 fold 已知行号范围切片为 context 行填充渲染;per 文件 Promise 缓存,失败降级为中性文案。将在 fork 修好后提 PR(Closes 本 issue)。截图:<用户提供的截图,展示「11 行…点击展开」行>
步骤 2:分支与实现
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):
docs(diff): git 折叠上下文按需展开设计文档feat(api): 暴露 gitShow 单文件版本内容读取feat(diff): 折叠行异步展开状态机与内容切片(rows.ts + DiffRows + DiffFiles + 测试)fix(diff): git lens 折叠行接入按需展开,修复点击无反应(DiffPane/DiffTab + i18n + 组件测试)
每个 commit 保持 typecheck/test 可绿。
步骤 3:验证
pnpm typecheck && pnpm test # 全量门禁
pnpm build && pnpm pack && pnpm test:mount # 挂载冒烟(CI 同款)
真机点开含多 hunk 提交,验证展开/折叠/失败降级,截图用于 PR。
步骤 4:发 PR
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:resolveFoldprop + 展开/加载/失败状态机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/changesFoldUnavailabletests/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 创建前等用户批准。
实施偏差记录
实施过程中与本文设计的任何偏离,逐条记录在此(惯例:文档以本节为准)。
- 语言文件数量:§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 计数随之更正。 - 缓存拆为两层:§4.4 把「同文件多 fold 共享一次内容拉取」归给 DiffFiles 的 fold 级 Promise 缓存,但 fold 级 key 含行号(
path|oldStart|newStart),无法去重内容请求;实施时把内容拉取缓存上移到 lens 层(DiffPane/DiffTab的foldContents,key =displayPath),DiffFiles 的 fold 级缓存保留(价值:文件头折叠再展开导致 DiffRows 重挂载后秒开、并发点击去重,reject 时摘除)。两层各司其职。 - loading 态交互与竞态防护:§4.3 三态渲染之外,加载中的 fold 不响应点击且无 pointer(
data-expandable移除),防 loading 期间重复触发;另以foldEpochref 使 segments 重置后 still-in-flight 的 resolve 回调失效,防「旧 fold 的迟到结果写进新 segments 同下标」竞态(§4.3 未展开)。 - unstaged 新侧读取失败的宽容降级:§4.5 未细化——工作区侧
fsRead失败(如文件已删除)或返回二进制时降级为空串(catch → null →'')而非整体 failed,让旧侧(index)上下文依然可展开。 - DiffPane 组件级 gitScope:fold loader 需要 SessionScope,新增组件级
gitScope(useMemo);load effect 的局部paneScope保持原样不动,最小侵入。 - 本机测试基线:Windows 本机全量跑
tests/git.spec.ts/tests/git-worktree.spec.ts中 3 个真实 git 子进程集成用例会并发抖动失败(stash 后基线复现同样失败,与本次改动无关;CI Linux 绿)。diff 相关 18/18 全绿(新增foldRowsFromContents6 用例 + 组件测试 4 用例)。 - 本地挂载冒烟未执行:Windows 本地 Git Bash 下
e2e-mount.sh将 tarball 规范为 MSYS 风格/d/...,file:scheme 前缀使 MSYS 自动参数转换失效,Windows node 解析为C:\d\...而 ENOENT(经DSH_CMD注入 cygpath wrapper 可绕过,再因依赖下载网络受限放弃);pnpm build产物构建已绿,挂载冒烟由 CIplugin-mountjob 同款覆盖。 - 服务端零改动前提被证伪(真机修正):§3/§5 的「git.show 路由已存在、服务端零改动」不成立——该路由此前无真实调用方,其 path 经
resolveGitPath解析为绝对文件系统路径(工作区命令语义),而git show <rev>:<path>只接受仓库相对路径,恒 fatal 返 null,两侧空串切片 0 行,展开表现为「marker 消失、零行渲染」(link 真机首测发现)。修正:路由透传仓库相对路径(diff 输出剥前缀的形态,暴露面与 git.diff/git.log 一致,仅本仓库版本树),另在两 lens 加「两侧皆空 → 降级『上下文未加载』」防御,取数异常不再静默空白展开。