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 菜单标题同步。
This commit is contained in:
Menghuan1918
2026-08-31 01:31:22 +08:00
parent 9255040489
commit 24a3813c63
53 changed files with 2693 additions and 1792 deletions
+270
View File
@@ -0,0 +1,270 @@
/**
* The one diff renderer every file-change surface shares (the changes tab's
* inline preview pane and the diff tab): fold-capable hunk rows with old/new
* 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.
*/
import { useEffect, useMemo, 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 { hasBlockComment, isColored, scanLine, type CodeToken, type TokenType } from './highlight.ts'
import css from './diff.module.css'
/** Long diff lines fold to one ellipsized row; the threshold is the char count. */
const FOLD_THRESHOLD = 120
/** Rendered hunk rows per file capped at this count; expand reveals the rest. */
const MAX_ROWS = 600
/** Token class -> CSS color class ('' inherits the row's diff color). */
const TOKEN_CLASS: Readonly<Record<TokenType, string>> = {
plain: '',
comment: css.tokComment ?? '',
string: css.tokString ?? '',
keyword: css.tokKeyword ?? '',
number: css.tokNumber ?? '',
type: css.tokType ?? '',
function: css.tokFunction ?? '',
macro: css.tokMacro ?? '',
}
/** One token span's class list: its color class, plus the change tint. */
function tokenSpanClass(type: TokenType, changed: boolean): string {
const color = TOKEN_CLASS[type]
return changed ? `${color} ${css.inlineChange}` : color
}
/** Render scanned tokens as colored nodes; uncolored runs stay text. */
function tokensToNodes(tokens: readonly CodeToken[], changed = false): ReactNode[] {
const nodes: ReactNode[] = []
for (const token of tokens) {
if (!changed && !isColored(token)) nodes.push(token.text)
else nodes.push(<span key={String(nodes.length)} className={tokenSpanClass(token.type, changed)}>{token.text}</span>)
}
return nodes
}
/**
* Intra-line diffs for mod-row pairs, keyed by row identity: each mod-run's
* first half (old side) pairs with its second half (new side), each pair
* diffed by common prefix/suffix so both sides highlight the exact changed
* substring.
*/
function buildInlineMap(segments: readonly DiffSegment[]): Map<DiffRow, InlineDiff> {
const map = new Map<DiffRow, InlineDiff>()
for (const segment of segments) {
if (segment.kind !== 'hunk') continue
const rows = segment.rows
let i = 0
while (i < rows.length) {
if (rows[i]!.kind !== 'mod') { i += 1; continue }
let j = i
while (j < rows.length && rows[j]!.kind === 'mod') j += 1
const block = rows.slice(i, j)
const half = Math.floor(block.length / 2)
for (let p = 0; p < half; p += 1) {
const delRow = block[p]!
const addRow = block[p + half]!
const inline = diffInline(delRow.text, addRow.text)
map.set(delRow, inline)
map.set(addRow, inline)
}
i = j
}
}
return map
}
/**
* Per-row block-comment entry state for a diff: the old side threads along
* old-line order and the new side along new-line order (the row order
* preserves both), so multi-line comments color correctly on each side.
*/
function diffBlockEntries(segments: readonly DiffSegment[], lang: string | undefined): Map<DiffRow, boolean> {
const entries = new Map<DiffRow, boolean>()
if (!hasBlockComment(lang)) return entries
let oldIn = false
let newIn = false
for (const segment of segments) {
if (segment.kind !== 'hunk') continue
for (const row of segment.rows) {
const isOld = row.oldLine !== undefined
const isNew = row.newLine !== undefined
entries.set(row, isOld ? oldIn : newIn)
if (isOld) oldIn = scanLine(row.text, lang, oldIn).inBlock
if (isNew) newIn = scanLine(row.text, lang, newIn).inBlock
}
}
return entries
}
export interface DiffRowsProps {
/** Precomputed segments (hunks and folds) for one file's diff. */
segments: readonly DiffSegment[]
/** Syntax language id (langOfPath); undefined renders plain text. */
lang?: string
}
/** One file's diff rows: fold chips between hunks, highlighted code rows. */
export function DiffRows({ segments, lang }: 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)
// Reset all folding when the segments identity changes (new target).
useEffect(() => {
setExpandedLines(new Set())
setExpandedFolds(new Set())
setExpandedAll(false)
}, [segments])
const inlineMap = useMemo(() => buildInlineMap(segments), [segments])
const blockEntries = useMemo(() => diffBlockEntries(segments, lang), [segments, lang])
/** One diff row: colored sign + syntax-colored text, long-line fold toggle. */
const renderDiffRow = (row: DiffRow, rowKey: string): ReactElement => {
if (row.kind === 'meta') {
return (
<div key={rowKey} className={css.row} data-kind="meta">
<span className={css.metaText}>{row.text}</span>
</div>
)
}
const isLong = row.text.length > FOLD_THRESHOLD
const isFolded = isLong && !expandedLines.has(rowKey)
const blockEntry = blockEntries.get(row) ?? false
return (
<div
key={rowKey}
className={css.row}
data-kind={row.kind}
data-folded={isFolded ? 'true' : undefined}
onClick={isLong ? () => {
setExpandedLines(prev => {
const next = new Set(prev)
if (next.has(rowKey)) next.delete(rowKey)
else next.add(rowKey)
return next
})
} : undefined}
title={isFolded ? row.text : undefined}
>
<span className={css.lineNo}>{row.oldLine !== undefined ? String(row.oldLine) : ''}</span>
<span className={css.lineNo}>{row.newLine !== undefined ? String(row.newLine) : ''}</span>
<span className={css.sign}>{row.kind === 'del' ? '-' : row.kind === 'add' ? '+' : row.kind === 'mod' ? '~' : ' '}</span>
<span className={css.text} data-folded={isFolded ? 'true' : undefined}>
{row.kind === 'mod' && (() => {
const inline = inlineMap.get(row)
if (inline === undefined) return tokensToNodes(scanLine(row.text, lang, blockEntry).tokens)
// Coalesced change runs, each split into syntax tokens with
// block-comment state threaded across the runs of this line.
const side = coalesceInline(row.oldLine !== undefined ? inline.old : inline.next)
const nodes: ReactNode[] = []
let state = blockEntry
for (const seg of side) {
const scan = scanLine(seg.text, lang, state)
state = scan.inBlock
nodes.push(...tokensToNodes(scan.tokens, seg.changed))
}
return nodes
})()}
{row.kind !== 'mod' && tokensToNodes(scanLine(row.text, lang, blockEntry).tokens)}
</span>
</div>
)
}
let renderedRows = 0
const renderSegment = (segment: DiffSegment, segIndex: number): ReactNode => {
if (segment.kind === 'hunk') {
const capped = !expandedAll && renderedRows + segment.rows.length > MAX_ROWS
const rows = capped ? segment.rows.slice(0, Math.max(MAX_ROWS - renderedRows, 0)) : segment.rows
renderedRows += rows.length
return (
<div key={`hunk-${String(segIndex)}`}>
{rows.map((row, index) => renderDiffRow(row, `${segIndex}-${String(index)}`))}
</div>
)
}
const expandable = segment.rows !== undefined && segment.count >= MIN_FOLD
if (!expandable) {
// A tiny fold (or a git gap with no rows to reveal): a quiet marker.
return (
<div key={`fold-${String(segIndex)}`} className={css.foldRow}>
<span className={css.foldMarker} title={t('changesContext')}>
{t('changesFold', { count: segment.count })}
</span>
</div>
)
}
const isExpanded = expandedFolds.has(segIndex)
return (
<div
key={`fold-${String(segIndex)}`}
className={css.foldRow}
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
})
}}
>
{isExpanded
? segment.rows!.map((row, index) => renderDiffRow(row, `${segIndex}-${String(index)}`))
: (
<span className={css.foldMarker} title={t('changesContext')}>
{t('changesFold', { count: segment.count })}
</span>
)}
</div>
)
}
const parts = segments.map(renderSegment)
return (
<div className={css.rows}>
{parts}
{renderedRows >= MAX_ROWS && !expandedAll && (
<button type="button" className={css.expand} onClick={() => { setExpandedAll(true) }}>
{t('diffExpand', { count: segments.reduce((sum, s) => sum + (s.kind === 'hunk' ? s.rows.length : 0), 0) - renderedRows })}
</button>
)}
</div>
)
}
export interface ReadRowsProps {
/** The file lines with their real line numbers (parseReadLines output). */
lines: ReadonlyArray<{ line: number; text: string }>
/** Syntax language id (langOfPath); undefined renders plain text. */
lang?: string
}
/** The read view: a line-numbered, syntax-colored slice of a read file. */
export function ReadRows({ lines, lang }: ReadRowsProps) {
const rows = useMemo(() => {
let state = false
return lines.map((line) => {
const scan = scanLine(line.text, lang, state)
state = scan.inBlock
return { line: line.line, nodes: tokensToNodes(scan.tokens) }
})
}, [lines, lang])
return (
<div className={css.rows}>
{rows.map((row) => (
<div key={String(row.line)} className={css.row} data-kind="read">
<span className={css.lineNo}>{String(row.line)}</span>
<span className={css.text}>{row.nodes}</span>
</div>
))}
</div>
)
}