- src/client/api.ts: 仿 gitCommitDiff 新增 gitShow 封装,走既有 git.show 服务路由(payload {rev, path},返回 content 或 null),服务端零改动
- src/client/diff/rows.ts: 新增 foldRowsFromContents 纯函数——old 侧按 fold 行号切片为 context 行(new 侧经区间偏移映射,落界才带行号),new 侧补纯增行;越界截断、\r 保留
- src/client/diff/DiffRows.tsx: 新增 resolveFold prop;foldData/foldLoading/foldFailed 三态,加载中不可点,失败降级静态标记;foldEpoch 使 segments 重置后 in-flight 回调失效
- src/client/diff/DiffFiles.tsx: resolveFold 下传各文件 DiffRows,fold 级 Promise 缓存去重并发点击与重挂载
- tests/diff-rows.spec.ts: foldRowsFromContents 六用例(对称/纯删/纯增/\r\n/空内容/越界)
背景:git -U3 不输出 hunk 间未改动行,gap fold 只有行号范围没有 rows,渲染复用「点击展开」文案却无交互;展开能力建为独立状态机(含 api 封装),git lens 只需注入取数 loader。
设计 docs/plans/2026-09-07-git-fold-expand-design.md §4.1-4.4。
179 lines
7.1 KiB
TypeScript
179 lines
7.1 KiB
TypeScript
/**
|
||
* 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 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, foldRowsFromContents, type FoldSegment } from '../src/client/diff/rows.ts'
|
||
|
||
const twoHunks = [
|
||
'diff --git a/a.ts b/a.ts',
|
||
'--- a/a.ts',
|
||
'+++ b/a.ts',
|
||
'@@ -1,4 +1,4 @@',
|
||
' context',
|
||
'-old line',
|
||
'+new line',
|
||
' tail',
|
||
'@@ -20,3 +20,4 @@',
|
||
' far below',
|
||
'-gone',
|
||
'+kept',
|
||
'+added',
|
||
' tail2',
|
||
].join('\n')
|
||
|
||
describe('unifiedSegments', () => {
|
||
it('converts hunk lines to rows and pairs the rewrite as mod', () => {
|
||
const file = parseUnifiedDiff(twoHunks).files[0]!
|
||
const segments = unifiedSegments(file)
|
||
// hunk, gap fold (lines 4..19 hidden), hunk
|
||
expect(segments.map(s => s.kind)).toEqual(['hunk', 'fold', 'hunk'])
|
||
const first = segments[0]!
|
||
expect(first.kind === 'hunk' && first.rows.map(r => r.kind)).toEqual(['context', 'mod', 'mod', 'context'])
|
||
// The paired rows keep their own side's line number.
|
||
const modOld = first.kind === 'hunk' ? first.rows[1]! : undefined
|
||
const modNew = first.kind === 'hunk' ? first.rows[2]! : undefined
|
||
expect(modOld?.oldLine).toBe(2)
|
||
expect(modOld?.newLine).toBeUndefined()
|
||
expect(modNew?.oldLine).toBeUndefined()
|
||
expect(modNew?.newLine).toBe(2)
|
||
})
|
||
|
||
it('emits a non-expandable fold for the unemitted gap between hunks', () => {
|
||
const file = parseUnifiedDiff(twoHunks).files[0]!
|
||
const gap = unifiedSegments(file)[1]!
|
||
expect(gap.kind).toBe('fold')
|
||
if (gap.kind === 'fold') {
|
||
expect(gap.rows).toBeUndefined()
|
||
// First hunk ends at line 3 on both sides, second starts at 20 → gap 16.
|
||
expect(gap.count).toBe(16)
|
||
expect(gap.oldStart).toBe(4)
|
||
expect(gap.oldEnd).toBe(19)
|
||
expect(gap.newStart).toBe(4)
|
||
expect(gap.newEnd).toBe(19)
|
||
}
|
||
})
|
||
|
||
it('emits a leading fold when the first hunk starts below line 1', () => {
|
||
const diff = [
|
||
'diff --git a/a.ts b/a.ts',
|
||
'--- a/a.ts',
|
||
'+++ b/a.ts',
|
||
'@@ -10 +10 @@',
|
||
'-old',
|
||
'+new',
|
||
].join('\n')
|
||
const file = parseUnifiedDiff(diff).files[0]!
|
||
const segments = unifiedSegments(file)
|
||
expect(segments.map(s => s.kind)).toEqual(['fold', 'hunk'])
|
||
const lead = segments[0]!
|
||
if (lead.kind === 'fold') {
|
||
expect(lead.count).toBe(9)
|
||
expect(lead.rows).toBeUndefined()
|
||
}
|
||
})
|
||
|
||
it('carries the no-newline marker as a meta row', () => {
|
||
const diff = [
|
||
'diff --git a/a.ts b/a.ts',
|
||
'--- a/a.ts',
|
||
'+++ b/a.ts',
|
||
'@@ -1 +1 @@',
|
||
'-old',
|
||
'\\ No newline at end of file',
|
||
'+new',
|
||
].join('\n')
|
||
const file = parseUnifiedDiff(diff).files[0]!
|
||
const rows = unifiedSegments(file).flatMap(s => (s.kind === 'hunk' ? s.rows : []))
|
||
expect(rows.map(r => r.kind)).toEqual(['del', 'meta', 'add'])
|
||
expect(rows[1]).toMatchObject({ kind: 'meta', text: ' No newline at end of file' })
|
||
})
|
||
|
||
it('counts header stats with mods on both sides', () => {
|
||
const file = parseUnifiedDiff(twoHunks).files[0]!
|
||
// Hunk 1 pairs 1 del + 1 add (2 mod rows); hunk 2 pairs 1 of its 1 del
|
||
// with 1 of its 2 adds (2 mod rows) leaving 1 pure add: 5 added, 4 deleted.
|
||
expect(diffStats(unifiedSegments(file))).toEqual({ added: 5, deleted: 4 })
|
||
// A full-file addition (untracked fallback) counts every row once.
|
||
const untracked = untrackedFile('new.ts', 'a\nb\n')
|
||
expect(diffStats(unifiedSegments(untracked))).toEqual({ added: 2, deleted: 0 })
|
||
})
|
||
})
|
||
|
||
describe('pairMods', () => {
|
||
it('pairs only the overlapping del/add rows within one run', () => {
|
||
const rows = pairMods([
|
||
{ kind: 'context' as const, oldLine: 1, newLine: 1, text: 'x' },
|
||
{ kind: 'del' as const, oldLine: 2, text: 'a' },
|
||
{ kind: 'del' as const, oldLine: 3, text: 'b' },
|
||
{ kind: 'add' as const, newLine: 2, text: 'B' },
|
||
{ kind: 'add' as const, newLine: 3, text: 'c' },
|
||
{ kind: 'add' as const, newLine: 4, text: 'd' },
|
||
])
|
||
expect(rows.map(r => r.kind)).toEqual(['context', 'mod', 'mod', 'mod', 'mod', 'add'])
|
||
})
|
||
|
||
it('matches diffLines output (the LCS walk pairs rewrites the same way)', () => {
|
||
const rows = diffLines('hello world', 'hello dsh')
|
||
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' },
|
||
])
|
||
})
|
||
})
|