From 900654b8fd3995980188a9d9f6eae4cdb8a92831 Mon Sep 17 00:00:00 2001 From: haruhi Date: Mon, 7 Sep 2026 23:51:10 +0800 Subject: [PATCH] =?UTF-8?q?fix(diff):=20git=20lens=20=E6=8A=98=E5=8F=A0?= =?UTF-8?q?=E8=A1=8C=E6=8E=A5=E5=85=A5=E6=8C=89=E9=9C=80=E5=B1=95=E5=BC=80?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8D=E7=82=B9=E5=87=BB=E6=97=A0=E5=8F=8D?= =?UTF-8?q?=E5=BA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/client/changes/DiffPane.tsx / src/client/DiffTab.tsx: 按 SidebarDiffRef 构造 fold loader——commit 侧 ^ 与 ;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。 --- src/client/DiffTab.tsx | 73 +++++++++++++++- src/client/changes/DiffPane.tsx | 78 ++++++++++++++++- src/client/locales-ar.ts | 2 + src/client/locales-de.ts | 2 + src/client/locales-fr.ts | 2 + src/client/locales-hi.ts | 2 + src/client/locales-id.ts | 2 + src/client/locales-it.ts | 2 + src/client/locales-ja.ts | 2 + src/client/locales-ko.ts | 2 + src/client/locales-nl.ts | 2 + src/client/locales-pl.ts | 2 + src/client/locales-pt.ts | 2 + src/client/locales-ru.ts | 2 + src/client/locales-sv.ts | 2 + src/client/locales-th.ts | 2 + src/client/locales-tr.ts | 2 + src/client/locales-vi.ts | 2 + src/client/locales-zh-HK.ts | 2 + src/client/locales-zh-MO.ts | 2 + src/client/locales-zh-TW.ts | 2 + src/client/locales.ts | 4 + tests/diff-fold-expand.spec.tsx | 143 ++++++++++++++++++++++++++++++++ 23 files changed, 331 insertions(+), 5 deletions(-) create mode 100644 tests/diff-fold-expand.spec.tsx diff --git a/src/client/DiffTab.tsx b/src/client/DiffTab.tsx index 2720abc..fc3a500 100644 --- a/src/client/DiffTab.tsx +++ b/src/client/DiffTab.tsx @@ -8,12 +8,13 @@ * very content it shows. Rendering goes through the shared {@link DiffFiles} * 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 type { SessionScope } from './api.ts' import { api } from './api.ts' import type { SidebarDiffRef } from './state.ts' 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 { resolveSidebarPath } from './produced-files.ts' import css from './sidebar.module.css' @@ -30,6 +31,11 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif const [error, setError] = useState(null) const [data, setData] = useState(null) 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(null) const refresh = useCallback((): void => { setTick(value => value + 1) }, []) @@ -39,6 +45,7 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif setLoading(true) setError(null) setData(null) + setEffectiveStaged(null) const load = async (): Promise => { try { 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 genuinely has no text changes. 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 (!cancelled) setData({ diff: result.diff }) @@ -82,6 +92,63 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif return () => { cancelled = true } }, [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>()) + 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 => + sidesOf(file).then(sides => foldRowsFromContents(segment, sides.old, sides.new)) + }, [sessionId, cwd, diff, effectiveStaged]) + return (
@@ -104,7 +171,7 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif <> {data.untracked !== undefined ? - : } + : } {data.diff === '' && data.untracked === undefined && (
{t('diffEmpty')}
)} diff --git a/src/client/changes/DiffPane.tsx b/src/client/changes/DiffPane.tsx index c609433..be2b84a 100644 --- a/src/client/changes/DiffPane.tsx +++ b/src/client/changes/DiffPane.tsx @@ -21,7 +21,7 @@ import { DiffRows, ReadRows } from '../diff/DiffRows.tsx' import { PdfView } from '../PdfView.tsx' import { DiffFiles } from '../diff/DiffFiles.tsx' 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 { redactText } from '../redact.ts' import { rewriteLocalImageUrls } from '../markdown-images.ts' @@ -158,7 +158,19 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp const [error, setError] = useState(null) const [diffText, setDiffText] = useState(null) const [untracked, setUntracked] = useState(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(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(() => ({ + sessionId: scope.sessionId, + cwd: scope.cwd, + ...(gitRef?.repoRoot !== undefined ? { repoRoot: gitRef.repoRoot } : {}), + }), [scope.sessionId, scope.cwd, gitRef?.repoRoot]) useEffect(() => { if (gitRef === null) return @@ -172,6 +184,7 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp setError(null) setDiffText(null) setUntracked(undefined) + setEffectiveStaged(null) const load = async (): Promise => { try { 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 // may have moved sides after the preview target was minted). 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 (!cancelled) setDiffText(result.diff) @@ -211,6 +227,63 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp return () => { cancelled = true } }, [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>()) + 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 => + 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 // target so a running op shows what is already known). ──────────────── const opRaw = target.kind === 'op' ? target.op : null @@ -508,6 +581,7 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp {diffText !== null && diffText !== '' && ( diff --git a/src/client/locales-ar.ts b/src/client/locales-ar.ts index 90c20b5..605f467 100644 --- a/src/client/locales-ar.ts +++ b/src/client/locales-ar.ts @@ -31,6 +31,8 @@ export const ar: Record = { changesRunning: 'قيد التنفيذ', changesError: 'خطأ', changesFold: '{count} أسطر… انقر للتوسيع', + changesFoldLoading: 'جارٍ التوسيع…', + changesFoldUnavailable: 'السياق غير متوفر', changesContext: 'سياق', changesPriorUnknown: 'المحتوى السابق خارج النافذة؛ يُعرض كما لو أُضيف بالكامل', explorer: 'المستكشف', diff --git a/src/client/locales-de.ts b/src/client/locales-de.ts index 799d1b7..0a3495c 100644 --- a/src/client/locales-de.ts +++ b/src/client/locales-de.ts @@ -16,6 +16,8 @@ export const de: Record = { changesRunning: 'läuft', changesError: 'Fehler', changesFold: '{count} Zeilen… zum Aufklappen klicken', + changesFoldLoading: 'Wird aufgeklappt…', + changesFoldUnavailable: 'Kontext nicht verfügbar', changesContext: 'Kontext', changesPriorUnknown: 'Inhalt vor der Änderung liegt außerhalb des Fensters; als vollständig neu angezeigt', explorer: 'Explorer', diff --git a/src/client/locales-fr.ts b/src/client/locales-fr.ts index 0f9b757..9e4c23c 100644 --- a/src/client/locales-fr.ts +++ b/src/client/locales-fr.ts @@ -23,6 +23,8 @@ export const fr: Record = { changesRunning: 'en cours', changesError: 'erreur', changesFold: '{count} lignes… cliquer pour déplier', + changesFoldLoading: 'Dépliage…', + changesFoldUnavailable: 'Contexte indisponible', changesContext: 'contexte', changesPriorUnknown: 'Le contenu antérieur est hors de la fenêtre ; affiché comme entièrement ajouté', explorer: 'Explorateur', diff --git a/src/client/locales-hi.ts b/src/client/locales-hi.ts index 02f932b..f78e1c7 100644 --- a/src/client/locales-hi.ts +++ b/src/client/locales-hi.ts @@ -30,6 +30,8 @@ export const hi: Record = { changesRunning: 'चल रहा है', changesError: 'त्रुटि', changesFold: '{count} पंक्तियाँ… विस्तार के लिए क्लिक करें', + changesFoldLoading: 'विस्तार हो रहा है…', + changesFoldUnavailable: 'संदर्भ अनुपलब्ध', changesContext: 'संदर्भ', changesPriorUnknown: 'बदलाव से पहले की सामग्री विंडो में नहीं है; पूरी नई जोड़ी गई दिखाई जाती है', explorer: 'एक्सप्लोरर', diff --git a/src/client/locales-id.ts b/src/client/locales-id.ts index 796bf0c..33fc653 100644 --- a/src/client/locales-id.ts +++ b/src/client/locales-id.ts @@ -28,6 +28,8 @@ export const id: Record = { changesRunning: 'berjalan', changesError: 'error', changesFold: '{count} baris… klik untuk meluaskan', + changesFoldLoading: 'Meluaskan…', + changesFoldUnavailable: 'Konteks tidak tersedia', changesContext: 'konteks', changesPriorUnknown: 'Konten sebelum perubahan berada di luar jendela; ditampilkan sebagai sepenuhnya baru', explorer: 'Explorer', diff --git a/src/client/locales-it.ts b/src/client/locales-it.ts index 59385cb..f2629ce 100644 --- a/src/client/locales-it.ts +++ b/src/client/locales-it.ts @@ -21,6 +21,8 @@ export const it: Record = { changesRunning: 'in esecuzione', changesError: 'errore', changesFold: '{count} righe… clicca per espandere', + changesFoldLoading: 'Espansione…', + changesFoldUnavailable: 'Contesto non disponibile', changesContext: 'contesto', changesPriorUnknown: 'Il contenuto precedente è fuori dalla finestra; mostrato come interamente aggiunto', explorer: 'Esplora risorse', diff --git a/src/client/locales-ja.ts b/src/client/locales-ja.ts index aca6e71..7f91f50 100644 --- a/src/client/locales-ja.ts +++ b/src/client/locales-ja.ts @@ -30,6 +30,8 @@ export const ja: Record = { changesRunning: '実行中', changesError: 'エラー', changesFold: '{count} 行…クリックで展開', + changesFoldLoading: '展開中…', + changesFoldUnavailable: 'コンテキスト未読み込み', changesContext: 'コンテキスト', changesPriorUnknown: '変更前の内容がウィンドウ内になく、すべて新規追加として表示されます', explorer: 'エクスプローラー', diff --git a/src/client/locales-ko.ts b/src/client/locales-ko.ts index 42b5733..f27bf13 100644 --- a/src/client/locales-ko.ts +++ b/src/client/locales-ko.ts @@ -22,6 +22,8 @@ export const ko: Record = { changesRunning: '실행 중', changesError: '오류', changesFold: '{count}줄… 클릭하여 펼치기', + changesFoldLoading: '펼치는 중…', + changesFoldUnavailable: '컨텍스트를 불러올 수 없음', changesContext: '컨텍스트', changesPriorUnknown: '변경 전 내용이 창에 없어 모두 새로 추가된 것으로 표시됩니다', explorer: '탐색기', diff --git a/src/client/locales-nl.ts b/src/client/locales-nl.ts index 589029c..96d976e 100644 --- a/src/client/locales-nl.ts +++ b/src/client/locales-nl.ts @@ -28,6 +28,8 @@ export const nl: Record = { changesRunning: 'actief', changesError: 'fout', changesFold: '{count} regels… klik om uit te klappen', + changesFoldLoading: 'Uitklappen…', + changesFoldUnavailable: 'Context niet beschikbaar', changesContext: 'context', changesPriorUnknown: 'Eerdere inhoud valt buiten het venster; getoond als volledig nieuw', explorer: 'Verkenner', diff --git a/src/client/locales-pl.ts b/src/client/locales-pl.ts index 2138899..f9d5999 100644 --- a/src/client/locales-pl.ts +++ b/src/client/locales-pl.ts @@ -32,6 +32,8 @@ export const pl: Record = { changesRunning: 'w toku', changesError: 'błąd', changesFold: '{count} wierszy… kliknij, aby rozwinąć', + changesFoldLoading: 'Rozwijanie…', + changesFoldUnavailable: 'Kontekst niedostępny', changesContext: 'kontekst', changesPriorUnknown: 'Zawartość sprzed zmiany jest poza oknem; pokazana jako w całości nowa', explorer: 'Eksplorator', diff --git a/src/client/locales-pt.ts b/src/client/locales-pt.ts index 1417abe..03e022a 100644 --- a/src/client/locales-pt.ts +++ b/src/client/locales-pt.ts @@ -13,6 +13,8 @@ export const pt: Record = { changesRunning: 'em execução', changesError: 'erro', changesFold: '{count} linhas… clique para expandir', + changesFoldLoading: 'Expandindo…', + changesFoldUnavailable: 'Contexto indisponível', changesContext: 'contexto', changesPriorUnknown: 'O conteúdo anterior está fora da janela; mostrado como totalmente adicionado', explorer: 'Explorador', diff --git a/src/client/locales-ru.ts b/src/client/locales-ru.ts index e1d01cf..c75518c 100644 --- a/src/client/locales-ru.ts +++ b/src/client/locales-ru.ts @@ -26,6 +26,8 @@ export const ru: Record = { changesRunning: 'выполняется', changesError: 'ошибка', changesFold: '{count} строк… нажмите, чтобы развернуть', + changesFoldLoading: 'Разворачивание…', + changesFoldUnavailable: 'Контекст недоступен', changesContext: 'контекст', changesPriorUnknown: 'Содержимое до изменения вне окна; показано как полностью добавленное', explorer: 'Проводник', diff --git a/src/client/locales-sv.ts b/src/client/locales-sv.ts index c57f2fd..5b08a7b 100644 --- a/src/client/locales-sv.ts +++ b/src/client/locales-sv.ts @@ -13,6 +13,8 @@ export const sv: Record = { changesRunning: 'körs', changesError: 'fel', changesFold: '{count} rader… klicka för att expandera', + changesFoldLoading: 'Expanderar…', + changesFoldUnavailable: 'Kontext ej tillgänglig', changesContext: 'kontext', changesPriorUnknown: 'Innehållet före ändringen ligger utanför fönstret; visas som helt nytt', explorer: 'Utforskare', diff --git a/src/client/locales-th.ts b/src/client/locales-th.ts index 9bf3462..009ceac 100644 --- a/src/client/locales-th.ts +++ b/src/client/locales-th.ts @@ -30,6 +30,8 @@ export const th: Record = { changesRunning: 'กำลังทำงาน', changesError: 'ข้อผิดพลาด', changesFold: '{count} บรรทัด… คลิกเพื่อขยาย', + changesFoldLoading: 'กำลังขยาย…', + changesFoldUnavailable: 'ไม่สามารถโหลดบริบทได้', changesContext: 'บริบท', changesPriorUnknown: 'เนื้อหาก่อนแก้ไขอยู่นอกหน้าต่าง แสดงเป็นเพิ่มใหม่ทั้งหมด', explorer: 'ตัวสำรวจ', diff --git a/src/client/locales-tr.ts b/src/client/locales-tr.ts index e18ee3c..633374a 100644 --- a/src/client/locales-tr.ts +++ b/src/client/locales-tr.ts @@ -30,6 +30,8 @@ export const tr: Record = { changesRunning: 'çalışıyor', changesError: 'hata', changesFold: '{count} satır… genişletmek için tıklayın', + changesFoldLoading: 'Genişletiliyor…', + changesFoldUnavailable: 'Bağlam yüklenemedi', changesContext: 'bağlam', changesPriorUnknown: 'Değişiklik öncesi içerik pencere dışında; tamamen yeni eklenmiş olarak gösteriliyor', explorer: 'Gezgin', diff --git a/src/client/locales-vi.ts b/src/client/locales-vi.ts index 42b803e..03df336 100644 --- a/src/client/locales-vi.ts +++ b/src/client/locales-vi.ts @@ -30,6 +30,8 @@ export const vi: Record = { changesRunning: 'đang chạy', changesError: 'lỗi', 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', 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á', diff --git a/src/client/locales-zh-HK.ts b/src/client/locales-zh-HK.ts index 9e9af6a..ba55f2e 100644 --- a/src/client/locales-zh-HK.ts +++ b/src/client/locales-zh-HK.ts @@ -45,6 +45,8 @@ export const zhHK: Record = { changesRunning: '執行中', changesError: '出錯', changesFold: '{count} 行…點擊展開', + changesFoldLoading: '展開中…', + changesFoldUnavailable: '上下文未載入', changesContext: '上下文', changesPriorUnknown: '變更前的內容不在視窗內,顯示為全部新增', explorer: '檔案總管', diff --git a/src/client/locales-zh-MO.ts b/src/client/locales-zh-MO.ts index 1d17d13..21de407 100644 --- a/src/client/locales-zh-MO.ts +++ b/src/client/locales-zh-MO.ts @@ -45,6 +45,8 @@ export const zhMO: Record = { changesRunning: '執行中', changesError: '出錯', changesFold: '{count} 行…點擊展開', + changesFoldLoading: '展開中…', + changesFoldUnavailable: '上下文未載入', changesContext: '上下文', changesPriorUnknown: '變更前的內容不在視窗內,顯示為全部新增', explorer: '檔案總管', diff --git a/src/client/locales-zh-TW.ts b/src/client/locales-zh-TW.ts index 235624d..a1dfd60 100644 --- a/src/client/locales-zh-TW.ts +++ b/src/client/locales-zh-TW.ts @@ -45,6 +45,8 @@ export const zhTW: Record = { changesRunning: '執行中', changesError: '出錯', changesFold: '{count} 行…點擊展開', + changesFoldLoading: '展開中…', + changesFoldUnavailable: '上下文未載入', changesContext: '上下文', changesPriorUnknown: '變更前的內容不在視窗內,顯示為全部新增', explorer: '檔案總管', diff --git a/src/client/locales.ts b/src/client/locales.ts index 7a3f92b..07fca34 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -42,6 +42,8 @@ export const zh = { changesRedactOffLabel: '脱敏关', changesRedactBanner: '已脱敏', changesFold: '{count} 行…点击展开', + changesFoldLoading: '展开中…', + changesFoldUnavailable: '上下文未加载', changesContext: '上下文', changesPriorUnknown: '变更前的内容不在窗口内,显示为全新增', explorer: '资源管理器', @@ -489,6 +491,8 @@ export const en: Record = { changesRedactOffLabel: 'Redaction off', changesRedactBanner: 'Redacted', changesFold: '{count} lines…click to expand', + changesFoldLoading: 'Expanding…', + changesFoldUnavailable: 'Context unavailable', changesContext: 'context', changesPriorUnknown: 'Prior content is outside the loaded window; shown as all-added', explorer: 'Explorer', diff --git a/tests/diff-fold-expand.spec.tsx b/tests/diff-fold-expand.spec.tsx new file mode 100644 index 0000000..bd17542 --- /dev/null +++ b/tests/diff-fold-expand.spec.tsx @@ -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('[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('[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('[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('[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('[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('[data-expandable="true"]') + await act(async () => { expanded!.click() }) + expect(container.textContent).not.toContain('j') + } finally { + act(() => { root.unmount() }) + container.remove() + } + }) +})