Merge pull request #576 from LaoYueHanNi/fix/diff-fold-expand
fix(diff): git 折叠上下文按需展开,修复「点击展开」无反应
This commit is contained in:
@@ -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
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -31,6 +31,8 @@ export const ar: Record<string, string> = {
|
||||
changesRunning: 'قيد التنفيذ',
|
||||
changesError: 'خطأ',
|
||||
changesFold: '{count} أسطر… انقر للتوسيع',
|
||||
changesFoldLoading: 'جارٍ التوسيع…',
|
||||
changesFoldUnavailable: 'السياق غير متوفر',
|
||||
changesContext: 'سياق',
|
||||
changesPriorUnknown: 'المحتوى السابق خارج النافذة؛ يُعرض كما لو أُضيف بالكامل',
|
||||
explorer: 'المستكشف',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -30,6 +30,8 @@ export const hi: Record<string, string> = {
|
||||
changesRunning: 'चल रहा है',
|
||||
changesError: 'त्रुटि',
|
||||
changesFold: '{count} पंक्तियाँ… विस्तार के लिए क्लिक करें',
|
||||
changesFoldLoading: 'विस्तार हो रहा है…',
|
||||
changesFoldUnavailable: 'संदर्भ अनुपलब्ध',
|
||||
changesContext: 'संदर्भ',
|
||||
changesPriorUnknown: 'बदलाव से पहले की सामग्री विंडो में नहीं है; पूरी नई जोड़ी गई दिखाई जाती है',
|
||||
explorer: 'एक्सप्लोरर',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -30,6 +30,8 @@ export const ja: Record<string, string> = {
|
||||
changesRunning: '実行中',
|
||||
changesError: 'エラー',
|
||||
changesFold: '{count} 行…クリックで展開',
|
||||
changesFoldLoading: '展開中…',
|
||||
changesFoldUnavailable: 'コンテキスト未読み込み',
|
||||
changesContext: 'コンテキスト',
|
||||
changesPriorUnknown: '変更前の内容がウィンドウ内になく、すべて新規追加として表示されます',
|
||||
explorer: 'エクスプローラー',
|
||||
|
||||
@@ -22,6 +22,8 @@ export const ko: Record<string, string> = {
|
||||
changesRunning: '실행 중',
|
||||
changesError: '오류',
|
||||
changesFold: '{count}줄… 클릭하여 펼치기',
|
||||
changesFoldLoading: '펼치는 중…',
|
||||
changesFoldUnavailable: '컨텍스트를 불러올 수 없음',
|
||||
changesContext: '컨텍스트',
|
||||
changesPriorUnknown: '변경 전 내용이 창에 없어 모두 새로 추가된 것으로 표시됩니다',
|
||||
explorer: '탐색기',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -26,6 +26,8 @@ export const ru: Record<string, string> = {
|
||||
changesRunning: 'выполняется',
|
||||
changesError: 'ошибка',
|
||||
changesFold: '{count} строк… нажмите, чтобы развернуть',
|
||||
changesFoldLoading: 'Разворачивание…',
|
||||
changesFoldUnavailable: 'Контекст недоступен',
|
||||
changesContext: 'контекст',
|
||||
changesPriorUnknown: 'Содержимое до изменения вне окна; показано как полностью добавленное',
|
||||
explorer: 'Проводник',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -30,6 +30,8 @@ export const th: Record<string, string> = {
|
||||
changesRunning: 'กำลังทำงาน',
|
||||
changesError: 'ข้อผิดพลาด',
|
||||
changesFold: '{count} บรรทัด… คลิกเพื่อขยาย',
|
||||
changesFoldLoading: 'กำลังขยาย…',
|
||||
changesFoldUnavailable: 'ไม่สามารถโหลดบริบทได้',
|
||||
changesContext: 'บริบท',
|
||||
changesPriorUnknown: 'เนื้อหาก่อนแก้ไขอยู่นอกหน้าต่าง แสดงเป็นเพิ่มใหม่ทั้งหมด',
|
||||
explorer: 'ตัวสำรวจ',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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á',
|
||||
|
||||
@@ -45,6 +45,8 @@ export const zhHK: Record<string, string> = {
|
||||
changesRunning: '執行中',
|
||||
changesError: '出錯',
|
||||
changesFold: '{count} 行…點擊展開',
|
||||
changesFoldLoading: '展開中…',
|
||||
changesFoldUnavailable: '上下文未載入',
|
||||
changesContext: '上下文',
|
||||
changesPriorUnknown: '變更前的內容不在視窗內,顯示為全部新增',
|
||||
explorer: '檔案總管',
|
||||
|
||||
@@ -45,6 +45,8 @@ export const zhMO: Record<string, string> = {
|
||||
changesRunning: '執行中',
|
||||
changesError: '出錯',
|
||||
changesFold: '{count} 行…點擊展開',
|
||||
changesFoldLoading: '展開中…',
|
||||
changesFoldUnavailable: '上下文未載入',
|
||||
changesContext: '上下文',
|
||||
changesPriorUnknown: '變更前的內容不在視窗內,顯示為全部新增',
|
||||
explorer: '檔案總管',
|
||||
|
||||
@@ -45,6 +45,8 @@ export const zhTW: Record<string, string> = {
|
||||
changesRunning: '執行中',
|
||||
changesError: '出錯',
|
||||
changesFold: '{count} 行…點擊展開',
|
||||
changesFoldLoading: '展開中…',
|
||||
changesFoldUnavailable: '上下文未載入',
|
||||
changesContext: '上下文',
|
||||
changesPriorUnknown: '變更前的內容不在視窗內,顯示為全部新增',
|
||||
explorer: '檔案總管',
|
||||
|
||||
@@ -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
@@ -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) }
|
||||
},
|
||||
|
||||
@@ -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
@@ -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' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user