fix(diff): git lens 折叠行接入按需展开,修复点击无反应

- src/client/changes/DiffPane.tsx / src/client/DiffTab.tsx: 按 SidebarDiffRef 构造 fold loader——commit 侧 <hash>^ 与 <hash>;staged 侧 HEAD 与 :0;unstaged 侧 :0 与工作区 fsRead(失败/二进制降级空串);per 文件内容 Promise 缓存,目标或刷新 tick 变化时重置
- fallback 换侧时记录 effectiveStaged,loader 依实际渲染侧取数,修正切片行号错位
- src/client/locales.ts 及 19 个 locales-*.ts: 新增 changesFoldLoading / changesFoldUnavailable 全量补译(共 20 文件 21 处)
- tests/diff-fold-expand.spec.tsx: 展开/折叠/缓存去重、失败降级、无 loader 保持 inert、session 路径回归

背景:git 提交/工作区 diff 折叠行承诺「点击展开」却永远不可点,与 session lens 行为不一致;接入状态机后承诺为真,session 路径与现有文案零变化。
设计 docs/plans/2026-09-07-git-fold-expand-design.md §4.5-4.6。
This commit is contained in:
haruhi
2026-09-07 23:51:10 +08:00
parent d990c10e59
commit 900654b8fd
23 changed files with 331 additions and 5 deletions
+70 -3
View File
@@ -8,12 +8,13 @@
* very content it shows. Rendering goes through the shared {@link DiffFiles} * very content it shows. Rendering goes through the shared {@link DiffFiles}
* renderer — the same one the changes tab's inline preview uses. * renderer — the same one the changes tab's inline preview uses.
*/ */
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import { IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SessionScope } from './api.ts' import type { SessionScope } from './api.ts'
import { api } from './api.ts' import { api } from './api.ts'
import type { SidebarDiffRef } from './state.ts' import type { SidebarDiffRef } from './state.ts'
import { DiffFiles } from './diff/DiffFiles.tsx' import { DiffFiles } from './diff/DiffFiles.tsx'
import { displayPath, foldRowsFromContents, type DiffFile, type DiffRow, type FoldSegment } from './diff/rows.ts'
import { t } from './locales.ts' import { t } from './locales.ts'
import { resolveSidebarPath } from './produced-files.ts' import { resolveSidebarPath } from './produced-files.ts'
import css from './sidebar.module.css' import css from './sidebar.module.css'
@@ -30,6 +31,11 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [data, setData] = useState<DiffData | null>(null) const [data, setData] = useState<DiffData | null>(null)
const [tick, setTick] = useState(0) const [tick, setTick] = useState(0)
// The staged flag of the side ACTUALLY rendered: when the requested side's
// diff came back empty the load falls back to the other side, and the fold
// expansion must read that side's revisions (else the sliced line numbers
// land on the wrong contents).
const [effectiveStaged, setEffectiveStaged] = useState<boolean | null>(null)
const refresh = useCallback((): void => { setTick(value => value + 1) }, []) const refresh = useCallback((): void => { setTick(value => value + 1) }, [])
@@ -39,6 +45,7 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif
setLoading(true) setLoading(true)
setError(null) setError(null)
setData(null) setData(null)
setEffectiveStaged(null)
const load = async (): Promise<void> => { const load = async (): Promise<void> => {
try { try {
if (diff.kind === 'commit') { if (diff.kind === 'commit') {
@@ -53,7 +60,10 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif
// file staged after its tab opened). Both sides empty means the // file staged after its tab opened). Both sides empty means the
// file genuinely has no text changes. // file genuinely has no text changes.
const other = await api.gitDiff(scope, diff.path, !diff.staged, diff.worktree) const other = await api.gitDiff(scope, diff.path, !diff.staged, diff.worktree)
if (other.diff !== '') result = other if (other.diff !== '') {
result = other
if (!cancelled) setEffectiveStaged(!diff.staged)
}
} }
if (result.diff !== '') { if (result.diff !== '') {
if (!cancelled) setData({ diff: result.diff }) if (!cancelled) setData({ diff: result.diff })
@@ -82,6 +92,63 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif
return () => { cancelled = true } return () => { cancelled = true }
}, [sessionId, cwd, diff, tick]) }, [sessionId, cwd, diff, tick])
// ── On-demand git fold expansion: a fold's hidden rows come from both
// sides' full contents (git.show / fsRead), fetched ONCE per file so
// sibling folds share the request, then sliced by each fold's line
// ranges. The cache dies with the ref or a refresh tick. ──────────────
const foldContents = useRef(new Map<string, Promise<{ old: string; new: string }>>())
useEffect(() => { foldContents.current = new Map() }, [diff, tick])
const foldLoader = useMemo(() => {
const sidesOf = (file: DiffFile): Promise<{ old: string; new: string }> => {
const fetchSides = async (): Promise<{ old: string; new: string }> => {
const scope: SessionScope = { sessionId, cwd, ...(diff.repoRoot !== undefined ? { repoRoot: diff.repoRoot } : {}) }
if (diff.kind === 'commit') {
// The patch's -m --first-parent shape: old side from the parent,
// new side from the commit (a root commit's parent read fails → '').
const [oldSide, newSide] = await Promise.all([
file.oldPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(scope, `${diff.hashFull}^`, displayPath(file.oldPath), diff.worktree),
file.newPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(scope, diff.hashFull, displayPath(file.newPath), diff.worktree),
])
return { old: oldSide.content ?? '', new: newSide.content ?? '' }
}
// Worktree change: staged is HEAD vs index, unstaged is index vs
// worktree (the worktree side reads the live file).
const staged = effectiveStaged ?? diff.staged
if (staged) {
const [oldSide, newSide] = await Promise.all([
file.oldPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(scope, 'HEAD', displayPath(file.oldPath), diff.worktree),
file.newPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(scope, ':0', displayPath(file.newPath), diff.worktree),
])
return { old: oldSide.content ?? '', new: newSide.content ?? '' }
}
const [oldSide, worktree] = await Promise.all([
file.oldPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(scope, ':0', displayPath(file.oldPath), diff.worktree),
api.fsRead(scope, resolveSidebarPath(diff.repoRoot ?? diff.worktree ?? cwd, displayPath(file.newPath))).catch(() => null),
])
return { old: oldSide.content ?? '', new: worktree !== null && worktree.kind === 'text' ? worktree.content : '' }
}
const path = displayPath(file.newPath === '/dev/null' ? file.oldPath : file.newPath)
let promise = foldContents.current.get(path)
if (promise === undefined) {
promise = fetchSides()
foldContents.current.set(path, promise)
}
return promise
}
return (file: DiffFile, segment: FoldSegment): Promise<readonly DiffRow[]> =>
sidesOf(file).then(sides => foldRowsFromContents(segment, sides.old, sides.new))
}, [sessionId, cwd, diff, effectiveStaged])
return ( return (
<div className={css.gitDiffTab}> <div className={css.gitDiffTab}>
<div className={css.gitDiffTabHeader}> <div className={css.gitDiffTabHeader}>
@@ -104,7 +171,7 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif
<> <>
{data.untracked !== undefined {data.untracked !== undefined
? <DiffFiles diff="" untrackedPath={diff.kind === 'worktree' ? diff.path : ''} untrackedContent={data.untracked} /> ? <DiffFiles diff="" untrackedPath={diff.kind === 'worktree' ? diff.path : ''} untrackedContent={data.untracked} />
: <DiffFiles diff={data.diff} />} : <DiffFiles diff={data.diff} resolveFold={foldLoader} />}
{data.diff === '' && data.untracked === undefined && ( {data.diff === '' && data.untracked === undefined && (
<div className={css.gitEmpty}>{t('diffEmpty')}</div> <div className={css.gitEmpty}>{t('diffEmpty')}</div>
)} )}
+76 -2
View File
@@ -21,7 +21,7 @@ import { DiffRows, ReadRows } from '../diff/DiffRows.tsx'
import { PdfView } from '../PdfView.tsx' import { PdfView } from '../PdfView.tsx'
import { DiffFiles } from '../diff/DiffFiles.tsx' import { DiffFiles } from '../diff/DiffFiles.tsx'
import { langOfPath } from '../diff/highlight.ts' import { langOfPath } from '../diff/highlight.ts'
import { buildDiffSegments, diffLines, diffStats, parseUnifiedDiff, unifiedSegments, type DiffRow } from '../diff/rows.ts' import { buildDiffSegments, diffLines, diffStats, displayPath, foldRowsFromContents, parseUnifiedDiff, unifiedSegments, type DiffFile, type DiffRow, type FoldSegment } from '../diff/rows.ts'
import { parseReadContent, parseReadLines, type FileOp } from './ops.ts' import { parseReadContent, parseReadLines, type FileOp } from './ops.ts'
import { redactText } from '../redact.ts' import { redactText } from '../redact.ts'
import { rewriteLocalImageUrls } from '../markdown-images.ts' import { rewriteLocalImageUrls } from '../markdown-images.ts'
@@ -158,7 +158,19 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [diffText, setDiffText] = useState<string | null>(null) const [diffText, setDiffText] = useState<string | null>(null)
const [untracked, setUntracked] = useState<string | undefined>(undefined) const [untracked, setUntracked] = useState<string | undefined>(undefined)
// The staged flag of the side ACTUALLY rendered: when the requested side's
// diff came back empty the load falls back to the other side, and the fold
// expansion must read that side's revisions (else the sliced line numbers
// land on the wrong contents).
const [effectiveStaged, setEffectiveStaged] = useState<boolean | null>(null)
const gitRef = target.kind === 'git' ? target.ref : null const gitRef = target.kind === 'git' ? target.ref : null
// The scope every git call of this target shares (repoRoot folded in when
// the ref carries one, exactly like the load effect's paneScope).
const gitScope = useMemo<SessionScope>(() => ({
sessionId: scope.sessionId,
cwd: scope.cwd,
...(gitRef?.repoRoot !== undefined ? { repoRoot: gitRef.repoRoot } : {}),
}), [scope.sessionId, scope.cwd, gitRef?.repoRoot])
useEffect(() => { useEffect(() => {
if (gitRef === null) return if (gitRef === null) return
@@ -172,6 +184,7 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
setError(null) setError(null)
setDiffText(null) setDiffText(null)
setUntracked(undefined) setUntracked(undefined)
setEffectiveStaged(null)
const load = async (): Promise<void> => { const load = async (): Promise<void> => {
try { try {
if (gitRef.kind === 'commit') { if (gitRef.kind === 'commit') {
@@ -184,7 +197,10 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
// The requested side is empty — try the OTHER side once (the change // The requested side is empty — try the OTHER side once (the change
// may have moved sides after the preview target was minted). // may have moved sides after the preview target was minted).
const other = await api.gitDiff(paneScope, gitRef.path, !gitRef.staged, gitRef.worktree) const other = await api.gitDiff(paneScope, gitRef.path, !gitRef.staged, gitRef.worktree)
if (other.diff !== '') result = other if (other.diff !== '') {
result = other
if (!cancelled) setEffectiveStaged(!gitRef.staged)
}
} }
if (result.diff !== '') { if (result.diff !== '') {
if (!cancelled) setDiffText(result.diff) if (!cancelled) setDiffText(result.diff)
@@ -211,6 +227,63 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
return () => { cancelled = true } return () => { cancelled = true }
}, [gitRef, scope.sessionId, scope.cwd, tick]) }, [gitRef, scope.sessionId, scope.cwd, tick])
// ── On-demand git fold expansion: a fold's hidden rows come from both
// sides' full contents (git.show / fsRead), fetched ONCE per file so
// sibling folds share the request, then sliced by each fold's line
// ranges. The cache dies with the target or a refresh tick. ───────────
const foldContents = useRef(new Map<string, Promise<{ old: string; new: string }>>())
useEffect(() => { foldContents.current = new Map() }, [gitRef, tick])
const foldLoader = useMemo(() => {
if (gitRef === null) return undefined
const sidesOf = (file: DiffFile): Promise<{ old: string; new: string }> => {
const fetchSides = async (): Promise<{ old: string; new: string }> => {
if (gitRef.kind === 'commit') {
// The patch's -m --first-parent shape: old side from the parent,
// new side from the commit (a root commit's parent read fails → '').
const [oldSide, newSide] = await Promise.all([
file.oldPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(gitScope, `${gitRef.hashFull}^`, displayPath(file.oldPath), gitRef.worktree),
file.newPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(gitScope, gitRef.hashFull, displayPath(file.newPath), gitRef.worktree),
])
return { old: oldSide.content ?? '', new: newSide.content ?? '' }
}
// Worktree change: staged is HEAD vs index, unstaged is index vs
// worktree (the worktree side reads the live file).
const staged = effectiveStaged ?? gitRef.staged
if (staged) {
const [oldSide, newSide] = await Promise.all([
file.oldPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(gitScope, 'HEAD', displayPath(file.oldPath), gitRef.worktree),
file.newPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(gitScope, ':0', displayPath(file.newPath), gitRef.worktree),
])
return { old: oldSide.content ?? '', new: newSide.content ?? '' }
}
const [oldSide, worktree] = await Promise.all([
file.oldPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(gitScope, ':0', displayPath(file.oldPath), gitRef.worktree),
api.fsRead(gitScope, resolveSidebarPath(gitRef.repoRoot ?? gitRef.worktree ?? scope.cwd, displayPath(file.newPath))).catch(() => null),
])
return { old: oldSide.content ?? '', new: worktree !== null && worktree.kind === 'text' ? worktree.content : '' }
}
const path = displayPath(file.newPath === '/dev/null' ? file.oldPath : file.newPath)
let promise = foldContents.current.get(path)
if (promise === undefined) {
promise = fetchSides()
foldContents.current.set(path, promise)
}
return promise
}
return (file: DiffFile, segment: FoldSegment): Promise<readonly DiffRow[]> =>
sidesOf(file).then(sides => foldRowsFromContents(segment, sides.old, sides.new))
}, [gitRef, gitScope, effectiveStaged, scope])
// ── Op target material (pure snapshots; the prior content came with the // ── Op target material (pure snapshots; the prior content came with the
// target so a running op shows what is already known). ──────────────── // target so a running op shows what is already known). ────────────────
const opRaw = target.kind === 'op' ? target.op : null const opRaw = target.kind === 'op' ? target.op : null
@@ -508,6 +581,7 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
{diffText !== null && diffText !== '' && ( {diffText !== null && diffText !== '' && (
<DiffFiles <DiffFiles
diff={diffText} diff={diffText}
resolveFold={foldLoader}
untrackedPath={untracked !== undefined && target.ref.kind === 'worktree' ? target.ref.path : undefined} untrackedPath={untracked !== undefined && target.ref.kind === 'worktree' ? target.ref.path : undefined}
untrackedContent={untracked} untrackedContent={untracked}
/> />
+2
View File
@@ -31,6 +31,8 @@ export const ar: Record<string, string> = {
changesRunning: 'قيد التنفيذ', changesRunning: 'قيد التنفيذ',
changesError: 'خطأ', changesError: 'خطأ',
changesFold: '{count} أسطر… انقر للتوسيع', changesFold: '{count} أسطر… انقر للتوسيع',
changesFoldLoading: 'جارٍ التوسيع…',
changesFoldUnavailable: 'السياق غير متوفر',
changesContext: 'سياق', changesContext: 'سياق',
changesPriorUnknown: 'المحتوى السابق خارج النافذة؛ يُعرض كما لو أُضيف بالكامل', changesPriorUnknown: 'المحتوى السابق خارج النافذة؛ يُعرض كما لو أُضيف بالكامل',
explorer: 'المستكشف', explorer: 'المستكشف',
+2
View File
@@ -16,6 +16,8 @@ export const de: Record<string, string> = {
changesRunning: 'läuft', changesRunning: 'läuft',
changesError: 'Fehler', changesError: 'Fehler',
changesFold: '{count} Zeilen… zum Aufklappen klicken', changesFold: '{count} Zeilen… zum Aufklappen klicken',
changesFoldLoading: 'Wird aufgeklappt…',
changesFoldUnavailable: 'Kontext nicht verfügbar',
changesContext: 'Kontext', changesContext: 'Kontext',
changesPriorUnknown: 'Inhalt vor der Änderung liegt außerhalb des Fensters; als vollständig neu angezeigt', changesPriorUnknown: 'Inhalt vor der Änderung liegt außerhalb des Fensters; als vollständig neu angezeigt',
explorer: 'Explorer', explorer: 'Explorer',
+2
View File
@@ -23,6 +23,8 @@ export const fr: Record<string, string> = {
changesRunning: 'en cours', changesRunning: 'en cours',
changesError: 'erreur', changesError: 'erreur',
changesFold: '{count} lignes… cliquer pour déplier', changesFold: '{count} lignes… cliquer pour déplier',
changesFoldLoading: 'Dépliage…',
changesFoldUnavailable: 'Contexte indisponible',
changesContext: 'contexte', changesContext: 'contexte',
changesPriorUnknown: 'Le contenu antérieur est hors de la fenêtre ; affiché comme entièrement ajouté', changesPriorUnknown: 'Le contenu antérieur est hors de la fenêtre ; affiché comme entièrement ajouté',
explorer: 'Explorateur', explorer: 'Explorateur',
+2
View File
@@ -30,6 +30,8 @@ export const hi: Record<string, string> = {
changesRunning: 'चल रहा है', changesRunning: 'चल रहा है',
changesError: 'त्रुटि', changesError: 'त्रुटि',
changesFold: '{count} पंक्तियाँ… विस्तार के लिए क्लिक करें', changesFold: '{count} पंक्तियाँ… विस्तार के लिए क्लिक करें',
changesFoldLoading: 'विस्तार हो रहा है…',
changesFoldUnavailable: 'संदर्भ अनुपलब्ध',
changesContext: 'संदर्भ', changesContext: 'संदर्भ',
changesPriorUnknown: 'बदलाव से पहले की सामग्री विंडो में नहीं है; पूरी नई जोड़ी गई दिखाई जाती है', changesPriorUnknown: 'बदलाव से पहले की सामग्री विंडो में नहीं है; पूरी नई जोड़ी गई दिखाई जाती है',
explorer: 'एक्सप्लोरर', explorer: 'एक्सप्लोरर',
+2
View File
@@ -28,6 +28,8 @@ export const id: Record<string, string> = {
changesRunning: 'berjalan', changesRunning: 'berjalan',
changesError: 'error', changesError: 'error',
changesFold: '{count} baris… klik untuk meluaskan', changesFold: '{count} baris… klik untuk meluaskan',
changesFoldLoading: 'Meluaskan…',
changesFoldUnavailable: 'Konteks tidak tersedia',
changesContext: 'konteks', changesContext: 'konteks',
changesPriorUnknown: 'Konten sebelum perubahan berada di luar jendela; ditampilkan sebagai sepenuhnya baru', changesPriorUnknown: 'Konten sebelum perubahan berada di luar jendela; ditampilkan sebagai sepenuhnya baru',
explorer: 'Explorer', explorer: 'Explorer',
+2
View File
@@ -21,6 +21,8 @@ export const it: Record<string, string> = {
changesRunning: 'in esecuzione', changesRunning: 'in esecuzione',
changesError: 'errore', changesError: 'errore',
changesFold: '{count} righe… clicca per espandere', changesFold: '{count} righe… clicca per espandere',
changesFoldLoading: 'Espansione…',
changesFoldUnavailable: 'Contesto non disponibile',
changesContext: 'contesto', changesContext: 'contesto',
changesPriorUnknown: 'Il contenuto precedente è fuori dalla finestra; mostrato come interamente aggiunto', changesPriorUnknown: 'Il contenuto precedente è fuori dalla finestra; mostrato come interamente aggiunto',
explorer: 'Esplora risorse', explorer: 'Esplora risorse',
+2
View File
@@ -30,6 +30,8 @@ export const ja: Record<string, string> = {
changesRunning: '実行中', changesRunning: '実行中',
changesError: 'エラー', changesError: 'エラー',
changesFold: '{count} 行…クリックで展開', changesFold: '{count} 行…クリックで展開',
changesFoldLoading: '展開中…',
changesFoldUnavailable: 'コンテキスト未読み込み',
changesContext: 'コンテキスト', changesContext: 'コンテキスト',
changesPriorUnknown: '変更前の内容がウィンドウ内になく、すべて新規追加として表示されます', changesPriorUnknown: '変更前の内容がウィンドウ内になく、すべて新規追加として表示されます',
explorer: 'エクスプローラー', explorer: 'エクスプローラー',
+2
View File
@@ -22,6 +22,8 @@ export const ko: Record<string, string> = {
changesRunning: '실행 중', changesRunning: '실행 중',
changesError: '오류', changesError: '오류',
changesFold: '{count}줄… 클릭하여 펼치기', changesFold: '{count}줄… 클릭하여 펼치기',
changesFoldLoading: '펼치는 중…',
changesFoldUnavailable: '컨텍스트를 불러올 수 없음',
changesContext: '컨텍스트', changesContext: '컨텍스트',
changesPriorUnknown: '변경 전 내용이 창에 없어 모두 새로 추가된 것으로 표시됩니다', changesPriorUnknown: '변경 전 내용이 창에 없어 모두 새로 추가된 것으로 표시됩니다',
explorer: '탐색기', explorer: '탐색기',
+2
View File
@@ -28,6 +28,8 @@ export const nl: Record<string, string> = {
changesRunning: 'actief', changesRunning: 'actief',
changesError: 'fout', changesError: 'fout',
changesFold: '{count} regels… klik om uit te klappen', changesFold: '{count} regels… klik om uit te klappen',
changesFoldLoading: 'Uitklappen…',
changesFoldUnavailable: 'Context niet beschikbaar',
changesContext: 'context', changesContext: 'context',
changesPriorUnknown: 'Eerdere inhoud valt buiten het venster; getoond als volledig nieuw', changesPriorUnknown: 'Eerdere inhoud valt buiten het venster; getoond als volledig nieuw',
explorer: 'Verkenner', explorer: 'Verkenner',
+2
View File
@@ -32,6 +32,8 @@ export const pl: Record<string, string> = {
changesRunning: 'w toku', changesRunning: 'w toku',
changesError: 'błąd', changesError: 'błąd',
changesFold: '{count} wierszy… kliknij, aby rozwinąć', changesFold: '{count} wierszy… kliknij, aby rozwinąć',
changesFoldLoading: 'Rozwijanie…',
changesFoldUnavailable: 'Kontekst niedostępny',
changesContext: 'kontekst', changesContext: 'kontekst',
changesPriorUnknown: 'Zawartość sprzed zmiany jest poza oknem; pokazana jako w całości nowa', changesPriorUnknown: 'Zawartość sprzed zmiany jest poza oknem; pokazana jako w całości nowa',
explorer: 'Eksplorator', explorer: 'Eksplorator',
+2
View File
@@ -13,6 +13,8 @@ export const pt: Record<string, string> = {
changesRunning: 'em execução', changesRunning: 'em execução',
changesError: 'erro', changesError: 'erro',
changesFold: '{count} linhas… clique para expandir', changesFold: '{count} linhas… clique para expandir',
changesFoldLoading: 'Expandindo…',
changesFoldUnavailable: 'Contexto indisponível',
changesContext: 'contexto', changesContext: 'contexto',
changesPriorUnknown: 'O conteúdo anterior está fora da janela; mostrado como totalmente adicionado', changesPriorUnknown: 'O conteúdo anterior está fora da janela; mostrado como totalmente adicionado',
explorer: 'Explorador', explorer: 'Explorador',
+2
View File
@@ -26,6 +26,8 @@ export const ru: Record<string, string> = {
changesRunning: 'выполняется', changesRunning: 'выполняется',
changesError: 'ошибка', changesError: 'ошибка',
changesFold: '{count} строк… нажмите, чтобы развернуть', changesFold: '{count} строк… нажмите, чтобы развернуть',
changesFoldLoading: 'Разворачивание…',
changesFoldUnavailable: 'Контекст недоступен',
changesContext: 'контекст', changesContext: 'контекст',
changesPriorUnknown: 'Содержимое до изменения вне окна; показано как полностью добавленное', changesPriorUnknown: 'Содержимое до изменения вне окна; показано как полностью добавленное',
explorer: 'Проводник', explorer: 'Проводник',
+2
View File
@@ -13,6 +13,8 @@ export const sv: Record<string, string> = {
changesRunning: 'körs', changesRunning: 'körs',
changesError: 'fel', changesError: 'fel',
changesFold: '{count} rader… klicka för att expandera', changesFold: '{count} rader… klicka för att expandera',
changesFoldLoading: 'Expanderar…',
changesFoldUnavailable: 'Kontext ej tillgänglig',
changesContext: 'kontext', changesContext: 'kontext',
changesPriorUnknown: 'Innehållet före ändringen ligger utanför fönstret; visas som helt nytt', changesPriorUnknown: 'Innehållet före ändringen ligger utanför fönstret; visas som helt nytt',
explorer: 'Utforskare', explorer: 'Utforskare',
+2
View File
@@ -30,6 +30,8 @@ export const th: Record<string, string> = {
changesRunning: 'กำลังทำงาน', changesRunning: 'กำลังทำงาน',
changesError: 'ข้อผิดพลาด', changesError: 'ข้อผิดพลาด',
changesFold: '{count} บรรทัด… คลิกเพื่อขยาย', changesFold: '{count} บรรทัด… คลิกเพื่อขยาย',
changesFoldLoading: 'กำลังขยาย…',
changesFoldUnavailable: 'ไม่สามารถโหลดบริบทได้',
changesContext: 'บริบท', changesContext: 'บริบท',
changesPriorUnknown: 'เนื้อหาก่อนแก้ไขอยู่นอกหน้าต่าง แสดงเป็นเพิ่มใหม่ทั้งหมด', changesPriorUnknown: 'เนื้อหาก่อนแก้ไขอยู่นอกหน้าต่าง แสดงเป็นเพิ่มใหม่ทั้งหมด',
explorer: 'ตัวสำรวจ', explorer: 'ตัวสำรวจ',
+2
View File
@@ -30,6 +30,8 @@ export const tr: Record<string, string> = {
changesRunning: 'çalışıyor', changesRunning: 'çalışıyor',
changesError: 'hata', changesError: 'hata',
changesFold: '{count} satır… genişletmek için tıklayın', changesFold: '{count} satır… genişletmek için tıklayın',
changesFoldLoading: 'Genişletiliyor…',
changesFoldUnavailable: 'Bağlam yüklenemedi',
changesContext: 'bağlam', changesContext: 'bağlam',
changesPriorUnknown: 'Değişiklik öncesi içerik pencere dışında; tamamen yeni eklenmiş olarak gösteriliyor', changesPriorUnknown: 'Değişiklik öncesi içerik pencere dışında; tamamen yeni eklenmiş olarak gösteriliyor',
explorer: 'Gezgin', explorer: 'Gezgin',
+2
View File
@@ -30,6 +30,8 @@ export const vi: Record<string, string> = {
changesRunning: 'đang chạy', changesRunning: 'đang chạy',
changesError: 'lỗi', changesError: 'lỗi',
changesFold: '{count} dòng… bấm để mở rộng', changesFold: '{count} dòng… bấm để mở rộng',
changesFoldLoading: 'Đang mở rộng…',
changesFoldUnavailable: 'Không tải được ngữ cảnh',
changesContext: 'ngữ cảnh', changesContext: 'ngữ cảnh',
changesPriorUnknown: 'Nội dung trước thay đổi nằm ngoài cửa sổ; hiển thị như toàn bộ mới thêm', changesPriorUnknown: 'Nội dung trước thay đổi nằm ngoài cửa sổ; hiển thị như toàn bộ mới thêm',
explorer: 'Trình khám phá', explorer: 'Trình khám phá',
+2
View File
@@ -45,6 +45,8 @@ export const zhHK: Record<string, string> = {
changesRunning: '執行中', changesRunning: '執行中',
changesError: '出錯', changesError: '出錯',
changesFold: '{count} 行…點擊展開', changesFold: '{count} 行…點擊展開',
changesFoldLoading: '展開中…',
changesFoldUnavailable: '上下文未載入',
changesContext: '上下文', changesContext: '上下文',
changesPriorUnknown: '變更前的內容不在視窗內,顯示為全部新增', changesPriorUnknown: '變更前的內容不在視窗內,顯示為全部新增',
explorer: '檔案總管', explorer: '檔案總管',
+2
View File
@@ -45,6 +45,8 @@ export const zhMO: Record<string, string> = {
changesRunning: '執行中', changesRunning: '執行中',
changesError: '出錯', changesError: '出錯',
changesFold: '{count} 行…點擊展開', changesFold: '{count} 行…點擊展開',
changesFoldLoading: '展開中…',
changesFoldUnavailable: '上下文未載入',
changesContext: '上下文', changesContext: '上下文',
changesPriorUnknown: '變更前的內容不在視窗內,顯示為全部新增', changesPriorUnknown: '變更前的內容不在視窗內,顯示為全部新增',
explorer: '檔案總管', explorer: '檔案總管',
+2
View File
@@ -45,6 +45,8 @@ export const zhTW: Record<string, string> = {
changesRunning: '執行中', changesRunning: '執行中',
changesError: '出錯', changesError: '出錯',
changesFold: '{count} 行…點擊展開', changesFold: '{count} 行…點擊展開',
changesFoldLoading: '展開中…',
changesFoldUnavailable: '上下文未載入',
changesContext: '上下文', changesContext: '上下文',
changesPriorUnknown: '變更前的內容不在視窗內,顯示為全部新增', changesPriorUnknown: '變更前的內容不在視窗內,顯示為全部新增',
explorer: '檔案總管', explorer: '檔案總管',
+4
View File
@@ -42,6 +42,8 @@ export const zh = {
changesRedactOffLabel: '脱敏关', changesRedactOffLabel: '脱敏关',
changesRedactBanner: '已脱敏', changesRedactBanner: '已脱敏',
changesFold: '{count} 行…点击展开', changesFold: '{count} 行…点击展开',
changesFoldLoading: '展开中…',
changesFoldUnavailable: '上下文未加载',
changesContext: '上下文', changesContext: '上下文',
changesPriorUnknown: '变更前的内容不在窗口内,显示为全新增', changesPriorUnknown: '变更前的内容不在窗口内,显示为全新增',
explorer: '资源管理器', explorer: '资源管理器',
@@ -489,6 +491,8 @@ export const en: Record<keyof typeof zh, string> = {
changesRedactOffLabel: 'Redaction off', changesRedactOffLabel: 'Redaction off',
changesRedactBanner: 'Redacted', changesRedactBanner: 'Redacted',
changesFold: '{count} lines…click to expand', changesFold: '{count} lines…click to expand',
changesFoldLoading: 'Expanding…',
changesFoldUnavailable: 'Context unavailable',
changesContext: 'context', changesContext: 'context',
changesPriorUnknown: 'Prior content is outside the loaded window; shown as all-added', changesPriorUnknown: 'Prior content is outside the loaded window; shown as all-added',
explorer: 'Explorer', explorer: 'Explorer',
+143
View File
@@ -0,0 +1,143 @@
// @vitest-environment jsdom
/**
* The git fold expansion path: DiffFiles forwards a resolveFold loader to
* DiffRows, whose first click on a rows-less git gap fold fetches the hidden
* rows (loading/failed markers in between) and later clicks toggle; folds
* that carry rows (the session-op path) expand directly, and gaps without a
* loader stay inert markers — the pre-fix behavior for op diffs.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { DiffFiles } from '../src/client/diff/DiffFiles.tsx'
import { DiffRows } from '../src/client/diff/DiffRows.tsx'
import { buildDiffSegments, diffLines, type DiffFile, type FoldSegment } from '../src/client/diff/rows.ts'
import { setupReactAct } from './test-utils.ts'
setupReactAct()
// A git-shaped two-hunk patch: the gap between the hunks (old/new lines
// 2..11) is a fold WITHOUT rows — expandable only through a resolveFold.
const gapDiff = [
'diff --git a/src/a.ts b/src/a.ts',
'--- a/src/a.ts',
'+++ b/src/a.ts',
'@@ -1 +1 @@',
'-old',
'+new',
'@@ -12 +12 @@',
'-tail',
'+tail2',
].join('\n')
// The hidden context rows a loader slices out of both sides' contents.
const hiddenRows = Array.from({ length: 10 }, (_, i) => ({
kind: 'context' as const,
oldLine: i + 2,
newLine: i + 2,
text: `ctx-${String(i + 2)}`,
}))
afterEach(() => { document.body.innerHTML = '' })
describe('DiffFiles on-demand fold expansion', () => {
it('expands a git gap fold through resolveFold, then collapses back', async () => {
const resolveFold = vi.fn((_file: DiffFile, _segment: FoldSegment) => Promise.resolve(hiddenRows))
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
act(() => { root.render(createElement(DiffFiles, { diff: gapDiff, resolveFold })) })
const fold = container.querySelector<HTMLElement>('[data-expandable="true"]')
expect(fold).not.toBeNull()
expect(container.textContent).not.toContain('ctx-5')
await act(async () => { fold!.click() })
expect(resolveFold).toHaveBeenCalledOnce()
expect(container.textContent).toContain('ctx-5')
// The loader received the gap fold itself (line ranges on board).
const segment = resolveFold.mock.calls[0]![1]
expect(segment.kind).toBe('fold')
expect(segment.oldStart).toBe(2)
// Collapse: the marker is back; the fetch never repeats.
const expanded = container.querySelector<HTMLElement>('[data-expandable="true"]')
await act(async () => { expanded!.click() })
expect(container.textContent).not.toContain('ctx-5')
expect(resolveFold).toHaveBeenCalledOnce()
// Re-expanding resolves from the per-fold cache (no second fetch).
const collapsed = container.querySelector<HTMLElement>('[data-expandable="true"]')
await act(async () => { collapsed!.click() })
expect(container.textContent).toContain('ctx-5')
expect(resolveFold).toHaveBeenCalledOnce()
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('degrades to the unavailable marker when the loader rejects', async () => {
const resolveFold = vi.fn(() => Promise.reject(new Error('boom')))
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
act(() => { root.render(createElement(DiffFiles, { diff: gapDiff, resolveFold })) })
const fold = container.querySelector<HTMLElement>('[data-expandable="true"]')
await act(async () => { fold!.click() })
// The degraded fold promises no interaction.
expect(container.querySelector('[data-expandable]')).toBeNull()
expect(container.textContent).not.toContain('ctx-5')
// jsdom's navigator.language is en-US → the en copy.
expect(container.textContent).toContain('Context unavailable')
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('keeps git gap folds inert without a resolver (session surfaces unchanged)', () => {
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
act(() => { root.render(createElement(DiffFiles, { diff: gapDiff })) })
expect(container.querySelector('[data-expandable]')).toBeNull()
// The quiet marker still shows the hidden line count.
expect(container.textContent).toContain('10 lines')
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('expands session folds (rows carried) directly, without any loader', async () => {
// buildDiffSegments output — the session-op path: folds carry their rows,
// so they toggle on click exactly as before the git expansion existed.
const rows = diffLines(
'a\nb\nc\nd\ne\nX\nf\ng\nh\ni\nj\nk\nl\n',
'a\nb\nc\nd\ne\nY\nf\ng\nh\ni\nj\nk\nl\n',
)
const segments = buildDiffSegments(rows)
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
act(() => { root.render(createElement(DiffRows, { segments })) })
const fold = container.querySelector<HTMLElement>('[data-expandable="true"]')
expect(fold).not.toBeNull()
// 'j' is hidden in the tail fold (the marker copy carries no 'j').
expect(container.textContent).not.toContain('j')
await act(async () => { fold!.click() })
expect(container.textContent).toContain('j')
const expanded = container.querySelector<HTMLElement>('[data-expandable="true"]')
await act(async () => { expanded!.click() })
expect(container.textContent).not.toContain('j')
} finally {
act(() => { root.unmount() })
container.remove()
}
})
})