Files
apes-sidebar/tests/diff-rows.spec.ts
T
Menghuan1918 24a3813c63 feat!: 合并 Git 面板与文件追踪为统一「文件变动」tab(双视角 + 统一 diff 栈)
在 PR #471(file-trace)基础上的演进:

- 合并 tab:git tab(id 保留,兼容持久化布局)升级为「文件变动」双视角
  tab——Git 视角(原 GitView 全部能力:staged/unstaged/提交/历史/worktree·
  子仓库选择/右键菜单/确认弹窗)+ 本轮文件视角(原 file-trace:会话事件
  日志实时折叠的模型读写编辑记录,按文件分组、新增类型筛选 chips、相对
  时间);file-trace tab 移除(未随任何版本发布)。
- 统一 diff 组件:新 src/client/diff/ 模块(rows.ts 引擎 + DiffRows/
  DiffFiles 渲染器 + highlight.ts 语法着色)——git diff 与会话操作 diff
  共用一套渲染:mod 改写配对(del-run/add-run 对半标蓝)、行内字符级
  高亮(修复了旧实现旧侧失配不高亮的问题,改为行对象身份键)、上下文
  hunk 折叠、git hunk 间未输出区间按行号范围生成不可展开折叠、语法着色
  色相落位 diff 三色空档、+n −m 统计 chips、行底色 tint;DiffTab 改用
  该栈(git diff tab 从此也有高亮)。
- 交互:两视角共用底部可拖拽预览面板(点击文件/提交/操作即预览,不再
  直接开 diff tab;面板可刷新、可键盘/拖拽调高、git 目标可一键展开为
  独立 diff tab);视角选择与面板高度持久化在 tab.meta;git 行 XY 徽章
  按状态字母着色;选中行高亮。
- i18n:fileTrace* 十键重命名为 changes* 前缀,git 键值更新为「文件
  变动」,新增 7 键(视角/筛选/面板操作),全部 21 个词典同步。
- 结构:src/client/changes/(ChangesTab/GitLens/SessionLens/DiffPane/
  ops)+ src/client/diff/;GitView/DiffView/filetrace/ 删除;git 样式
  从 sidebar.module.css 迁至 changes.module.css。
- 测试:builtins 8→7(git tab 承接 badge pin)、diff-rows(unified
  转换/mod 配对/间隙折叠/统计)、changes-tab(壳交互 jsdom)、
  diff-files-collapse(原 DiffView 折叠行为钉住)、changes-ops(原
  filetrace 数据层)、git-view-worktree 迁移至 GitLens;全量 1122 通过。
- 文档:README ×2 功能矩阵合并、external-plugin-guide §4.4 与内置清单
  7 tab、mount e2e 菜单标题同步。
2026-08-31 01:31:22 +08:00

124 lines
4.5 KiB
TypeScript
Raw 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 non-expandable folds carrying their line ranges, 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'
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'])
})
})