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:
+70
-3
@@ -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<string | null>(null)
|
||||
const [data, setData] = useState<DiffData | null>(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<boolean | null>(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<void> => {
|
||||
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<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 (
|
||||
<div className={css.gitDiffTab}>
|
||||
<div className={css.gitDiffTabHeader}>
|
||||
@@ -104,7 +171,7 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif
|
||||
<>
|
||||
{data.untracked !== undefined
|
||||
? <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 && (
|
||||
<div className={css.gitEmpty}>{t('diffEmpty')}</div>
|
||||
)}
|
||||
|
||||
@@ -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<string | null>(null)
|
||||
const [diffText, setDiffText] = useState<string | null>(null)
|
||||
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
|
||||
// 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(() => {
|
||||
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<void> => {
|
||||
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<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
|
||||
// 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 !== '' && (
|
||||
<DiffFiles
|
||||
diff={diffText}
|
||||
resolveFold={foldLoader}
|
||||
untrackedPath={untracked !== undefined && target.ref.kind === 'worktree' ? target.ref.path : undefined}
|
||||
untrackedContent={untracked}
|
||||
/>
|
||||
|
||||
@@ -31,6 +31,8 @@ export const ar: Record<string, string> = {
|
||||
changesRunning: 'قيد التنفيذ',
|
||||
changesError: 'خطأ',
|
||||
changesFold: '{count} أسطر… انقر للتوسيع',
|
||||
changesFoldLoading: 'جارٍ التوسيع…',
|
||||
changesFoldUnavailable: 'السياق غير متوفر',
|
||||
changesContext: 'سياق',
|
||||
changesPriorUnknown: 'المحتوى السابق خارج النافذة؛ يُعرض كما لو أُضيف بالكامل',
|
||||
explorer: 'المستكشف',
|
||||
|
||||
@@ -16,6 +16,8 @@ export const de: Record<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -23,6 +23,8 @@ export const fr: Record<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -30,6 +30,8 @@ export const hi: Record<string, string> = {
|
||||
changesRunning: 'चल रहा है',
|
||||
changesError: 'त्रुटि',
|
||||
changesFold: '{count} पंक्तियाँ… विस्तार के लिए क्लिक करें',
|
||||
changesFoldLoading: 'विस्तार हो रहा है…',
|
||||
changesFoldUnavailable: 'संदर्भ अनुपलब्ध',
|
||||
changesContext: 'संदर्भ',
|
||||
changesPriorUnknown: 'बदलाव से पहले की सामग्री विंडो में नहीं है; पूरी नई जोड़ी गई दिखाई जाती है',
|
||||
explorer: 'एक्सप्लोरर',
|
||||
|
||||
@@ -28,6 +28,8 @@ export const id: Record<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -21,6 +21,8 @@ export const it: Record<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -30,6 +30,8 @@ export const ja: Record<string, string> = {
|
||||
changesRunning: '実行中',
|
||||
changesError: 'エラー',
|
||||
changesFold: '{count} 行…クリックで展開',
|
||||
changesFoldLoading: '展開中…',
|
||||
changesFoldUnavailable: 'コンテキスト未読み込み',
|
||||
changesContext: 'コンテキスト',
|
||||
changesPriorUnknown: '変更前の内容がウィンドウ内になく、すべて新規追加として表示されます',
|
||||
explorer: 'エクスプローラー',
|
||||
|
||||
@@ -22,6 +22,8 @@ export const ko: Record<string, string> = {
|
||||
changesRunning: '실행 중',
|
||||
changesError: '오류',
|
||||
changesFold: '{count}줄… 클릭하여 펼치기',
|
||||
changesFoldLoading: '펼치는 중…',
|
||||
changesFoldUnavailable: '컨텍스트를 불러올 수 없음',
|
||||
changesContext: '컨텍스트',
|
||||
changesPriorUnknown: '변경 전 내용이 창에 없어 모두 새로 추가된 것으로 표시됩니다',
|
||||
explorer: '탐색기',
|
||||
|
||||
@@ -28,6 +28,8 @@ export const nl: Record<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -32,6 +32,8 @@ export const pl: Record<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -13,6 +13,8 @@ export const pt: Record<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -26,6 +26,8 @@ export const ru: Record<string, string> = {
|
||||
changesRunning: 'выполняется',
|
||||
changesError: 'ошибка',
|
||||
changesFold: '{count} строк… нажмите, чтобы развернуть',
|
||||
changesFoldLoading: 'Разворачивание…',
|
||||
changesFoldUnavailable: 'Контекст недоступен',
|
||||
changesContext: 'контекст',
|
||||
changesPriorUnknown: 'Содержимое до изменения вне окна; показано как полностью добавленное',
|
||||
explorer: 'Проводник',
|
||||
|
||||
@@ -13,6 +13,8 @@ export const sv: Record<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -30,6 +30,8 @@ export const th: Record<string, string> = {
|
||||
changesRunning: 'กำลังทำงาน',
|
||||
changesError: 'ข้อผิดพลาด',
|
||||
changesFold: '{count} บรรทัด… คลิกเพื่อขยาย',
|
||||
changesFoldLoading: 'กำลังขยาย…',
|
||||
changesFoldUnavailable: 'ไม่สามารถโหลดบริบทได้',
|
||||
changesContext: 'บริบท',
|
||||
changesPriorUnknown: 'เนื้อหาก่อนแก้ไขอยู่นอกหน้าต่าง แสดงเป็นเพิ่มใหม่ทั้งหมด',
|
||||
explorer: 'ตัวสำรวจ',
|
||||
|
||||
@@ -30,6 +30,8 @@ export const tr: Record<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -30,6 +30,8 @@ export const vi: Record<string, string> = {
|
||||
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á',
|
||||
|
||||
@@ -45,6 +45,8 @@ export const zhHK: Record<string, string> = {
|
||||
changesRunning: '執行中',
|
||||
changesError: '出錯',
|
||||
changesFold: '{count} 行…點擊展開',
|
||||
changesFoldLoading: '展開中…',
|
||||
changesFoldUnavailable: '上下文未載入',
|
||||
changesContext: '上下文',
|
||||
changesPriorUnknown: '變更前的內容不在視窗內,顯示為全部新增',
|
||||
explorer: '檔案總管',
|
||||
|
||||
@@ -45,6 +45,8 @@ export const zhMO: Record<string, string> = {
|
||||
changesRunning: '執行中',
|
||||
changesError: '出錯',
|
||||
changesFold: '{count} 行…點擊展開',
|
||||
changesFoldLoading: '展開中…',
|
||||
changesFoldUnavailable: '上下文未載入',
|
||||
changesContext: '上下文',
|
||||
changesPriorUnknown: '變更前的內容不在視窗內,顯示為全部新增',
|
||||
explorer: '檔案總管',
|
||||
|
||||
@@ -45,6 +45,8 @@ export const zhTW: Record<string, string> = {
|
||||
changesRunning: '執行中',
|
||||
changesError: '出錯',
|
||||
changesFold: '{count} 行…點擊展開',
|
||||
changesFoldLoading: '展開中…',
|
||||
changesFoldUnavailable: '上下文未載入',
|
||||
changesContext: '上下文',
|
||||
changesPriorUnknown: '變更前的內容不在視窗內,顯示為全部新增',
|
||||
explorer: '檔案總管',
|
||||
|
||||
@@ -42,6 +42,8 @@ export const zh = {
|
||||
changesRedactOffLabel: '脱敏关',
|
||||
changesRedactBanner: '已脱敏',
|
||||
changesFold: '{count} 行…点击展开',
|
||||
changesFoldLoading: '展开中…',
|
||||
changesFoldUnavailable: '上下文未加载',
|
||||
changesContext: '上下文',
|
||||
changesPriorUnknown: '变更前的内容不在窗口内,显示为全新增',
|
||||
explorer: '资源管理器',
|
||||
@@ -489,6 +491,8 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
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',
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user