Files
apes-sidebar/tests/diff-rows.spec.ts
T
haruhi d990c10e59 feat(diff): 折叠行异步展开状态机与内容切片
- 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。
2026-09-07 23:51:06 +08:00

179 lines
7.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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' },
])
})
})