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。
This commit is contained in:
@@ -316,6 +316,11 @@ export const api = {
|
||||
/** Full patch text of one commit (diff display for the history rows). */
|
||||
gitCommitDiff: (scope: SessionScope, hash: string, worktree?: string, signal?: AbortSignal) =>
|
||||
call<{ diff: string }>('git.commit-diff', gitPayload(scope, worktree, { hash }), signal),
|
||||
/** One file's content at a revision (`git show <rev>:<path>`); null when the
|
||||
* revision has no such path. The diff views' on-demand hunk-fold expansion
|
||||
* reads both sides' full contents through this. */
|
||||
gitShow: (scope: SessionScope, rev: string, path: string, worktree?: string, signal?: AbortSignal) =>
|
||||
call<{ content: string | null }>('git.show', gitPayload(scope, worktree, { rev, path }), signal),
|
||||
/** The session's file-tool events for the changes tab's session lens: the
|
||||
* `tool/call` + `tool/result` rows past `afterSeq` (0 = whole window),
|
||||
* capped to the recent window host-side. The client runtime exposes no
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
* Untracked files produce no `git diff` output; the caller passes their
|
||||
* content to render as a full-file addition instead.
|
||||
*/
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { t } from '../locales.ts'
|
||||
import { diffStats, displayPath, parseUnifiedDiff, unifiedSegments, untrackedFile, type DiffFile } from './rows.ts'
|
||||
import { diffStats, displayPath, parseUnifiedDiff, unifiedSegments, untrackedFile, type DiffFile, type DiffRow, type FoldSegment } from './rows.ts'
|
||||
import { langOfPath } from './highlight.ts'
|
||||
import { DiffRows } from './DiffRows.tsx'
|
||||
import css from './diff.module.css'
|
||||
@@ -51,9 +51,14 @@ export interface DiffFilesProps {
|
||||
/** Untracked-file content: when present, renders as a full-file addition instead of parsing. */
|
||||
untrackedPath?: string
|
||||
untrackedContent?: string
|
||||
/** Fetch a git gap fold's hidden rows on demand (both sides' contents by
|
||||
* the fold's line ranges); forwarded to every file's DiffRows. Absent
|
||||
* folds without `rows` stay non-expandable (session-op diffs and
|
||||
* untracked additions always carry theirs). */
|
||||
resolveFold?: (file: DiffFile, segment: FoldSegment) => Promise<readonly DiffRow[]>
|
||||
}
|
||||
|
||||
export function DiffFiles({ diff, untrackedPath, untrackedContent }: DiffFilesProps) {
|
||||
export function DiffFiles({ diff, untrackedPath, untrackedContent, resolveFold }: DiffFilesProps) {
|
||||
const parsed = useMemo(() => {
|
||||
if (untrackedPath !== undefined) {
|
||||
return { files: [untrackedFile(untrackedPath, untrackedContent ?? '')] }
|
||||
@@ -63,6 +68,26 @@ export function DiffFiles({ diff, untrackedPath, untrackedContent }: DiffFilesPr
|
||||
const [expandedFiles, setExpandedFiles] = useState<Set<number>>(() => defaultExpandedFiles(parsed.files))
|
||||
useEffect(() => { setExpandedFiles(defaultExpandedFiles(parsed.files)) }, [parsed])
|
||||
|
||||
// One in-flight/resolved promise per file+fold key, so a fold re-clicked
|
||||
// after a remount (file header collapsed and re-expanded) resolves without
|
||||
// a second fetch and concurrent clicks join the same request. The cache
|
||||
// dies with `parsed` (a new diff text); rejected promises are dropped so a
|
||||
// later retry can go through.
|
||||
const foldCache = useRef(new Map<string, Promise<readonly DiffRow[]>>())
|
||||
useEffect(() => { foldCache.current = new Map() }, [parsed])
|
||||
const loadFold = (file: DiffFile, segment: FoldSegment): Promise<readonly DiffRow[]> => {
|
||||
if (resolveFold === undefined) return Promise.resolve([])
|
||||
const path = displayPath(file.newPath === '/dev/null' ? file.oldPath : file.newPath)
|
||||
const key = `${path}|${String(segment.oldStart)}|${String(segment.newStart)}`
|
||||
let promise = foldCache.current.get(key)
|
||||
if (promise === undefined) {
|
||||
promise = resolveFold(file, segment)
|
||||
foldCache.current.set(key, promise)
|
||||
promise.catch(() => { foldCache.current.delete(key) })
|
||||
}
|
||||
return promise
|
||||
}
|
||||
|
||||
// Segments and header stats computed once per file.
|
||||
const files = useMemo(
|
||||
() => parsed.files.map((file) => {
|
||||
@@ -107,7 +132,7 @@ export function DiffFiles({ diff, untrackedPath, untrackedContent }: DiffFilesPr
|
||||
)}
|
||||
</button>
|
||||
{expandable && fileExpanded && (
|
||||
<DiffRows segments={segments} lang={langOfPath(to)} />
|
||||
<DiffRows segments={segments} lang={langOfPath(to)} resolveFold={resolveFold !== undefined ? (segment) => loadFold(file, segment) : undefined} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,11 +4,14 @@
|
||||
* 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.
|
||||
* 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, useState, type ReactElement, type ReactNode } from 'react'
|
||||
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 InlineDiff } from './rows.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'
|
||||
|
||||
@@ -105,21 +108,36 @@ export interface DiffRowsProps {
|
||||
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 }: DiffRowsProps) {
|
||||
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])
|
||||
@@ -190,7 +208,19 @@ export function DiffRows({ segments, lang }: DiffRowsProps) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const expandable = segment.rows !== undefined && segment.count >= MIN_FOLD
|
||||
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 (
|
||||
@@ -201,27 +231,49 @@ export function DiffRows({ segments, lang }: DiffRowsProps) {
|
||||
</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="true"
|
||||
data-expandable={loading ? undefined : 'true'}
|
||||
data-expanded={isExpanded ? 'true' : undefined}
|
||||
onClick={() => {
|
||||
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
|
||||
? segment.rows!.map((row, index) => renderDiffRow(row, `${segIndex}-${String(index)}`))
|
||||
{isExpanded && revealed !== undefined
|
||||
? revealed.map((row, index) => renderDiffRow(row, `${segIndex}-${String(index)}`))
|
||||
: (
|
||||
<span className={css.foldMarker} title={t('changesContext')}>
|
||||
{t('changesFold', { count: segment.count })}
|
||||
{loading ? t('changesFoldLoading') : t('changesFold', { count: segment.count })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -451,6 +451,41 @@ export function untrackedFile(path: string, content: string): DiffFile {
|
||||
return { oldPath: '/dev/null', newPath: `b/${path}`, binary: false, hunks: [{ oldStart: 0, newStart: 1, header: '', lines }] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize a git gap fold's hidden rows from the two sides' full file
|
||||
* contents, by the fold's known line ranges: the old side drives context
|
||||
* rows (each mapped onto the new side through the fold's offset — a gap is
|
||||
* an unchanged run, so the sides align), and new-side lines the old range
|
||||
* never reaches become pure additions. Line numbers clip to the actual
|
||||
* content (a no-newline file's ranges can overrun by one); `\r` endings
|
||||
* survive verbatim, like git's own context lines.
|
||||
*/
|
||||
export function foldRowsFromContents(fold: FoldSegment, oldContent: string, newContent: string): DiffRow[] {
|
||||
const oldLines = oldContent.length === 0 ? [] : oldContent.split('\n')
|
||||
const newLines = newContent.length === 0 ? [] : newContent.split('\n')
|
||||
const offset = fold.newStart - fold.oldStart
|
||||
const rows: DiffRow[] = []
|
||||
const oldFrom = Math.max(fold.oldStart, 1)
|
||||
const oldTo = Math.min(fold.oldEnd, oldLines.length)
|
||||
for (let oldLine = oldFrom; oldLine <= oldTo; oldLine += 1) {
|
||||
const text = oldLines[oldLine - 1]!
|
||||
const newLine = oldLine + offset
|
||||
rows.push(
|
||||
newLine >= fold.newStart && newLine <= fold.newEnd && newLine <= newLines.length
|
||||
? { kind: 'context', oldLine, newLine, text }
|
||||
: { kind: 'context', oldLine, text },
|
||||
)
|
||||
}
|
||||
// New-side lines beyond what the old range reached (a pure-addition gap):
|
||||
// rows carrying only the new-side number, exactly like added lines.
|
||||
const newFrom = Math.max(fold.newStart, 1)
|
||||
const newTo = Math.min(fold.newEnd, newLines.length)
|
||||
for (let newLine = Math.max(newFrom, oldTo + offset + 1); newLine <= newTo; newLine += 1) {
|
||||
rows.push({ kind: 'add', newLine, text: newLines[newLine - 1]! })
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/** Strip the `a/` / `b/` prefix git puts on diff paths (not on /dev/null). */
|
||||
export function displayPath(path: string): string {
|
||||
if (path === '/dev/null') return path
|
||||
|
||||
+58
-3
@@ -2,11 +2,12 @@
|
||||
* 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.
|
||||
* hunks become folds carrying their line ranges (expandable on demand via
|
||||
* foldRowsFromContents), 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'
|
||||
import { parseUnifiedDiff, unifiedSegments, diffLines, pairMods, diffStats, untrackedFile, foldRowsFromContents, type FoldSegment } from '../src/client/diff/rows.ts'
|
||||
|
||||
const twoHunks = [
|
||||
'diff --git a/a.ts b/a.ts',
|
||||
@@ -121,3 +122,57 @@ describe('pairMods', () => {
|
||||
expect(rows.map(r => r.kind)).toEqual(['mod', 'mod'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('foldRowsFromContents', () => {
|
||||
it('slices an aligned gap into context rows carrying both line numbers', () => {
|
||||
const fold: FoldSegment = { kind: 'fold', count: 4, oldStart: 4, oldEnd: 7, newStart: 6, newEnd: 9 }
|
||||
const rows = foldRowsFromContents(fold, 'a\nb\nc\nd\ne\nf\ng\nh\n', 'A\nB\nC\nD\nE\nF\nG\nH\nI\n')
|
||||
expect(rows).toEqual([
|
||||
{ kind: 'context', oldLine: 4, newLine: 6, text: 'd' },
|
||||
{ kind: 'context', oldLine: 5, newLine: 7, text: 'e' },
|
||||
{ kind: 'context', oldLine: 6, newLine: 8, text: 'f' },
|
||||
{ kind: 'context', oldLine: 7, newLine: 9, text: 'g' },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the old-side number alone when the new range cannot map (pure deletion gap)', () => {
|
||||
const fold: FoldSegment = { kind: 'fold', count: 2, oldStart: 3, oldEnd: 4, newStart: 3, newEnd: 2 }
|
||||
const rows = foldRowsFromContents(fold, 'a\nb\nc\nd\n', 'a\nb\n')
|
||||
expect(rows).toEqual([
|
||||
{ kind: 'context', oldLine: 3, text: 'c' },
|
||||
{ kind: 'context', oldLine: 4, text: 'd' },
|
||||
])
|
||||
})
|
||||
|
||||
it('emits pure additions for new lines the old range never reached (pure addition gap)', () => {
|
||||
const fold: FoldSegment = { kind: 'fold', count: 3, oldStart: 3, oldEnd: 2, newStart: 3, newEnd: 5 }
|
||||
const rows = foldRowsFromContents(fold, 'a\nb\n', 'a\nb\nc\nd\ne\n')
|
||||
expect(rows).toEqual([
|
||||
{ kind: 'add', newLine: 3, text: 'c' },
|
||||
{ kind: 'add', newLine: 4, text: 'd' },
|
||||
{ kind: 'add', newLine: 5, text: 'e' },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps \\r endings verbatim like git context lines', () => {
|
||||
const fold: FoldSegment = { kind: 'fold', count: 2, oldStart: 1, oldEnd: 2, newStart: 1, newEnd: 2 }
|
||||
const rows = foldRowsFromContents(fold, 'a\r\nb\r\n', 'a\r\nb\r\n')
|
||||
expect(rows.map(r => r.text)).toEqual(['a\r', 'b\r'])
|
||||
})
|
||||
|
||||
it('returns no rows for empty contents', () => {
|
||||
const fold: FoldSegment = { kind: 'fold', count: 5, oldStart: 1, oldEnd: 5, newStart: 1, newEnd: 5 }
|
||||
expect(foldRowsFromContents(fold, '', '')).toEqual([])
|
||||
})
|
||||
|
||||
it('clips line numbers to the actual content (no-newline overrun)', () => {
|
||||
// A file without a trailing newline: the fold's ranges can overrun the
|
||||
// actual line count by one; the slice stops at the real last line.
|
||||
const fold: FoldSegment = { kind: 'fold', count: 3, oldStart: 2, oldEnd: 4, newStart: 2, newEnd: 4 }
|
||||
const rows = foldRowsFromContents(fold, 'a\nb\nc', 'a\nb\nc')
|
||||
expect(rows).toEqual([
|
||||
{ kind: 'context', oldLine: 2, newLine: 2, text: 'b' },
|
||||
{ kind: 'context', oldLine: 3, newLine: 3, text: 'c' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user