From d990c10e59fd896da7940214724fa89543cde2f5 Mon Sep 17 00:00:00 2001 From: haruhi Date: Mon, 7 Sep 2026 23:51:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(diff):=20=E6=8A=98=E5=8F=A0=E8=A1=8C?= =?UTF-8?q?=E5=BC=82=E6=AD=A5=E5=B1=95=E5=BC=80=E7=8A=B6=E6=80=81=E6=9C=BA?= =?UTF-8?q?=E4=B8=8E=E5=86=85=E5=AE=B9=E5=88=87=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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。 --- src/client/api.ts | 5 +++ src/client/diff/DiffFiles.tsx | 33 ++++++++++++-- src/client/diff/DiffRows.tsx | 84 ++++++++++++++++++++++++++++------- src/client/diff/rows.ts | 35 +++++++++++++++ tests/diff-rows.spec.ts | 61 +++++++++++++++++++++++-- 5 files changed, 195 insertions(+), 23 deletions(-) diff --git a/src/client/api.ts b/src/client/api.ts index 3235a6f..cf0cacb 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -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 :`); 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 diff --git a/src/client/diff/DiffFiles.tsx b/src/client/diff/DiffFiles.tsx index ce03cbf..7f6e4ee 100644 --- a/src/client/diff/DiffFiles.tsx +++ b/src/client/diff/DiffFiles.tsx @@ -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 } -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>(() => 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>()) + useEffect(() => { foldCache.current = new Map() }, [parsed]) + const loadFold = (file: DiffFile, segment: FoldSegment): Promise => { + 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 )} {expandable && fileExpanded && ( - + loadFold(file, segment) : undefined} /> )} ) diff --git a/src/client/diff/DiffRows.tsx b/src/client/diff/DiffRows.tsx index e40b877..6127ac2 100644 --- a/src/client/diff/DiffRows.tsx +++ b/src/client/diff/DiffRows.tsx @@ -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 } /** 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>(new Set()) // Hunk-fold segments expanded by index; default collapsed. const [expandedFolds, setExpandedFolds] = useState>(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>(new Map()) + const [foldLoading, setFoldLoading] = useState>(new Set()) + const [foldFailed, setFoldFailed] = useState>(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) { ) } - 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 ( +
+ + {t('changesFoldUnavailable')} + +
+ ) + } + 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) { ) } + const loading = foldLoading.has(segIndex) const isExpanded = expandedFolds.has(segIndex) + const revealed = segment.rows ?? resolvedRows return (
{ - setExpandedFolds(prev => { - const next = new Set(prev) - if (next.has(segIndex)) next.delete(segIndex) - else next.add(segIndex) - return next - }) + 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)}`)) : ( - {t('changesFold', { count: segment.count })} + {loading ? t('changesFoldLoading') : t('changesFold', { count: segment.count })} )}
diff --git a/src/client/diff/rows.ts b/src/client/diff/rows.ts index bfd7e68..218f6e0 100644 --- a/src/client/diff/rows.ts +++ b/src/client/diff/rows.ts @@ -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 diff --git a/tests/diff-rows.spec.ts b/tests/diff-rows.spec.ts index 47d7620..857c200 100644 --- a/tests/diff-rows.spec.ts +++ b/tests/diff-rows.spec.ts @@ -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' }, + ]) + }) +})