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:
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user