Merge pull request #576 from LaoYueHanNi/fix/diff-fold-expand

fix(diff): git 折叠上下文按需展开,修复「点击展开」无反应
This commit is contained in:
Atlas Zeng
2026-09-08 16:14:53 +08:00
committed by GitHub
30 changed files with 766 additions and 29 deletions
@@ -0,0 +1,217 @@
# 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` / `<hash>^` / `<hashFull>`),返回 `{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,`<hash>^` 的 `^` 无需转义;`git show :0:<path>` 取 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<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 `展开中…` / 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:`<hash>^` 与 `<hash>`;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 #<N>。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 同款覆盖。
8. **服务端零改动前提被证伪(真机修正)**:§3/§5 的「git.show 路由已存在、服务端零改动」不成立——该路由此前无真实调用方,其 path 经 `resolveGitPath` 解析为绝对文件系统路径(工作区命令语义),而 `git show <rev>:<path>` 只接受仓库相对路径,恒 fatal 返 null,两侧空串切片 0 行,展开表现为「marker 消失、零行渲染」(link 真机首测发现)。修正:路由透传仓库相对路径(diff 输出剥前缀的形态,暴露面与 git.diff/git.log 一致,仅本仓库版本树),另在两 lens 加「两侧皆空 → 降级『上下文未加载』」防御,取数异常不再静默空白展开。
+78 -3
View File
@@ -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<string | null>(null)
const [data, setData] = useState<DiffData | null>(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<boolean | null>(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<void> => {
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,71 @@ 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<string, Promise<{ old: string; new: string }>>())
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') {
// 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 ofSides(oldSide.content, 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 ofSides(oldSide.content, 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 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)
if (promise === undefined) {
promise = fetchSides()
foldContents.current.set(path, promise)
}
return promise
}
return (file: DiffFile, segment: FoldSegment): Promise<readonly DiffRow[]> =>
sidesOf(file).then(sides => foldRowsFromContents(segment, sides.old, sides.new))
}, [sessionId, cwd, diff, effectiveStaged])
return (
<div className={css.gitDiffTab}>
<div className={css.gitDiffTabHeader}>
@@ -104,7 +179,7 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif
<>
{data.untracked !== undefined
? <DiffFiles diff="" untrackedPath={diff.kind === 'worktree' ? diff.path : ''} untrackedContent={data.untracked} />
: <DiffFiles diff={data.diff} />}
: <DiffFiles diff={data.diff} resolveFold={foldLoader} />}
{data.diff === '' && data.untracked === undefined && (
<div className={css.gitEmpty}>{t('diffEmpty')}</div>
)}
+5
View File
@@ -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 <rev>:<path>`); 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
+84 -2
View File
@@ -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<string | null>(null)
const [diffText, setDiffText] = useState<string | null>(null)
const [untracked, setUntracked] = useState<string | undefined>(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<boolean | null>(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<SessionScope>(() => ({
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<void> => {
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,71 @@ 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<string, Promise<{ old: string; new: string }>>())
useEffect(() => { foldContents.current = new Map() }, [gitRef, tick])
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,
// 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 ofSides(oldSide.content, 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 ofSides(oldSide.content, 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 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)
if (promise === undefined) {
promise = fetchSides()
foldContents.current.set(path, promise)
}
return promise
}
return (file: DiffFile, segment: FoldSegment): Promise<readonly DiffRow[]> =>
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 +589,7 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
{diffText !== null && diffText !== '' && (
<DiffFiles
diff={diffText}
resolveFold={foldLoader}
untrackedPath={untracked !== undefined && target.ref.kind === 'worktree' ? target.ref.path : undefined}
untrackedContent={untracked}
/>
+29 -4
View File
@@ -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<readonly DiffRow[]>
}
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<Set<number>>(() => 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<string, Promise<readonly DiffRow[]>>())
useEffect(() => { foldCache.current = new Map() }, [parsed])
const loadFold = (file: DiffFile, segment: FoldSegment): Promise<readonly DiffRow[]> => {
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
)}
</button>
{expandable && fileExpanded && (
<DiffRows segments={segments} lang={langOfPath(to)} />
<DiffRows segments={segments} lang={langOfPath(to)} resolveFold={resolveFold !== undefined ? (segment) => loadFold(file, segment) : undefined} />
)}
</div>
)
+68 -16
View File
@@ -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<readonly DiffRow[]>
}
/** 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<ReadonlySet<string>>(new Set())
// Hunk-fold segments expanded by index; default collapsed.
const [expandedFolds, setExpandedFolds] = useState<ReadonlySet<number>>(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<ReadonlyMap<number, readonly DiffRow[]>>(new Map())
const [foldLoading, setFoldLoading] = useState<ReadonlySet<number>>(new Set())
const [foldFailed, setFoldFailed] = useState<ReadonlySet<number>>(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) {
</div>
)
}
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 (
<div key={`fold-${String(segIndex)}`} className={css.foldRow}>
<span className={css.foldMarker} title={t('changesContext')}>
{t('changesFoldUnavailable')}
</span>
</div>
)
}
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) {
</div>
)
}
const loading = foldLoading.has(segIndex)
const isExpanded = expandedFolds.has(segIndex)
const revealed = segment.rows ?? resolvedRows
return (
<div
key={`fold-${String(segIndex)}`}
className={css.foldRow}
data-expandable="true"
data-expandable={loading ? undefined : 'true'}
data-expanded={isExpanded ? 'true' : undefined}
onClick={() => {
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)}`))
: (
<span className={css.foldMarker} title={t('changesContext')}>
{t('changesFold', { count: segment.count })}
{loading ? t('changesFoldLoading') : t('changesFold', { count: segment.count })}
</span>
)}
</div>
+35
View File
@@ -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
+2
View File
@@ -31,6 +31,8 @@ export const ar: Record<string, string> = {
changesRunning: 'قيد التنفيذ',
changesError: 'خطأ',
changesFold: '{count} أسطر… انقر للتوسيع',
changesFoldLoading: 'جارٍ التوسيع…',
changesFoldUnavailable: 'السياق غير متوفر',
changesContext: 'سياق',
changesPriorUnknown: 'المحتوى السابق خارج النافذة؛ يُعرض كما لو أُضيف بالكامل',
explorer: 'المستكشف',
+2
View File
@@ -16,6 +16,8 @@ export const de: Record<string, string> = {
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',
+2
View File
@@ -23,6 +23,8 @@ export const fr: Record<string, string> = {
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',
+2
View File
@@ -30,6 +30,8 @@ export const hi: Record<string, string> = {
changesRunning: 'चल रहा है',
changesError: 'त्रुटि',
changesFold: '{count} पंक्तियाँ… विस्तार के लिए क्लिक करें',
changesFoldLoading: 'विस्तार हो रहा है…',
changesFoldUnavailable: 'संदर्भ अनुपलब्ध',
changesContext: 'संदर्भ',
changesPriorUnknown: 'बदलाव से पहले की सामग्री विंडो में नहीं है; पूरी नई जोड़ी गई दिखाई जाती है',
explorer: 'एक्सप्लोरर',
+2
View File
@@ -28,6 +28,8 @@ export const id: Record<string, string> = {
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',
+2
View File
@@ -21,6 +21,8 @@ export const it: Record<string, string> = {
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',
+2
View File
@@ -30,6 +30,8 @@ export const ja: Record<string, string> = {
changesRunning: '実行中',
changesError: 'エラー',
changesFold: '{count} 行…クリックで展開',
changesFoldLoading: '展開中…',
changesFoldUnavailable: 'コンテキスト未読み込み',
changesContext: 'コンテキスト',
changesPriorUnknown: '変更前の内容がウィンドウ内になく、すべて新規追加として表示されます',
explorer: 'エクスプローラー',
+2
View File
@@ -22,6 +22,8 @@ export const ko: Record<string, string> = {
changesRunning: '실행 중',
changesError: '오류',
changesFold: '{count}줄… 클릭하여 펼치기',
changesFoldLoading: '펼치는 중…',
changesFoldUnavailable: '컨텍스트를 불러올 수 없음',
changesContext: '컨텍스트',
changesPriorUnknown: '변경 전 내용이 창에 없어 모두 새로 추가된 것으로 표시됩니다',
explorer: '탐색기',
+2
View File
@@ -28,6 +28,8 @@ export const nl: Record<string, string> = {
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',
+2
View File
@@ -32,6 +32,8 @@ export const pl: Record<string, string> = {
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',
+2
View File
@@ -13,6 +13,8 @@ export const pt: Record<string, string> = {
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',
+2
View File
@@ -26,6 +26,8 @@ export const ru: Record<string, string> = {
changesRunning: 'выполняется',
changesError: 'ошибка',
changesFold: '{count} строк… нажмите, чтобы развернуть',
changesFoldLoading: 'Разворачивание…',
changesFoldUnavailable: 'Контекст недоступен',
changesContext: 'контекст',
changesPriorUnknown: 'Содержимое до изменения вне окна; показано как полностью добавленное',
explorer: 'Проводник',
+2
View File
@@ -13,6 +13,8 @@ export const sv: Record<string, string> = {
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',
+2
View File
@@ -30,6 +30,8 @@ export const th: Record<string, string> = {
changesRunning: 'กำลังทำงาน',
changesError: 'ข้อผิดพลาด',
changesFold: '{count} บรรทัด… คลิกเพื่อขยาย',
changesFoldLoading: 'กำลังขยาย…',
changesFoldUnavailable: 'ไม่สามารถโหลดบริบทได้',
changesContext: 'บริบท',
changesPriorUnknown: 'เนื้อหาก่อนแก้ไขอยู่นอกหน้าต่าง แสดงเป็นเพิ่มใหม่ทั้งหมด',
explorer: 'ตัวสำรวจ',
+2
View File
@@ -30,6 +30,8 @@ export const tr: Record<string, string> = {
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',
+2
View File
@@ -30,6 +30,8 @@ export const vi: Record<string, string> = {
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á',
+2
View File
@@ -45,6 +45,8 @@ export const zhHK: Record<string, string> = {
changesRunning: '執行中',
changesError: '出錯',
changesFold: '{count} 行…點擊展開',
changesFoldLoading: '展開中…',
changesFoldUnavailable: '上下文未載入',
changesContext: '上下文',
changesPriorUnknown: '變更前的內容不在視窗內,顯示為全部新增',
explorer: '檔案總管',
+2
View File
@@ -45,6 +45,8 @@ export const zhMO: Record<string, string> = {
changesRunning: '執行中',
changesError: '出錯',
changesFold: '{count} 行…點擊展開',
changesFoldLoading: '展開中…',
changesFoldUnavailable: '上下文未載入',
changesContext: '上下文',
changesPriorUnknown: '變更前的內容不在視窗內,顯示為全部新增',
explorer: '檔案總管',
+2
View File
@@ -45,6 +45,8 @@ export const zhTW: Record<string, string> = {
changesRunning: '執行中',
changesError: '出錯',
changesFold: '{count} 行…點擊展開',
changesFoldLoading: '展開中…',
changesFoldUnavailable: '上下文未載入',
changesContext: '上下文',
changesPriorUnknown: '變更前的內容不在視窗內,顯示為全部新增',
explorer: '檔案總管',
+4
View File
@@ -42,6 +42,8 @@ export const zh = {
changesRedactOffLabel: '脱敏关',
changesRedactBanner: '已脱敏',
changesFold: '{count} 行…点击展开',
changesFoldLoading: '展开中…',
changesFoldUnavailable: '上下文未加载',
changesContext: '上下文',
changesPriorUnknown: '变更前的内容不在窗口内,显示为全新增',
explorer: '资源管理器',
@@ -489,6 +491,8 @@ export const en: Record<keyof typeof zh, string> = {
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',
+7 -1
View File
@@ -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 <rev>:<path>` 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) }
},
+143
View File
@@ -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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[data-expandable="true"]')
await act(async () => { expanded!.click() })
expect(container.textContent).not.toContain('j')
} finally {
act(() => { root.unmount() })
container.remove()
}
})
})
+58 -3
View File
@@ -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' },
])
})
})