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:
haruhi
2026-09-07 23:51:06 +08:00
parent 6b02285742
commit d990c10e59
5 changed files with 195 additions and 23 deletions
+29 -4
View File
@@ -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>
)