- 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。
324 lines
13 KiB
TypeScript
324 lines
13 KiB
TypeScript
/**
|
|
* 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; the one
|
|
* exception is git gap folds, whose rows git never emitted and a caller
|
|
* supplies on demand through resolveFold (first click fetches, then they
|
|
* toggle like any fold).
|
|
*/
|
|
import { useEffect, useMemo, useRef, useState, type ReactElement, type ReactNode } from 'react'
|
|
import { t } from '../locales.ts'
|
|
import { coalesceInline, diffInline, MIN_FOLD, type DiffRow, type DiffSegment, type FoldSegment, 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
|
|
/** Fetch a git gap fold's hidden rows on demand (both sides' contents
|
|
* sliced by the fold's line ranges). Absent folds without `rows` — git's
|
|
* unemitted gaps without a resolver — stay non-expandable markers. */
|
|
resolveFold?: (segment: FoldSegment) => Promise<readonly DiffRow[]>
|
|
}
|
|
|
|
/** One file's diff rows: fold chips between hunks, highlighted code rows. */
|
|
export function DiffRows({ segments, lang, resolveFold }: 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)
|
|
// On-demand fold expansion (git gaps): resolved rows per segment index,
|
|
// the loading/failed markers, and an epoch that invalidates in-flight
|
|
// resolves when the segments identity changes.
|
|
const [foldData, setFoldData] = useState<ReadonlyMap<number, readonly DiffRow[]>>(new Map())
|
|
const [foldLoading, setFoldLoading] = useState<ReadonlySet<number>>(new Set())
|
|
const [foldFailed, setFoldFailed] = useState<ReadonlySet<number>>(new Set())
|
|
const foldEpoch = useRef(0)
|
|
// Reset all folding when the segments identity changes (new target).
|
|
useEffect(() => {
|
|
setExpandedLines(new Set())
|
|
setExpandedFolds(new Set())
|
|
setExpandedAll(false)
|
|
setFoldData(new Map())
|
|
setFoldLoading(new Set())
|
|
setFoldFailed(new Set())
|
|
foldEpoch.current += 1
|
|
}, [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>
|
|
)
|
|
}
|
|
if (foldFailed.has(segIndex)) {
|
|
// The on-demand fetch failed: a quiet, non-clickable marker — the
|
|
// expansion promise is gone, so no interaction is promised either.
|
|
return (
|
|
<div key={`fold-${String(segIndex)}`} className={css.foldRow}>
|
|
<span className={css.foldMarker} title={t('changesContext')}>
|
|
{t('changesFoldUnavailable')}
|
|
</span>
|
|
</div>
|
|
)
|
|
}
|
|
const resolvedRows = foldData.get(segIndex)
|
|
const expandable = (segment.rows !== undefined || resolvedRows !== undefined || resolveFold !== 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 loading = foldLoading.has(segIndex)
|
|
const isExpanded = expandedFolds.has(segIndex)
|
|
const revealed = segment.rows ?? resolvedRows
|
|
return (
|
|
<div
|
|
key={`fold-${String(segIndex)}`}
|
|
className={css.foldRow}
|
|
data-expandable={loading ? undefined : 'true'}
|
|
data-expanded={isExpanded ? 'true' : undefined}
|
|
onClick={loading ? undefined : () => {
|
|
if (revealed !== undefined) {
|
|
setExpandedFolds(prev => {
|
|
const next = new Set(prev)
|
|
if (next.has(segIndex)) next.delete(segIndex)
|
|
else next.add(segIndex)
|
|
return next
|
|
})
|
|
return
|
|
}
|
|
// A git gap fold's first click: fetch its rows, then expand.
|
|
if (resolveFold === undefined) return
|
|
const epoch = foldEpoch.current
|
|
setFoldLoading(prev => new Set(prev).add(segIndex))
|
|
resolveFold(segment).then(
|
|
(rows) => {
|
|
if (foldEpoch.current !== epoch) return
|
|
setFoldData(prev => new Map(prev).set(segIndex, rows))
|
|
setFoldLoading(prev => { const next = new Set(prev); next.delete(segIndex); return next })
|
|
setExpandedFolds(prev => new Set(prev).add(segIndex))
|
|
},
|
|
() => {
|
|
if (foldEpoch.current !== epoch) return
|
|
setFoldLoading(prev => { const next = new Set(prev); next.delete(segIndex); return next })
|
|
setFoldFailed(prev => new Set(prev).add(segIndex))
|
|
},
|
|
)
|
|
}}
|
|
>
|
|
{isExpanded && revealed !== undefined
|
|
? revealed.map((row, index) => renderDiffRow(row, `${segIndex}-${String(index)}`))
|
|
: (
|
|
<span className={css.foldMarker} title={t('changesContext')}>
|
|
{loading ? t('changesFoldLoading') : 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>
|
|
)
|
|
}
|