From 5bf7dfe3c4c8ded999215b89d4972893de19b80a Mon Sep 17 00:00:00 2001 From: haruhi Date: Tue, 8 Sep 2026 00:04:25 +0800 Subject: [PATCH] =?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) } },