Files
apes-sidebar/src/client/diff/DiffRows.tsx
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

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>
)
}