在 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 菜单标题同步。
124 lines
4.5 KiB
TypeScript
124 lines
4.5 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 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'])
|
||
})
|
||
})
|