diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 4512fbf9b5..0b510bd064 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1051,6 +1051,17 @@ export const AppContainer = (props: AppContainerProps) => { // is swapped in once the real callback exists. const openRewindSelectorRef = useRef<() => void>(() => {}); + // /diff opens a per-turn diff dialog. Unlike rewind, no double-press or + // history-bound guard is needed, so the open/close handlers can live here + // (no ref bridge required). + const [isDiffDialogOpen, setIsDiffDialogOpen] = useState(false); + const openDiffDialog = useCallback(() => { + setIsDiffDialogOpen(true); + }, []); + const closeDiffDialog = useCallback(() => { + setIsDiffDialogOpen(false); + }, []); + const slashCommandActions = useMemo( () => ({ openAuthDialog, @@ -1082,6 +1093,7 @@ export const AppContainer = (props: AppContainerProps) => { openHooksDialog, openResumeDialog, openRewindSelector: () => openRewindSelectorRef.current(), + openDiffDialog, handleResume, handleBranch, openDeleteDialog, @@ -1113,6 +1125,7 @@ export const AppContainer = (props: AppContainerProps) => { handleBranch, openDeleteDialog, openHelpDialog, + openDiffDialog, ], ); @@ -2271,6 +2284,7 @@ export const AppContainer = (props: AppContainerProps) => { isHelpDialogOpen || isExtensionsManagerDialogOpen || isRewindSelectorOpen || + isDiffDialogOpen || bgTasksDialogOpen || showWorktreeExitDialog; dialogsVisibleRef.current = dialogsVisible; @@ -2740,6 +2754,8 @@ export const AppContainer = (props: AppContainerProps) => { closeHelpDialog, isBackgroundTasksDialogOpen: bgTasksDialogOpen, closeBackgroundTasksDialog: closeBgTasksDialog, + isDiffDialogOpen, + closeDiffDialog, showWorktreeExitDialog, closeWorktreeExitDialog: () => setShowWorktreeExitDialog(false), }); @@ -3284,6 +3300,8 @@ export const AppContainer = (props: AppContainerProps) => { // Rewind selector isRewindSelectorOpen, rewindEscPending, + // Diff dialog + isDiffDialogOpen, }), [ isThemeDialogOpen, @@ -3408,6 +3426,8 @@ export const AppContainer = (props: AppContainerProps) => { // Rewind selector isRewindSelectorOpen, rewindEscPending, + // Diff dialog + isDiffDialogOpen, ], ); @@ -3489,6 +3509,9 @@ export const AppContainer = (props: AppContainerProps) => { openRewindSelector, closeRewindSelector, handleRewindConfirm, + // Diff dialog + openDiffDialog, + closeDiffDialog, }), [ openThemeDialog, @@ -3564,6 +3587,9 @@ export const AppContainer = (props: AppContainerProps) => { openRewindSelector, closeRewindSelector, handleRewindConfirm, + // Diff dialog + openDiffDialog, + closeDiffDialog, ], ); diff --git a/packages/cli/src/ui/commands/diffCommand.test.ts b/packages/cli/src/ui/commands/diffCommand.test.ts index a2ab127f3b..4900000b42 100644 --- a/packages/cli/src/ui/commands/diffCommand.test.ts +++ b/packages/cli/src/ui/commands/diffCommand.test.ts @@ -69,7 +69,12 @@ describe('diffCommand', () => { it('errors when getWorkingDir and getProjectRoot both return empty', async () => { if (!diffCommand.action) throw new Error('Command has no action'); + // Non-interactive mode runs the fetchGitDiff path that actually needs a + // cwd. Interactive mode short-circuits to opening the dialog (the + // dialog's own hooks tolerate a missing cwd by showing the empty state), + // so the cwd guard only fires off the dialog path. const noCwdContext = createMockCommandContext({ + executionMode: 'non_interactive', services: { config: { getWorkingDir: () => '', @@ -301,75 +306,26 @@ describe('diffCommand interactive mode', () => { mockFetchGitDiff = vi.mocked(fetchGitDiff); }); - it('dispatches a diff_stats history item instead of returning text', async () => { + it('opens the diff dialog without touching git or the history', async () => { if (!diffCommand.action) throw new Error('Command has no action'); const ctx = makeInteractiveContext(); - mockFetchGitDiff.mockResolvedValue({ - stats: { filesCount: 2, linesAdded: 7, linesRemoved: 3 }, - perFileStats: new Map([ - ['src/a.ts', { added: 5, removed: 2, isBinary: false }], - ['src/b.ts', { added: 2, removed: 1, isBinary: false }], - ]), - } satisfies GitDiffResult); const result = await diffCommand.action(ctx, ''); - expect(result).toBeUndefined(); - expect(ctx.ui.addItem).toHaveBeenCalledTimes(1); - const call = (ctx.ui.addItem as Mock).mock.calls[0][0]; - expect(call.type).toBe('diff_stats'); - expect(call.model).toMatchObject({ - filesCount: 2, - linesAdded: 7, - linesRemoved: 3, - hiddenCount: 0, - }); - expect(call.model.rows).toHaveLength(2); - expect(call.model.rows[0]).toMatchObject({ - filename: 'src/a.ts', - added: 5, - removed: 2, - isBinary: false, - isUntracked: false, - }); - }); - - it('still returns a plain-text info message for the "clean tree" case', async () => { - if (!diffCommand.action) throw new Error('Command has no action'); - const ctx = makeInteractiveContext(); - mockFetchGitDiff.mockResolvedValue({ - stats: { filesCount: 0, linesAdded: 0, linesRemoved: 0 }, - perFileStats: new Map(), - } satisfies GitDiffResult); - - const result = await diffCommand.action(ctx, ''); - expect(result).toMatchObject({ type: 'message', messageType: 'info' }); + expect(result).toEqual({ type: 'dialog', dialog: 'diff' }); + // Dialog ownership: the data fetch happens inside the dialog's hooks, + // not in the command. Asserting we *don't* call git here keeps the + // contract from regressing to the old "summary in scroll history" + // behavior, which paid for a git fetch before the user could even + // see the picker. + expect(mockFetchGitDiff).not.toHaveBeenCalled(); expect(ctx.ui.addItem).not.toHaveBeenCalled(); }); - it('still returns an error MessageActionReturn when fetchGitDiff throws', async () => { + it('errors when config is unavailable even in interactive mode', async () => { if (!diffCommand.action) throw new Error('Command has no action'); - const ctx = makeInteractiveContext(); - mockFetchGitDiff.mockRejectedValueOnce(new Error('boom')); - + const ctx = createMockCommandContext({ executionMode: 'interactive' }); const result = await diffCommand.action(ctx, ''); expect(result).toMatchObject({ type: 'message', messageType: 'error' }); - expect(ctx.ui.addItem).not.toHaveBeenCalled(); - }); - - it('propagates hiddenCount to the history item for fast-path results', async () => { - if (!diffCommand.action) throw new Error('Command has no action'); - const ctx = makeInteractiveContext(); - mockFetchGitDiff.mockResolvedValue({ - stats: { filesCount: 60, linesAdded: 100, linesRemoved: 20 }, - perFileStats: new Map([ - ['src/a.ts', { added: 1, removed: 0, isBinary: false }], - ]), - } satisfies GitDiffResult); - - await diffCommand.action(ctx, ''); - const call = (ctx.ui.addItem as Mock).mock.calls[0][0]; - expect(call.model.hiddenCount).toBe(59); - expect(call.model.rows).toHaveLength(1); }); }); diff --git a/packages/cli/src/ui/commands/diffCommand.ts b/packages/cli/src/ui/commands/diffCommand.ts index 8607ec324b..e1bcc1d5f5 100644 --- a/packages/cli/src/ui/commands/diffCommand.ts +++ b/packages/cli/src/ui/commands/diffCommand.ts @@ -13,20 +13,16 @@ import { CommandKind, type CommandContext, type MessageActionReturn, + type OpenDialogActionReturn, type SlashCommand, } from './types.js'; import { t } from '../../i18n/index.js'; -import { - MessageType, - type DiffRenderModel, - type DiffRenderRow, - type HistoryItemDiffStats, -} from '../types.js'; -import { escapeAnsiCtrlCodes } from '../utils/textUtils.js'; +import { type DiffRenderModel, type DiffRenderRow } from '../types.js'; +import { sanitizeFilenameForDisplay } from '../utils/textUtils.js'; async function diffAction( context: CommandContext, -): Promise { +): Promise { const { config } = context.services; if (!config) { return { @@ -36,6 +32,13 @@ async function diffAction( }; } + // Interactive mode: open the per-turn diff dialog. Non-interactive / ACP + // paths keep the plain-text "working tree vs HEAD" summary so pipes, logs, + // and remote transports that don't speak Ink still get legible output. + if (context.executionMode === 'interactive') { + return { type: 'dialog', dialog: 'diff' }; + } + const cwd = config.getWorkingDir() || config.getProjectRoot(); if (!cwd) { return { @@ -78,19 +81,6 @@ async function diffAction( const model = buildDiffRenderModel(result); - // Interactive path: dispatch a structured history item so `DiffStatsDisplay` - // can render with theme colors. Non-interactive / ACP stay on the - // plain-text MessageActionReturn path so pipes, logs, and transports that - // don't speak Ink still see legible output. - if (context.executionMode === 'interactive') { - const item: HistoryItemDiffStats = { - type: MessageType.DIFF_STATS, - model, - }; - context.ui.addItem(item, Date.now()); - return; - } - return { type: 'message', messageType: 'info', @@ -207,43 +197,6 @@ export function renderDiffModelText(model: DiffRenderModel): string { return lines.length > 0 ? `${header}\n${lines.join('\n')}${capNote}` : header; } -// Match standalone C0 controls (incl. TAB/CR/LF/BEL/BS), DEL, and C1 controls. -// `escapeAnsiCtrlCodes` only neutralizes multi-byte ANSI sequences, so a -// filename like `bad\nINJECTED.txt` or `bad\roverwrite.txt` would otherwise -// still break layout in the non-interactive / ACP rendering path. -// eslint-disable-next-line no-control-regex -const FILENAME_CONTROL_CHARS_REGEX = /[\x00-\x1f\x7f-\x9f]/g; - -function escapeControlChar(ch: string): string { - switch (ch) { - case '\b': - return '\\b'; - case '\t': - return '\\t'; - case '\n': - return '\\n'; - case '\f': - return '\\f'; - case '\r': - return '\\r'; - default: { - // JSON.stringify only escapes 0x00-0x1F (and `"` / `\`); DEL (0x7F) and - // C1 controls (0x80-0x9F) are returned as raw bytes, which is exactly - // what we are trying to keep out of the rendered output. Hand-roll the - // \uXXXX escape so every matched code point becomes printable. - const code = ch.charCodeAt(0); - return `\\u${code.toString(16).padStart(4, '0')}`; - } - } -} - -function sanitizeFilenameForDisplay(name: string): string { - return escapeAnsiCtrlCodes(name).replace( - FILENAME_CONTROL_CHARS_REGEX, - escapeControlChar, - ); -} - function formatRowsText(rows: DiffRenderRow[]): string[] { if (rows.length === 0) return []; const { addWidth, remWidth, statColumnWidth } = computeDiffColumnWidths(rows); diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index a7454ea975..b914a21447 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -189,7 +189,8 @@ export interface OpenDialogActionReturn { | 'extensions_manage' | 'hooks' | 'mcp' - | 'rewind'; + | 'rewind' + | 'diff'; } /** diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 99c4975f7e..c901bb46e0 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -49,6 +49,7 @@ import { MCPManagementDialog } from './mcp/MCPManagementDialog.js'; import { HooksManagementDialog } from './hooks/HooksManagementDialog.js'; import { SessionPicker } from './SessionPicker.js'; import { RewindSelector } from './RewindSelector.js'; +import { DiffDialog } from './DiffDialog.js'; import { MemoryDialog } from './MemoryDialog.js'; import { Help } from './Help.js'; import { BackgroundTasksDialog } from './background-view/BackgroundTasksDialog.js'; @@ -486,6 +487,18 @@ export const DialogManager = ({ ); } + if (uiState.isDiffDialogOpen) { + return ( + + ); + } + // Background tasks dialog — lowest priority so other dialogs // (permissions, trust prompts, auth, etc.) always take precedence. The // dialog is part of the shared dialogsVisible machinery (see diff --git a/packages/cli/src/ui/components/DiffDialog.tsx b/packages/cli/src/ui/components/DiffDialog.tsx new file mode 100644 index 0000000000..5c89d2dfe8 --- /dev/null +++ b/packages/cli/src/ui/components/DiffDialog.tsx @@ -0,0 +1,732 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Box, Text } from 'ink'; +import type { Hunk } from 'diff'; +import type { + FileHistoryService, + GitDiffResult, + PerFileStats, + TurnDiff, + TurnFileDiff, +} from '@qwen-code/qwen-code-core'; +import type { HistoryItem } from '../types.js'; +import { theme } from '../semantic-colors.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { useTerminalSize } from '../hooks/useTerminalSize.js'; +import { useTurnDiffs, type TurnDiffEntry } from '../hooks/useTurnDiffs.js'; +import { useDiffData } from '../hooks/useDiffData.js'; +import { DiffRenderer } from './messages/DiffRenderer.js'; +import { sanitizeFilenameForDisplay } from '../utils/textUtils.js'; +import { t } from '../../i18n/index.js'; + +const MAX_VISIBLE_FILES = 8; + +export interface DiffDialogProps { + history: HistoryItem[]; + cwd: string | undefined; + fileHistoryService: FileHistoryService | undefined; + fileCheckpointingEnabled: boolean; + onClose: () => void; +} + +type UnifiedFile = { + /** Raw repo-relative path. Used as a stable map key against + * `current.hunks` / `TurnDiff.files[].filePath`. Never rendered to the + * terminal — those keys can contain ANSI escapes or bare control bytes + * (git allows them in tracked / untracked paths via `-z`). */ + path: string; + /** Sanitized version of `path` safe to drop into a `` node. */ + displayPath: string; + added: number; + removed: number; + isBinary: boolean; + isUntracked: boolean; + isDeleted: boolean; + isNewFile: boolean; + truncated: boolean; + oversized: boolean; + /** Whether the source actually has hunks for this file. Untracked + * files don't appear in `git diff HEAD` output, capped/oversized + * turn entries have empty hunks — pressing Enter on those would land + * the user on a dead-end "No hunks available" screen, so we block + * Enter in the keypress handler when this is false. */ + hasHunks: boolean; +}; + +type Source = + | { kind: 'current'; label: string } + | { kind: 'turn'; label: string; entry: TurnDiffEntry }; + +type ViewMode = 'list' | 'detail'; + +export function DiffDialog({ + history, + cwd, + fileHistoryService, + fileCheckpointingEnabled, + onClose, +}: DiffDialogProps): React.JSX.Element { + const current = useDiffData(cwd); + const { turns, loading: turnsLoading } = useTurnDiffs( + history, + fileHistoryService, + fileCheckpointingEnabled, + ); + + const sources = useMemo(() => { + const list: Source[] = [{ kind: 'current', label: t('Current') }]; + for (const entry of turns) { + list.push({ + kind: 'turn', + label: `T${entry.turnIndex}`, + entry, + }); + } + return list; + }, [turns]); + + const [sourceIndex, setSourceIndex] = useState(0); + const [fileIndex, setFileIndex] = useState(0); + const [viewMode, setViewMode] = useState('list'); + // Transient hint shown in place of the nav line when Enter lands on a + // non-enterable row (binary / oversized / no-hunks). Cleared on the next + // navigation keypress so it doesn't linger past the user's response. + const [keyHint, setKeyHint] = useState(null); + + // Derive clamped indexes inline rather than via a useEffect + setState. + // Effect-based clamping causes an extra render frame (the first paint + // uses the stale out-of-range index, the effect then schedules a + // setState that retriggers the render), which can look like a flicker + // in Ink. Computing on the fly keeps the dialog single-frame consistent + // when `sources` or `files` shrink between mount and resolve. + const safeSourceIndex = Math.min( + sourceIndex, + Math.max(0, sources.length - 1), + ); + + // Reset file selection when switching sources — file lists between + // sources are unrelated. (This still needs an effect: it's mutating + // state rather than just clamping on read.) + useEffect(() => { + setFileIndex(0); + setViewMode('list'); + }, [safeSourceIndex]); + + const activeSource = sources[safeSourceIndex]; + const files = useMemo(() => { + if (!activeSource) return []; + return activeSource.kind === 'current' + ? currentToFiles(current.result, current.hunks) + : turnToFiles(activeSource.entry.diff); + }, [activeSource, current.result, current.hunks]); + + const safeFileIndex = Math.min(fileIndex, Math.max(0, files.length - 1)); + const selectedFile = files[safeFileIndex]; + + const stats = useMemo(() => { + if (!activeSource) return { filesCount: 0, linesAdded: 0, linesRemoved: 0 }; + if (activeSource.kind === 'current') { + const s = current.result?.stats; + return { + filesCount: s?.filesCount ?? 0, + linesAdded: s?.linesAdded ?? 0, + linesRemoved: s?.linesRemoved ?? 0, + }; + } + const s = activeSource.entry.diff.stats; + return { + filesCount: s.filesChanged, + linesAdded: s.linesAdded, + linesRemoved: s.linesRemoved, + }; + }, [activeSource, current.result]); + + // Refs let the keypress handler stay referentially stable across renders + // even though it reads varying state. Without this, every render would + // recreate the callback, churn `subscribe`/`unsubscribe` inside + // `useKeypress`, and add unnecessary work to every keystroke. + const viewModeRef = useRef(viewMode); + const sourcesLenRef = useRef(sources.length); + const filesLenRef = useRef(files.length); + const selectedFileRef = useRef(selectedFile); + const onCloseRef = useRef(onClose); + const setKeyHintRef = useRef(setKeyHint); + viewModeRef.current = viewMode; + sourcesLenRef.current = sources.length; + filesLenRef.current = files.length; + selectedFileRef.current = selectedFile; + onCloseRef.current = onClose; + setKeyHintRef.current = setKeyHint; + + const handleKeypress = useCallback((key: { name?: string }) => { + const name = key.name; + // Ctrl+C is intentionally NOT handled here — the AppContainer-level + // handler routes it through `closeAnyOpenDialog`, where this dialog + // is registered. Handling it both places would double-fire and could + // escalate to the exit prompt after the dialog already closed. + if (name === 'escape') { + if (viewModeRef.current === 'detail') { + setViewMode('list'); + } else { + onCloseRef.current(); + } + return; + } + if (viewModeRef.current === 'detail') { + if (name === 'left' || name === 'backspace') { + setViewMode('list'); + } + return; + } + // Any navigation key clears a previously displayed Enter-rejection + // hint so it doesn't outlive the user's next action. + setKeyHintRef.current(null); + if (name === 'left') { + setSourceIndex((i) => Math.max(0, i - 1)); + return; + } + if (name === 'right') { + setSourceIndex((i) => Math.min(sourcesLenRef.current - 1, i + 1)); + return; + } + if (name === 'up' || name === 'k') { + setFileIndex((i) => Math.max(0, i - 1)); + return; + } + if (name === 'down' || name === 'j') { + setFileIndex((i) => + Math.min(Math.max(0, filesLenRef.current - 1), i + 1), + ); + return; + } + if (name === 'return') { + const sel = selectedFileRef.current; + // Refuse Enter when the detail view would have nothing to show: + // binary / oversized rows are presented with explicit notes in + // the list, and rows with no hunks (untracked files, capped + // entries) would otherwise land users on a dead-end screen. + // Surface a transient hint so the keypress isn't silently + // consumed — without it users could mistake the dialog for hung. + if (!sel) return; + if (sel.isBinary) { + setKeyHintRef.current(t('Binary file — no diff to view.')); + return; + } + if (sel.oversized) { + setKeyHintRef.current( + t('Oversized file — diff omitted. Use `git diff` to inspect.'), + ); + return; + } + if (!sel.hasHunks) { + setKeyHintRef.current(t('No diff content available for this file.')); + return; + } + setViewMode('detail'); + return; + } + }, []); + + useKeypress(handleKeypress, { isActive: true }); + + const { columns, rows } = useTerminalSize(); + const dialogWidth = Math.min(columns - 4, 110); + const detailHeight = Math.max(8, rows - 12); + + const headerTitle = + activeSource?.kind === 'turn' + ? t('Turn {{n}}', { n: String(activeSource.entry.turnIndex) }) + : t('Working tree vs HEAD'); + const headerSubtitle = + activeSource?.kind === 'turn' && activeSource.entry.promptPreview + ? `“${activeSource.entry.promptPreview}”` + : activeSource?.kind === 'current' + ? t('(git diff HEAD)') + : ''; + + const loadingNow = + (activeSource?.kind === 'current' && current.loading) || + (activeSource?.kind === 'turn' && turnsLoading); + + // For "Current", `stats.filesCount` may exceed `files.length` when + // `fetchGitDiff` capped `perFileStats` at MAX_FILES (=50). For turn + // sources, `getTurnDiff` reports `filesOmitted` when the turn touched + // more files than `MAX_TURN_DIFF_FILES`. Surface either gap so capped + // rows aren't indistinguishable from "everything fit". + // + // Semantic asymmetry: the Current count is exact (numstat is cheap so + // every change is counted before capping), while the turn count is an + // upper bound — some of the cap-dropped files may have been unchanged. + // The footer copy reflects that with "up to N more" for turn sources. + const hiddenFileCount = + activeSource?.kind === 'current' + ? Math.max(0, stats.filesCount - files.length) + : activeSource?.kind === 'turn' + ? activeSource.entry.diff.stats.filesOmitted + : 0; + const hiddenIsUpperBound = activeSource?.kind === 'turn'; + + return ( + + + + /diff · {headerTitle} + {headerSubtitle ? ( + {headerSubtitle} + ) : null} + + + {stats.filesCount} {stats.filesCount === 1 ? t('file') : t('files')} + {stats.linesAdded > 0 ? ( + +{stats.linesAdded} + ) : null} + {stats.linesRemoved > 0 ? ( + -{stats.linesRemoved} + ) : null} + + + + + + + {loadingNow ? ( + {t('Loading diff…')} + ) : !activeSource || files.length === 0 ? ( + + {emptyMessage( + activeSource, + current.result, + fileCheckpointingEnabled, + )} + + ) : viewMode === 'list' ? ( + <> + + {hiddenFileCount > 0 ? ( + + {' '} + {hiddenIsUpperBound + ? t('…and up to {{n}} more (showing first {{shown}})', { + n: String(hiddenFileCount), + shown: String(files.length), + }) + : t('…and {{n}} more (showing first {{shown}})', { + n: String(hiddenFileCount), + shown: String(files.length), + })} + + ) : null} + + ) : selectedFile ? ( + + ) : null} + + + + + {keyHint && viewMode === 'list' + ? keyHint + : viewMode === 'list' + ? sources.length > 1 + ? t('←/→ source · ↑/↓ file · Enter view · Esc close') + : t('↑/↓ file · Enter view · Esc close') + : t('← back · Esc close')} + + + + ); +} + +function SourceSwitcher({ + sources, + sourceIndex, +}: { + sources: Source[]; + sourceIndex: number; +}): React.JSX.Element | null { + if (sources.length <= 1) return null; + return ( + + {sourceIndex > 0 ? ( + + ) : ( + + )} + {sources.map((s, i) => { + const selected = i === sourceIndex; + return ( + + {i > 0 ? ' · ' : ''} + {s.label} + + ); + })} + {sourceIndex < sources.length - 1 ? ( + + ) : null} + + ); +} + +function FileList({ + files, + selectedIndex, + contentWidth, +}: { + files: UnifiedFile[]; + selectedIndex: number; + contentWidth: number; +}): React.JSX.Element { + const { startIndex, endIndex } = useVisibleWindow( + files.length, + selectedIndex, + MAX_VISIBLE_FILES, + ); + const visible = files.slice(startIndex, endIndex); + const aboveCount = startIndex; + const belowCount = files.length - endIndex; + // Reserve room for the pointer (2), the tag column (≤16 chars), and the + // stats column (≤16 chars). Anything past that gets head-truncated so + // overflowing paths can't wrap and break the row layout. + const TAG_AND_STATS_BUDGET = 32; + const maxPathChars = Math.max(8, contentWidth - 2 - TAG_AND_STATS_BUDGET); + return ( + + {aboveCount > 0 ? ( + + {' '} + ↑ {aboveCount} {aboveCount === 1 ? t('more file') : t('more files')} + + ) : null} + {visible.map((f, idx) => ( + + ))} + {belowCount > 0 ? ( + + {' '} + ↓ {belowCount} {belowCount === 1 ? t('more file') : t('more files')} + + ) : null} + + ); +} + +function FileRow({ + file, + selected, + maxPathChars, +}: { + file: UnifiedFile; + selected: boolean; + maxPathChars: number; +}): React.JSX.Element { + const pointer = selected ? '› ' : ' '; + // Tag priority: mutually exclusive states first (a file can't be both + // deleted and untracked), then capability flags. `isBinary` is omitted + // here because the stats column already renders an italic "binary" + // marker — duplicating it as a tag would just clutter the row. + const tag = file.isDeleted + ? t(' (deleted)') + : file.isUntracked + ? t(' (untracked)') + : file.oversized + ? t(' (oversized — diff omitted)') + : file.isNewFile + ? t(' (new)') + : file.truncated + ? t(' (truncated)') + : ''; + // Head-truncate so the basename (the part users actually read) is kept. + // Use the sanitized displayPath — `file.path` may carry raw control bytes. + const path = truncatePathStart(file.displayPath, maxPathChars); + return ( + + + {pointer} + {path} + + {tag} + {file.isBinary ? ( + + {t('binary')} + + ) : ( + <> + {file.added > 0 ? ( + +{file.added} + ) : null} + {file.added > 0 && file.removed > 0 ? : null} + {file.removed > 0 ? ( + -{file.removed} + ) : null} + + )} + + ); +} + +function FileDetail({ + file, + activeSource, + currentHunks, + availableHeight, + contentWidth, +}: { + file: UnifiedFile; + activeSource: Source; + currentHunks: Map; + availableHeight: number; + contentWidth: number; +}): React.JSX.Element { + const diffText = useMemo(() => { + if (file.isBinary) return ''; + if (activeSource.kind === 'current') { + const hunks = currentHunks.get(file.path); + if (!hunks || hunks.length === 0) return ''; + return hunksToUnifiedDiff(file.path, hunks); + } + const entry = activeSource.entry.diff.files.find( + (f) => f.filePath === file.path, + ); + if (!entry) return ''; + return hunksToUnifiedDiff(file.path, entry.hunks); + }, [file, activeSource, currentHunks]); + + if (file.isBinary) { + return ( + {t('Binary file — no diff.')} + ); + } + if (file.oversized) { + return ( + + {t('Oversized file — diff omitted. Use `git diff` to inspect.')} + + ); + } + if (!diffText) { + return ( + + {t('No hunks available for {{path}}.', { path: file.displayPath })} + + ); + } + + return ( + + + {truncatePathStart(file.displayPath, contentWidth)} + + + + + + ); +} + +/** + * Truncate from the **start** so the basename — the most identifying part + * of a path — survives. Mirrors claude-code's `truncateStartToWidth` and + * keeps long absolute paths from wrapping and shattering the row layout. + */ +function truncatePathStart(path: string, maxChars: number): string { + if (maxChars <= 0) return ''; + if (path.length <= maxChars) return path; + if (maxChars <= 1) return path.slice(-maxChars); + return '…' + path.slice(-(maxChars - 1)); +} + +function useVisibleWindow( + total: number, + selectedIndex: number, + windowSize: number, +): { startIndex: number; endIndex: number } { + if (total <= windowSize) return { startIndex: 0, endIndex: total }; + let start = Math.max(0, selectedIndex - Math.floor(windowSize / 2)); + let end = start + windowSize; + if (end > total) { + end = total; + start = Math.max(0, end - windowSize); + } + return { startIndex: start, endIndex: end }; +} + +function currentToFiles( + result: GitDiffResult | null, + hunks: Map, +): UnifiedFile[] { + if (!result) return []; + // `result.perFileStats` is already bounded by `fetchGitDiff` (MAX_FILES=50) + // and the whole map is empty when the diff exceeds MAX_FILES_FOR_DETAILS + // upstream, so no additional cap is necessary here. + const out: UnifiedFile[] = []; + for (const [path, s] of result.perFileStats) { + out.push(perFileToUnified(path, s, hunks)); + } + out.sort((a, b) => a.path.localeCompare(b.path)); + return out; +} + +function perFileToUnified( + path: string, + s: PerFileStats, + hunks: Map, +): UnifiedFile { + const fileHunks = hunks.get(path); + // `s.truncated` from `parseGitNumstat` already means "untracked file + // exceeded the line-counting read cap". The earlier `total > + // MAX_LINES_PER_FILE` OR was conflating it with `parseGitDiff`'s + // hunk-line cap (which is a separate, on-the-hunk-side cap that + // doesn't lose the stats). A 500-line tracked file with accurate + // numstat counts should NOT be flagged as truncated. + return { + path, + displayPath: sanitizeFilenameForDisplay(path), + added: s.added ?? 0, + removed: s.isUntracked ? 0 : (s.removed ?? 0), + isBinary: !!s.isBinary, + isUntracked: !!s.isUntracked, + isDeleted: !!s.isDeleted, + // `isNewFile` means "added in this turn" (snapshot before-state empty, + // after-state populated). Git's `untracked` is a different concept + // (never in HEAD/index) and is tagged separately — conflating them + // would mislead users about what `/rewind` can recover, since + // untracked files are not under file-history protection. + isNewFile: false, + truncated: !!s.truncated, + oversized: false, + // `git diff HEAD` skips untracked files entirely and capped/skipped + // entries can lack hunks even when present in perFileStats — gate + // Enter on the actual presence of hunks rather than the row's + // existence. + hasHunks: !!fileHunks && fileHunks.length > 0, + }; +} + +function turnToFiles(diff: TurnDiff): UnifiedFile[] { + return diff.files.map(turnFileToUnified); +} + +function turnFileToUnified(f: TurnFileDiff): UnifiedFile { + return { + path: f.filePath, + displayPath: sanitizeFilenameForDisplay(f.filePath), + added: f.linesAdded, + removed: f.linesRemoved, + // Binary detection lives in core (`looksBinary` NUL-byte sniff): the + // snapshot is text content, so the renderer would otherwise feed + // garbage to DiffRenderer for a turn that edited an image. + isBinary: f.isBinary, + isUntracked: false, + isDeleted: f.isDeleted, + isNewFile: f.isNewFile, + truncated: false, + oversized: f.oversized, + hasHunks: f.hunks.length > 0, + }; +} + +function hunksToUnifiedDiff( + filePath: string, + hunks: Array<{ + oldStart: number; + oldLines: number; + newStart: number; + newLines: number; + lines: string[]; + }>, +): string { + // A header-only string isn't a valid unified diff and would confuse + // DiffRenderer (which expects at least one `@@` block past `---/+++`). + // The FileDetail empty-text check then catches this as "no hunks". + if (hunks.length === 0) return ''; + // DiffRenderer expects unified-diff text starting with the file header so + // its `--- /+++` skip works. We hand it a minimal envelope plus the hunk + // headers and lines verbatim. Sanitize the embedded path to defang any + // control bytes git could have round-tripped (DiffRenderer drops the + // `---` line and only skips `+++` past unknown content, but sanitizing + // both keeps oddities from sneaking into log captures). + const safePath = sanitizeFilenameForDisplay(filePath); + const lines: string[] = [`--- a/${safePath}`, `+++ b/${safePath}`]; + for (const h of hunks) { + lines.push( + `@@ -${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines} @@`, + ); + for (const l of h.lines) lines.push(l); + } + return lines.join('\n'); +} + +function emptyMessage( + activeSource: Source | undefined, + currentResult: GitDiffResult | null, + fileCheckpointingEnabled: boolean, +): string { + if (!activeSource) { + return fileCheckpointingEnabled + ? t('No diff data yet.') + : t( + 'Per-turn diffs are unavailable because file checkpointing is disabled.', + ); + } + if (activeSource.kind === 'current') { + if (!currentResult) { + return t( + 'No diff available. Either this is not a git repository, HEAD is missing, or a merge/rebase/cherry-pick/revert is in progress.', + ); + } + // `fetchGitDiff` returns `filesCount > 0` with an empty `perFileStats` + // map when the diff exceeds MAX_FILES_FOR_DETAILS — calling that case + // "clean" would silently hide a large dirty tree. Surface it explicitly. + if (currentResult.stats.filesCount > 0) { + return t( + '{{count}} files changed but the diff is too large to list per-file (+{{added}} / -{{removed}}). Use `git diff` for details.', + { + count: String(currentResult.stats.filesCount), + added: String(currentResult.stats.linesAdded), + removed: String(currentResult.stats.linesRemoved), + }, + ); + } + return t('Working tree is clean.'); + } + return t('No file changes were captured in this turn.'); +} diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 3f4629014b..d35638ce99 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -107,6 +107,9 @@ export interface UIActions { openRewindSelector: () => void; closeRewindSelector: () => void; handleRewindConfirm: (userItem: HistoryItem, option: RestoreOption) => void; + // Diff dialog + openDiffDialog: () => void; + closeDiffDialog: () => void; } export const UIActionsContext = createContext(null); diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 4d790bb6ed..72b70d1b70 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -183,6 +183,8 @@ export interface UIState { // Rewind selector isRewindSelectorOpen: boolean; rewindEscPending: boolean; + // Diff dialog + isDiffDialogOpen: boolean; } export const UIStateContext = createContext(null); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 0607778cdd..88890a8f37 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -110,6 +110,7 @@ export interface SlashCommandProcessorActions { openMcpDialog: () => void; openHooksDialog: () => void; openRewindSelector: () => void; + openDiffDialog: () => void; openHelpDialog: () => void; } @@ -741,6 +742,9 @@ export const useSlashCommandProcessor = ( case 'rewind': actions.openRewindSelector(); return { type: 'handled' }; + case 'diff': + actions.openDiffDialog(); + return { type: 'handled' }; case 'help': actions.openHelpDialog(); return { type: 'handled' }; diff --git a/packages/cli/src/ui/hooks/useDialogClose.ts b/packages/cli/src/ui/hooks/useDialogClose.ts index c50854bd9d..bbdb6cbfab 100644 --- a/packages/cli/src/ui/hooks/useDialogClose.ts +++ b/packages/cli/src/ui/hooks/useDialogClose.ts @@ -70,6 +70,10 @@ export interface DialogCloseOptions { isBackgroundTasksDialogOpen: boolean; closeBackgroundTasksDialog: () => void; + // Diff dialog + isDiffDialogOpen?: boolean; + closeDiffDialog?: () => void; + // Worktree exit dialog (Phase C) showWorktreeExitDialog?: boolean; closeWorktreeExitDialog?: () => void; @@ -140,6 +144,23 @@ export function useDialogClose(options: DialogCloseOptions) { return true; } + // Scoped invariant: the diff-dialog branch MUST sit above the + // background-tasks branch because `DialogManager` renders the diff + // dialog over `BackgroundTasksDialog` when both flags are true (see + // `DialogManager.tsx` — diff block at the `BackgroundTasksDialog` + // fall-through). The rest of this hook's ordering is **not** a + // mirror of `DialogManager` and isn't intended to be: most higher- + // priority dialogs in `DialogManager` (theme, auth, settings, …) + // already appear above this block in their own priority order. Only + // the diff-vs-background pair previously matched the wrong way. + if (options.isDiffDialogOpen && options.closeDiffDialog) { + // /diff dialog — same rationale as the background-tasks dialog: + // Ctrl+C should dismiss the dialog rather than fall through to the + // exit-prompt path or cancel the (non-existent) request. + options.closeDiffDialog(); + return true; + } + if (options.isBackgroundTasksDialogOpen) { // Background tasks dialog — routed through closeAnyOpenDialog so // Ctrl+C and the global escape path dismiss it without escalating diff --git a/packages/cli/src/ui/hooks/useDiffData.ts b/packages/cli/src/ui/hooks/useDiffData.ts new file mode 100644 index 0000000000..f2cfd5a1a3 --- /dev/null +++ b/packages/cli/src/ui/hooks/useDiffData.ts @@ -0,0 +1,87 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useEffect, useState } from 'react'; +import type { Hunk } from 'diff'; +import { + createDebugLogger, + fetchGitDiff, + fetchGitDiffHunks, + type GitDiffResult, +} from '@qwen-code/qwen-code-core'; + +const debugLogger = createDebugLogger('DiffDialog'); + +export interface CurrentDiffData { + /** `null` ⇒ not a git repo / HEAD missing / mid-rebase / etc. */ + result: GitDiffResult | null; + hunks: Map; + loading: boolean; +} + +/** + * Loads "working tree vs HEAD" stats and hunks **once at mount**. Mirrors + * the data shape `fetchGitDiff` already returns so renderers can be + * driven from a single contract — see `DiffDialog`. + * + * Snapshot semantics: the dialog's "Current" tab reflects the state at + * the moment `/diff` was opened, not the live worktree. Re-fetching on + * every render would flicker the UI as users navigate between sources; + * users who want a fresh view can close and reopen `/diff`. The + * `cwd`-only dependency reinforces this — typing in another shell pane + * does not retrigger the fetch. + * + * Failures are swallowed and surfaced as the empty result (the dialog + * displays an explanatory empty-state instead of crashing), matching + * how non-interactive `/diff` already behaves. We log them at the debug + * level so an operator can still trace permission flips, corrupt index + * files, or other git failures. + */ +export function useDiffData(cwd: string | undefined): CurrentDiffData { + const [result, setResult] = useState(null); + const [hunks, setHunks] = useState>(new Map()); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + if (!cwd) { + setResult(null); + setHunks(new Map()); + setLoading(false); + return; + } + setLoading(true); + Promise.all([ + fetchGitDiff(cwd).catch((err) => { + debugLogger.debug(`fetchGitDiff failed: ${err}`); + return null; + }), + fetchGitDiffHunks(cwd).catch((err) => { + debugLogger.debug(`fetchGitDiffHunks failed: ${err}`); + return new Map(); + }), + ]) + .then(([statsRes, hunksRes]) => { + if (cancelled) return; + setResult(statsRes); + setHunks(hunksRes); + setLoading(false); + }) + .catch((err) => { + // Defense-in-depth: each inner promise already swallows its own + // errors, but a setState during unmount or a future refactor could + // still throw here. Log and unstick `loading` rather than letting + // the rejection propagate to the default-handler. + debugLogger.debug(`useDiffData pipeline failed: ${err}`); + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [cwd]); + + return { result, hunks, loading }; +} diff --git a/packages/cli/src/ui/hooks/useTurnDiffs.test.ts b/packages/cli/src/ui/hooks/useTurnDiffs.test.ts new file mode 100644 index 0000000000..ed20bea112 --- /dev/null +++ b/packages/cli/src/ui/hooks/useTurnDiffs.test.ts @@ -0,0 +1,194 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderHook, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import type { FileHistoryService, TurnDiff } from '@qwen-code/qwen-code-core'; +import { useTurnDiffs } from './useTurnDiffs.js'; +import type { HistoryItem } from '../types.js'; + +// The shared debug logger pulls in the full core module graph during test +// startup, which is overkill for a focused renderHook spec. Stub it so the +// hook can call `createDebugLogger(...)` without instantiating Storage etc. +vi.mock('@qwen-code/qwen-code-core', () => ({ + createDebugLogger: () => ({ + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +function userTurn(id: number, text: string, promptId?: string): HistoryItem { + return { + id, + type: 'user', + text, + ...(promptId !== undefined ? { promptId } : {}), + } as HistoryItem; +} + +function slashTurn(id: number, text: string): HistoryItem { + // Slash commands are also `type: 'user'` but `isRealUserTurn` filters them + // out — useful for the "empty filter" assertion. + return { id, type: 'user', text } as HistoryItem; +} + +function fakeDiff(promptId: string, fileCount: number): TurnDiff { + return { + promptId, + timestamp: new Date(), + files: Array.from({ length: fileCount }, (_, i) => ({ + filePath: `f${i}.txt`, + hunks: [], + isNewFile: false, + isDeleted: false, + linesAdded: 1, + linesRemoved: 0, + oversized: false, + isBinary: false, + })), + stats: { + filesChanged: fileCount, + linesAdded: fileCount, + linesRemoved: 0, + filesOmitted: 0, + }, + }; +} + +function makeService( + responder: (promptId: string) => Promise, +): FileHistoryService { + // Only `getTurnDiff` is touched by the hook; everything else stays + // undefined and would throw if accessed (which itself is a useful guard). + return { getTurnDiff: responder } as unknown as FileHistoryService; +} + +describe('useTurnDiffs', () => { + it('returns empty when disabled', async () => { + const service = makeService(async () => fakeDiff('p1', 1)); + // Stable history reference: useEffect deps include `history`, so a new + // array on every render would re-fire the effect → infinite loop. + const history = [userTurn(1, 'hello', 'p1')]; + const { result } = renderHook(() => useTurnDiffs(history, service, false)); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.turns).toEqual([]); + }); + + it('returns empty when fileHistoryService is undefined', async () => { + const history = [userTurn(1, 'hello', 'p1')]; + const { result } = renderHook(() => useTurnDiffs(history, undefined, true)); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.turns).toEqual([]); + }); + + it('filters out slash-commands, missing promptId, and empty diffs', async () => { + const service = makeService(async (id) => { + if (id === 'p-empty') return fakeDiff(id, 0); // empty: should drop + if (id === 'p-good') return fakeDiff(id, 2); + return undefined; // no snapshot: should drop + }); + + const history: HistoryItem[] = [ + slashTurn(1, '/help'), + userTurn(2, 'no prompt id'), // no promptId → filtered + userTurn(3, 'empty diff', 'p-empty'), + userTurn(4, 'good one', 'p-good'), + userTurn(5, 'missing snapshot', 'p-missing'), + ]; + + const { result } = renderHook(() => useTurnDiffs(history, service, true)); + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.turns).toHaveLength(1); + expect(result.current.turns[0].diff.promptId).toBe('p-good'); + }); + + it('orders results most-recent-first', async () => { + const service = makeService(async (id) => fakeDiff(id, 1)); + const history: HistoryItem[] = [ + userTurn(1, 'oldest', 'p1'), + userTurn(2, 'middle', 'p2'), + userTurn(3, 'newest', 'p3'), + ]; + + const { result } = renderHook(() => useTurnDiffs(history, service, true)); + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.turns.map((t) => t.diff.promptId)).toEqual([ + 'p3', + 'p2', + 'p1', + ]); + // 1-based turn index follows the original history order, not the + // most-recent-first display order — newest turn = highest index. + expect(result.current.turns.map((t) => t.turnIndex)).toEqual([3, 2, 1]); + }); + + it('isolates per-turn errors so one bad turn does not poison the rest', async () => { + const service = makeService(async (id) => { + if (id === 'p-bad') throw new Error('disk fell over'); + return fakeDiff(id, 1); + }); + const history: HistoryItem[] = [ + userTurn(1, 'good a', 'p-a'), + userTurn(2, 'bad', 'p-bad'), + userTurn(3, 'good c', 'p-c'), + ]; + + const { result } = renderHook(() => useTurnDiffs(history, service, true)); + await waitFor(() => expect(result.current.loading).toBe(false)); + + // p-bad's throw is swallowed; the two good turns still arrive. + expect(result.current.turns.map((t) => t.diff.promptId).sort()).toEqual([ + 'p-a', + 'p-c', + ]); + }); + + it('processes more than TURN_CONCURRENCY (4) turns without dropping any', async () => { + // 10 turns × concurrency 4 forces ≥3 batches. Verifies the for-loop + // walks every batch instead of returning after the first. + const promptIds = Array.from({ length: 10 }, (_, i) => `p${i + 1}`); + const service = makeService(async (id) => fakeDiff(id, 1)); + const history: HistoryItem[] = promptIds.map((id, i) => + userTurn(i + 1, `turn ${i + 1}`, id), + ); + + const { result } = renderHook(() => useTurnDiffs(history, service, true)); + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.turns).toHaveLength(10); + // All promptIds present, ordering still newest-first. + expect(result.current.turns.map((t) => t.diff.promptId)).toEqual( + [...promptIds].reverse(), + ); + }); + + it('caps in-flight calls at TURN_CONCURRENCY (concurrent fan-out bounded)', async () => { + let inFlight = 0; + let peak = 0; + const service = makeService(async (id) => { + inFlight++; + peak = Math.max(peak, inFlight); + // Yield so other queued calls can observe the gate. + await new Promise((r) => setTimeout(r, 5)); + inFlight--; + return fakeDiff(id, 1); + }); + + const history: HistoryItem[] = Array.from({ length: 12 }, (_, i) => + userTurn(i + 1, `t${i + 1}`, `p${i + 1}`), + ); + const { result } = renderHook(() => useTurnDiffs(history, service, true)); + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.turns).toHaveLength(12); + // Hook's TURN_CONCURRENCY = 4; never more than 4 simultaneous calls. + expect(peak).toBeLessThanOrEqual(4); + expect(peak).toBeGreaterThan(0); + }); +}); diff --git a/packages/cli/src/ui/hooks/useTurnDiffs.ts b/packages/cli/src/ui/hooks/useTurnDiffs.ts new file mode 100644 index 0000000000..f23b976da8 --- /dev/null +++ b/packages/cli/src/ui/hooks/useTurnDiffs.ts @@ -0,0 +1,157 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useEffect, useState } from 'react'; +import { + createDebugLogger, + type FileHistoryService, + type TurnDiff, +} from '@qwen-code/qwen-code-core'; +import type { HistoryItem, HistoryItemUser } from '../types.js'; + +type UserTurn = HistoryItem & HistoryItemUser; +import { isRealUserTurn } from '../utils/historyMapping.js'; +import { escapeAnsiCtrlCodes } from '../utils/textUtils.js'; + +const debugLogger = createDebugLogger('DiffDialog'); + +/** Cap concurrent `getTurnDiff` calls. Each call can fan out to up to + * `MAX_TURN_DIFF_FILES * 2 = 1000` open()s; without an outer cap a + * 50-turn session would multiply that and trivially blow past macOS's + * default `ulimit -n` of 256. 4 is conservative — turns batch quickly + * enough that loading never feels slower in practice while bounding the + * worst case to ~4000 concurrent fds (well under typical 4096 ceilings). + */ +const TURN_CONCURRENCY = 4; + +export interface TurnDiffEntry { + /** 1-based index displayed to the user (T1 = oldest). */ + turnIndex: number; + /** Trimmed preview of the original prompt, for the source tab label. */ + promptPreview: string; + /** Full diff payload from FileHistoryService. */ + diff: TurnDiff; +} + +/** + * Loads per-turn diffs for every user turn that has a tracked `promptId`. + * + * Output is ordered **most recent first** to match how users mentally scan + * "what just happened" — the source picker in the dialog mirrors that. + * + * Turns that: + * - have no `promptId` (slash commands, BTW prompts, pre-checkpointing + * legacy turns), or + * - have a `promptId` but no matching snapshot (e.g. compressed-out turns + * where the snapshot survives but the user message was rebuilt without + * a `promptId`), or + * - produced no file changes at all + * are filtered out: showing an empty "T7" entry is just noise. + */ +export function useTurnDiffs( + history: HistoryItem[], + fileHistoryService: FileHistoryService | undefined, + enabled: boolean, +): { turns: TurnDiffEntry[]; loading: boolean } { + const [turns, setTurns] = useState([]); + const [loading, setLoading] = useState(enabled); + + useEffect(() => { + if (!enabled || !fileHistoryService) { + setTurns([]); + setLoading(false); + return; + } + + let cancelled = false; + setLoading(true); + + // isRealUserTurn is a type predicate, so the filter narrows to + // UserTurn[] without the previous `as HistoryItem[]` cast. + const userTurns = history.filter(isRealUserTurn); + + const loadOne = async ( + item: UserTurn, + idx: number, + ): Promise => { + // Early-exit so a quick close → reopen doesn't keep paying for + // disk reads from the previous effect. The outer cancellation + // guard alone would still suppress setState, but the I/O would + // have already completed. + if (cancelled) return null; + const { promptId } = item; + if (!promptId) return null; + try { + const diff = await fileHistoryService.getTurnDiff(promptId); + if (cancelled) return null; + if (!diff || diff.files.length === 0) return null; + return { + turnIndex: idx + 1, + promptPreview: previewOfUserItem(item), + diff, + } satisfies TurnDiffEntry; + } catch { + return null; + } + }; + + // Process turns in fixed-size batches instead of an unbounded + // Promise.all over every turn. See TURN_CONCURRENCY for the rationale. + const loadAll = async (): Promise => { + const out: TurnDiffEntry[] = []; + for (let i = 0; i < userTurns.length; i += TURN_CONCURRENCY) { + if (cancelled) return out; + const slice = userTurns.slice(i, i + TURN_CONCURRENCY); + const batch = await Promise.all( + slice.map((item, j) => loadOne(item, i + j)), + ); + for (const entry of batch) { + if (entry) out.push(entry); + } + } + return out; + }; + + loadAll() + .then((entries) => { + if (cancelled) return; + // Most recent first — matches the mental model: hitting `/diff` + // is almost always "what just changed". + entries.reverse(); + setTurns(entries); + setLoading(false); + }) + .catch((err) => { + // Defense-in-depth: each inner promise already swallows its own + // errors, but a setState during unmount or a future refactor could + // still surface here. Log and unstick `loading` rather than letting + // the rejection propagate to the Node default-handler (which on + // Node 22+ terminates the process for unhandled rejections). + debugLogger.debug(`useTurnDiffs pipeline failed: ${err}`); + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [history, fileHistoryService, enabled]); + + return { turns, loading }; +} + +const PREVIEW_MAX = 60; + +function previewOfUserItem(item: UserTurn): string { + if (!item.text) return ''; + // Neutralize ANSI / OSC escapes so a prompt containing pasted terminal + // output (or a hostile OSC 8 hyperlink) cannot reach the terminal raw + // via the source-tab label. `HistoryItemDisplay` already applies the + // same defense to the chat surface. + const safe = escapeAnsiCtrlCodes(item.text); + const oneLine = safe.replace(/\s+/g, ' ').trim(); + if (oneLine.length <= PREVIEW_MAX) return oneLine; + return `${oneLine.slice(0, PREVIEW_MAX - 1)}…`; +} diff --git a/packages/cli/src/ui/utils/historyMapping.ts b/packages/cli/src/ui/utils/historyMapping.ts index 4c7d9875e4..f0d9c12e8d 100644 --- a/packages/cli/src/ui/utils/historyMapping.ts +++ b/packages/cli/src/ui/utils/historyMapping.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { HistoryItem } from '../types.js'; +import type { HistoryItem, HistoryItemUser } from '../types.js'; import type { Content } from '@google/genai'; import { STARTUP_CONTEXT_MODEL_ACK } from '@qwen-code/qwen-code-core'; import { isSlashCommand } from './commandUtils.js'; @@ -14,8 +14,14 @@ import { isSlashCommand } from './commandUtils.js'; * sent to the model, as opposed to a slash-command invocation (`/help`, * `/stats`, …) which is stored with `type: 'user'` in the UI but never * reaches the API history or `turnParentUuids`. + * + * Typed as a type predicate so callers can drop their `as HistoryItemUser` + * casts — a regression that loosened either side of the narrowing would now + * be caught by tsc instead of silently bypassing it. */ -export function isRealUserTurn(item: HistoryItem): boolean { +export function isRealUserTurn( + item: HistoryItem, +): item is HistoryItem & HistoryItemUser { if (item.type !== 'user' || !item.text) return false; return !isSlashCommand(item.text) && !item.text.startsWith('?'); } diff --git a/packages/cli/src/ui/utils/textUtils.test.ts b/packages/cli/src/ui/utils/textUtils.test.ts index 2c30df32b4..63167176f1 100644 --- a/packages/cli/src/ui/utils/textUtils.test.ts +++ b/packages/cli/src/ui/utils/textUtils.test.ts @@ -11,6 +11,7 @@ import type { } from '@qwen-code/qwen-code-core'; import { escapeAnsiCtrlCodes, + sanitizeFilenameForDisplay, sanitizeSensitiveText, sliceTextByVisualHeight, } from './textUtils.js'; @@ -224,6 +225,58 @@ describe('textUtils', () => { }); }); + describe('sanitizeFilenameForDisplay', () => { + it('passes clean filenames through unchanged', () => { + expect(sanitizeFilenameForDisplay('src/foo.ts')).toBe('src/foo.ts'); + expect(sanitizeFilenameForDisplay('packages/cli/src/index.ts')).toBe( + 'packages/cli/src/index.ts', + ); + expect(sanitizeFilenameForDisplay('文件.txt')).toBe('文件.txt'); + expect(sanitizeFilenameForDisplay('')).toBe(''); + }); + + it('escapes C0 control bytes that bypass escapeAnsiCtrlCodes', () => { + // Bare \n / \r / NUL / BEL / BS slip past the ANSI regex but would + // still inject layout breaks or terminal effects in ``. + expect(sanitizeFilenameForDisplay('a\nb')).toBe('a\\nb'); + expect(sanitizeFilenameForDisplay('a\rb')).toBe('a\\rb'); + expect(sanitizeFilenameForDisplay('a\tb')).toBe('a\\tb'); + expect(sanitizeFilenameForDisplay('a\bb')).toBe('a\\bb'); + expect(sanitizeFilenameForDisplay('a\fb')).toBe('a\\fb'); + expect(sanitizeFilenameForDisplay('a\x00b')).toBe('a\\u0000b'); + expect(sanitizeFilenameForDisplay('a\x07b')).toBe('a\\u0007b'); + }); + + it('escapes DEL (0x7F) and C1 control bytes (0x80–0x9F)', () => { + expect(sanitizeFilenameForDisplay('a\x7fb')).toBe('a\\u007fb'); + expect(sanitizeFilenameForDisplay('a\x80b')).toBe('a\\u0080b'); + expect(sanitizeFilenameForDisplay('a\x9fb')).toBe('a\\u009fb'); + }); + + it('strips multi-byte ANSI CSI sequences', () => { + // SGR color/reset and cursor movement should not survive to the + // terminal — `escapeAnsiCtrlCodes` neutralizes the ESC byte, then + // the control-char pass cleans up any leftover bare C0/C1 bytes. + const ansi = '\x1b[31mred\x1b[0m'; + const out = sanitizeFilenameForDisplay(ansi); + expect(out.includes('\x1b')).toBe(false); + expect(out).toContain('red'); + }); + + it('handles a path crafted with mixed C0 controls + ANSI', () => { + const crafted = `evil\x1b[2K\npath\x00.txt`; + const out = sanitizeFilenameForDisplay(crafted); + // No raw C0 / DEL bytes remain in the output. + for (let i = 0; i < out.length; i++) { + const code = out.charCodeAt(i); + expect(code < 0x20 || code === 0x7f).toBe(false); + } + expect(out).toContain('evil'); + expect(out).toContain('path'); + expect(out).toContain('.txt'); + }); + }); + describe('sanitizeSensitiveText', () => { it('should return text unchanged if no sensitive patterns', () => { const text = 'Hello, this is a normal prompt'; diff --git a/packages/cli/src/ui/utils/textUtils.ts b/packages/cli/src/ui/utils/textUtils.ts index 453a6404bf..be1fdbbba3 100644 --- a/packages/cli/src/ui/utils/textUtils.ts +++ b/packages/cli/src/ui/utils/textUtils.ts @@ -390,3 +390,50 @@ export function sanitizeSensitiveText( return result; } + +// Match standalone C0 controls (incl. TAB/CR/LF/BEL/BS), DEL, and C1 controls. +// `escapeAnsiCtrlCodes` only neutralizes multi-byte ANSI sequences; raw single +// bytes like `\n`, `\r`, BEL, BS slip past it and can still break layouts or +// inject terminal effects when rendered as part of a git-supplied filename. +// eslint-disable-next-line no-control-regex +const FILENAME_CONTROL_CHARS_REGEX = /[\x00-\x1f\x7f-\x9f]/g; + +function escapeFilenameControlChar(ch: string): string { + switch (ch) { + case '\b': + return '\\b'; + case '\t': + return '\\t'; + case '\n': + return '\\n'; + case '\f': + return '\\f'; + case '\r': + return '\\r'; + default: { + // DEL (0x7F) and C1 controls (0x80-0x9F) are returned as raw bytes by + // JSON.stringify, which is exactly what we are trying to keep out of + // rendered output. Hand-roll the \uXXXX escape so every matched code + // point becomes printable. + const code = ch.charCodeAt(0); + return `\\u${code.toString(16).padStart(4, '0')}`; + } + } +} + +/** + * Make a git-supplied filename safe to drop into a TUI text node or a + * stdout / log line. Strips both multi-byte ANSI sequences (via + * `escapeAnsiCtrlCodes`) and bare control bytes that git happily round-trips + * through `-z` paths but which would otherwise inject color resets, cursor + * moves, BEL, or layout-breaking newlines into the rendered output. + * + * Use this anywhere a path from `fetchGitDiff`, `fetchGitDiffHunks`, or a + * file-history backup is rendered to the user. + */ +export function sanitizeFilenameForDisplay(name: string): string { + return escapeAnsiCtrlCodes(name).replace( + FILENAME_CONTROL_CHARS_REGEX, + escapeFilenameControlChar, + ); +} diff --git a/packages/core/src/services/fileHistoryService.test.ts b/packages/core/src/services/fileHistoryService.test.ts index ff154dbad2..60a8744245 100644 --- a/packages/core/src/services/fileHistoryService.test.ts +++ b/packages/core/src/services/fileHistoryService.test.ts @@ -14,7 +14,7 @@ import { readFile, } from 'node:fs/promises'; import { existsSync } from 'node:fs'; -import { join } from 'node:path'; +import { basename, join } from 'node:path'; import { tmpdir } from 'node:os'; const mockStorageDir = vi.hoisted(() => vi.fn()); @@ -25,6 +25,7 @@ vi.mock('../config/storage.js', () => ({ vi.mock('../utils/debugLogger.js', () => ({ createDebugLogger: () => ({ debug: vi.fn(), + warn: vi.fn(), error: vi.fn(), }), })); @@ -641,4 +642,321 @@ describe('FileHistoryService', () => { expect(stats).toBeUndefined(); }); }); + + describe('getTurnDiff', () => { + it('returns undefined when disabled', async () => { + const disabled = new FileHistoryService('s', false, projectDir); + expect(await disabled.getTurnDiff('p1')).toBeUndefined(); + }); + + it('returns undefined when the prompt has no snapshot', async () => { + expect(await service.getTurnDiff('missing')).toBeUndefined(); + }); + + it('diffs a turn against the next snapshot', async () => { + const file = join(projectDir, 'a.txt'); + await writeFile(file, 'line1\nline2\nline3\n'); + + // Turn 1 begins — captures pre-edit state — then the tool would + // modify the file. We mirror that order: makeSnapshot → trackEdit + // → mutate. This is the same sequence `client.ts` follows on + // every UserQuery turn. + await service.makeSnapshot('p1'); + await service.trackEdit(file); + await writeFile(file, 'line1\nLINE2_EDITED\nline3\n'); + + // Turn 2 begins — this snapshot becomes the "after" for turn 1. + await service.makeSnapshot('p2'); + + const turn1 = await service.getTurnDiff('p1'); + expect(turn1).toBeDefined(); + expect(turn1!.files).toHaveLength(1); + // filePath is repo-relative (matches Current source convention). + expect(turn1!.files[0].filePath).toBe(basename(file)); + expect(turn1!.files[0].linesAdded).toBe(1); + expect(turn1!.files[0].linesRemoved).toBe(1); + expect(turn1!.files[0].isNewFile).toBe(false); + expect(turn1!.files[0].isDeleted).toBe(false); + expect(turn1!.stats.filesChanged).toBe(1); + }); + + it('compares the latest turn against the live worktree', async () => { + const file = join(projectDir, 'b.txt'); + await writeFile(file, 'before'); + + await service.makeSnapshot('p1'); + await service.trackEdit(file); + await writeFile(file, 'after-edit-1\nafter-edit-2'); + + const turn = await service.getTurnDiff('p1'); + expect(turn).toBeDefined(); + expect(turn!.files).toHaveLength(1); + // 2 added lines (or 1 add + content change depending on diff alg) + expect( + turn!.files[0].linesAdded + turn!.files[0].linesRemoved, + ).toBeGreaterThan(0); + }); + + it('flags newly created files', async () => { + const file = join(projectDir, 'new.txt'); + + // Pre-existing snapshot with no tracked files. + await service.makeSnapshot('p1'); + // Now the tool creates the file mid-turn 1. trackEdit captures + // the pre-state (file does not exist). + await service.trackEdit(file); + await writeFile(file, 'fresh content\n'); + await service.makeSnapshot('p2'); + + const turn1 = await service.getTurnDiff('p1'); + expect(turn1).toBeDefined(); + const entry = turn1!.files.find((f) => f.filePath === basename(file)); + expect(entry).toBeDefined(); + expect(entry!.isNewFile).toBe(true); + expect(entry!.isDeleted).toBe(false); + expect(entry!.linesAdded).toBeGreaterThan(0); + }); + + it('skips files with no change between snapshots', async () => { + const file = join(projectDir, 'untouched.txt'); + await writeFile(file, 'stable\n'); + + await service.makeSnapshot('p1'); + await service.trackEdit(file); + // No actual modification before next snapshot. + await service.makeSnapshot('p2'); + + const turn1 = await service.getTurnDiff('p1'); + expect(turn1).toBeDefined(); + // The file got tracked but content is identical — should not appear + // in the per-turn diff. + expect(turn1!.files).toHaveLength(0); + expect(turn1!.stats.filesChanged).toBe(0); + }); + + // Regression for the silent-empty-string bug: a backup that records a + // real backupFileName but is unreadable on disk used to be coerced to + // '', producing a fake "every line added" diff. Now we drop the row + // entirely so the dialog doesn't lie about phantom changes. + it('skips files whose backup file is missing on disk', async () => { + const file = join(projectDir, 'lostbackup.txt'); + await writeFile(file, 'before'); + + await service.makeSnapshot('p1'); + await service.trackEdit(file); + await writeFile(file, 'after'); + await service.makeSnapshot('p2'); + + // Wipe the backup directory between makeSnapshot('p2') and the diff + // read. The snapshot records still point at the deleted file paths. + await rm(join(storageDir, 'file-history'), { + recursive: true, + force: true, + }); + + const turn1 = await service.getTurnDiff('p1'); + expect(turn1).toBeDefined(); + expect(turn1!.files).toHaveLength(0); + }); + + // Regression for the unbounded structuredPatch allocation: a single + // huge file in history could blow up TUI memory when /diff opens. + // Oversized rows now skip hunk construction but still surface in the + // file list with best-effort line-count stats. + it('detects files deleted during a turn', async () => { + const file = join(projectDir, 'doomed.txt'); + await writeFile(file, 'line a\nline b\n'); + + await service.makeSnapshot('p1'); + await service.trackEdit(file); + // Simulate the tool deleting the file mid-turn. + await rm(file); + await service.makeSnapshot('p2'); + + const turn1 = await service.getTurnDiff('p1'); + expect(turn1).toBeDefined(); + const entry = turn1!.files.find((f) => f.filePath === basename(file)); + expect(entry).toBeDefined(); + expect(entry!.isDeleted).toBe(true); + expect(entry!.isNewFile).toBe(false); + expect(entry!.linesRemoved).toBeGreaterThan(0); + }); + + it('flags binary content with isBinary and skips hunk generation', async () => { + const file = join(projectDir, 'image.bin'); + // PNG-ish header — NUL bytes within the sniff window trip the + // looksBinary heuristic. + await writeFile(file, '\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR'); + + await service.makeSnapshot('p1'); + await service.trackEdit(file); + // Append more binary bytes so before !== after. + await writeFile( + file, + '\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00', + ); + await service.makeSnapshot('p2'); + + const turn1 = await service.getTurnDiff('p1'); + expect(turn1).toBeDefined(); + const entry = turn1!.files.find((f) => f.filePath === basename(file)); + expect(entry).toBeDefined(); + expect(entry!.isBinary).toBe(true); + expect(entry!.hunks).toEqual([]); + }); + + // Files that target's snapshot didn't capture (e.g. they were first + // tracked in a later turn) must not show up in target's diff — + // otherwise we'd attribute a newer turn's edits to an earlier one. + it('does not attribute later-tracked files to earlier turns', async () => { + const fileA = join(projectDir, 'a.txt'); + const fileB = join(projectDir, 'b.txt'); + await writeFile(fileA, 'A1'); + + // Turn 1 only edits file A. + await service.makeSnapshot('p1'); + await service.trackEdit(fileA); + await writeFile(fileA, 'A2'); + + // Turn 2 begins. makeSnapshot captures A's new state. File B does + // not exist yet and isn't tracked. + await service.makeSnapshot('p2'); + + // Turn 2 creates file B for the first time. + await service.trackEdit(fileB); + await writeFile(fileB, 'B1'); + + // Turn 3 begins. Now B is in trackedFiles → snapshot[2] captures it. + await service.makeSnapshot('p3'); + + // Turn 1's diff must reference only A, never B. + const turn1 = await service.getTurnDiff('p1'); + expect(turn1).toBeDefined(); + const paths = turn1!.files.map((f) => f.filePath); + expect(paths).toContain(basename(fileA)); + expect(paths).not.toContain(basename(fileB)); + }); + + // Regression for the live-worktree read-failure collapse: if a file + // becomes unreadable in the worktree (EACCES, EBUSY, …) we used to + // treat it as deleted and synthesize a phantom delete hunk. Now we + // drop the row so the dialog never lies about removals that didn't + // actually happen. + it('does not synthesize a delete hunk when the live worktree read fails', async () => { + const file = join(projectDir, 'flaky.txt'); + await writeFile(file, 'still here\n'); + + await service.makeSnapshot('p1'); + await service.trackEdit(file); + await writeFile(file, 'changed\n'); + + // Replace the file with a directory so readFile rejects with EISDIR + // (a non-ENOENT failure that previously masqueraded as deletion). + await rm(file); + await mkdir(file); + + const turn1 = await service.getTurnDiff('p1'); + expect(turn1).toBeDefined(); + const entry = turn1!.files.find((f) => f.filePath === basename(file)); + // Row dropped because the live endpoint is unreadable, not because + // the file is gone. + expect(entry).toBeUndefined(); + }); + + it('flags oversized files instead of allocating large hunks', async () => { + const file = join(projectDir, 'big.txt'); + // 1.5 MB > MAX_DIFF_SIZE_BYTES (1 MB) + const big = 'x'.repeat(1_500_000); + await writeFile(file, big); + + await service.makeSnapshot('p1'); + await service.trackEdit(file); + // Append a small amount so before !== after but both endpoints are + // still oversized. + await writeFile(file, big + '\nappended\n'); + await service.makeSnapshot('p2'); + + const turn1 = await service.getTurnDiff('p1'); + expect(turn1).toBeDefined(); + const entry = turn1!.files.find((f) => f.filePath === basename(file)); + expect(entry).toBeDefined(); + expect(entry!.oversized).toBe(true); + expect(entry!.hunks).toEqual([]); + // Pre-read size guard bails before allocating, so we cannot compute + // a line-count delta. Stats are 0/0; the row's purpose is to signal + // the omission, not to estimate changes. + expect(entry!.linesAdded).toBe(0); + expect(entry!.linesRemoved).toBe(0); + }); + + // Regression for the live-worktree branch of the OOM guard: the + // previous oversized test compares two backups (both endpoints take + // the backup branch), so it never exercised `readPathWithSizeGuard` + // on the live worktree. This case has a single snapshot, so `after` + // is read from the live file — verifying `stat()` + open/fstat there. + it('flags oversized in the live-worktree branch (latest-turn endpoint)', async () => { + const file = join(projectDir, 'live-big.txt'); + await writeFile(file, 'tiny seed\n'); + + // Single snapshot: turn 1 has no successor, so its `after` + // endpoint is the live worktree, not a backup. + await service.makeSnapshot('p1'); + await service.trackEdit(file); + // Inflate past MAX_DIFF_SIZE_BYTES so the worktree-side guard + // trips during getTurnDiff. + await writeFile(file, 'x'.repeat(1_500_000)); + + const turn1 = await service.getTurnDiff('p1'); + expect(turn1).toBeDefined(); + const entry = turn1!.files.find((f) => f.filePath === basename(file)); + expect(entry).toBeDefined(); + expect(entry!.oversized).toBe(true); + expect(entry!.hunks).toEqual([]); + expect(entry!.linesAdded).toBe(0); + expect(entry!.linesRemoved).toBe(0); + // Worktree exists at read time → not flagged as a deletion. + expect(entry!.isDeleted).toBe(false); + }); + + // Mixed-size endpoint: only the `after` endpoint trips the cap. The + // discriminated union must still narrow `.exists` correctly when the + // two sides return different `kind`s. + it('handles mixed-size endpoints (small before, oversized after)', async () => { + const file = join(projectDir, 'mixed-big.txt'); + await writeFile(file, 'tiny seed\n'); + + await service.makeSnapshot('p1'); + await service.trackEdit(file); + // Grow past cap *before* snapshot p2 captures it as a backup. + await writeFile(file, 'x'.repeat(1_500_000)); + await service.makeSnapshot('p2'); + + const turn1 = await service.getTurnDiff('p1'); + expect(turn1).toBeDefined(); + const entry = turn1!.files.find((f) => f.filePath === basename(file)); + expect(entry).toBeDefined(); + expect(entry!.oversized).toBe(true); + // Before existed (snapshot has tiny content), so it's neither new + // nor a deletion even though after is oversized. + expect(entry!.isNewFile).toBe(false); + expect(entry!.isDeleted).toBe(false); + }); + + // filesOmitted should be 0 in the happy-path cases and reflected on + // every TurnDiff (regression: a forgetten field default would let + // the dialog's truncation indicator stay silent under cap pressure). + it('reports stats.filesOmitted === 0 when below the per-turn cap', async () => { + const file = join(projectDir, 'omit-baseline.txt'); + await writeFile(file, 'a\n'); + + await service.makeSnapshot('p1'); + await service.trackEdit(file); + await writeFile(file, 'a\nb\n'); + await service.makeSnapshot('p2'); + + const turn1 = await service.getTurnDiff('p1'); + expect(turn1).toBeDefined(); + expect(turn1!.stats.filesOmitted).toBe(0); + }); + }); }); diff --git a/packages/core/src/services/fileHistoryService.ts b/packages/core/src/services/fileHistoryService.ts index ecd57d27ac..16cbb7860d 100644 --- a/packages/core/src/services/fileHistoryService.ts +++ b/packages/core/src/services/fileHistoryService.ts @@ -10,14 +10,16 @@ import { chmod, copyFile, mkdir, + open, readFile, stat, unlink, } from 'node:fs/promises'; import { dirname, isAbsolute, join, relative, sep } from 'node:path'; -import { diffLines } from 'diff'; +import { diffLines, structuredPatch, type Hunk } from 'diff'; import { Storage } from '../config/storage.js'; import { createDebugLogger } from '../utils/debugLogger.js'; +import { MAX_DIFF_SIZE_BYTES } from '../utils/gitDiff.js'; const debugLogger = createDebugLogger('FILE_HISTORY'); @@ -58,8 +60,54 @@ export interface RewindResult { filesFailed: string[]; } +export interface TurnFileDiff { + filePath: string; + hunks: Hunk[]; + isNewFile: boolean; + isDeleted: boolean; + linesAdded: number; + linesRemoved: number; + /** True when the before/after content exceeded `MAX_DIFF_SIZE_BYTES` and + * hunk generation was skipped to keep dialog memory bounded. The stats + * remain a best-effort line-count delta. */ + oversized: boolean; + /** True when either endpoint's content contains NUL bytes (the standard + * binary sniff). Hunks are empty in that case — rendering them as text + * would corrupt the terminal or freeze the renderer. */ + isBinary: boolean; +} + +export interface TurnDiff { + promptId: string; + timestamp: Date; + files: TurnFileDiff[]; + stats: { + filesChanged: number; + linesAdded: number; + linesRemoved: number; + /** Upper bound on candidate files dropped because the turn touched + * more than `MAX_TURN_DIFF_FILES`. It is intentionally counted at + * the candidate layer (pre-diff) rather than the diff layer (post- + * filter for unchanged), so a turn editing 600 files with cap 500 + * reports `filesOmitted = 100` regardless of how many of the + * processed 500 turn out to have no actual change. Some of the + * 100 may also have had no change — we can't know without paying + * the read the cap was specifically meant to avoid. Treat it as + * "up to N more files were not surfaced". */ + filesOmitted: number; + }; +} + const MAX_SNAPSHOTS = 100; const FILE_HISTORY_DIR = 'file-history'; +/** Per-turn read-fanout cap. Each candidate file may read up to two backups, + * so 500 files ≈ 1000 concurrent opens — safely under the typical 4096 fd + * ceiling and well below `ulimit -n` defaults on Linux/macOS. */ +const MAX_TURN_DIFF_FILES = 500; +/** How many bytes to scan for NUL when sniffing binary content. Matches + * git's heuristic and is enough to catch the common cases (PNG/JPEG/PDF + * headers, ELF/Mach-O magic) without re-scanning the entire file. */ +const BINARY_SNIFF_BYTES = 8 * 1024; function isENOENT(e: unknown): boolean { return ( @@ -272,6 +320,154 @@ async function computeDiffStatsForFile( return { filesChanged, insertions, deletions }; } +/** Discriminated-union outcome of an endpoint read. Adding an explicit + * `kind` to every variant lets the compiler enforce branch coverage and + * removes the manual `as` casts the previous shape forced on callers. */ +interface EndpointReadOk { + kind: 'ok'; + content: string; + exists: boolean; +} + +interface EndpointReadUnreadable { + kind: 'unreadable'; +} + +/** Sentinel returned when the underlying file is too large to read into memory + * safely. Caller treats the row as oversized without ever holding the bytes. */ +interface EndpointReadOversized { + kind: 'oversized'; + /** True when the path exists (only meaningful for the worktree branch — a + * backup record with a real `backupFileName` always implies the file existed + * at snapshot time). */ + exists: boolean; +} + +type EndpointRead = + | EndpointReadOk + | EndpointReadUnreadable + | EndpointReadOversized; + +/** + * Read one endpoint of a turn diff (either a snapshot backup or, when the + * "after" endpoint is the live worktree, the file on disk). + * + * Returns `'unreadable'` when the underlying file exists but cannot be + * read (permission flip, EBUSY, decoding failure, etc.). `getTurnDiff` + * skips rows for which either endpoint is unreadable, so the dialog + * never fabricates phantom hunks against an empty string we never + * actually had. ENOENT is treated as a genuine absence — for the live + * worktree that means the file was deleted; for a backup with a real + * `backupFileName` it means the snapshot is corrupt and is reported + * as unreadable. + * + * Returns `{ kind: 'oversized' }` when the on-disk file is larger than + * `MAX_DIFF_SIZE_BYTES`. We `stat()` first and bail before allocating — + * otherwise a 2 GB `write_file` blob would be slurped into the Node heap + * just for the downstream `Buffer.byteLength` check to reject it, OOM-ing + * the dialog before the cap can fire. The dialog renders these rows as + * "(oversized — diff omitted)" without ever holding the bytes. + */ +async function readEndpointContent( + backup: FileHistoryBackup | undefined, + worktreePath: string | undefined, + sessionId: string, +): Promise { + if (worktreePath !== undefined) { + return readPathWithSizeGuard(worktreePath, 'worktree'); + } + if (!backup) return { kind: 'ok', content: '', exists: false }; + if (backup.backupFileName === null) { + return { kind: 'ok', content: '', exists: false }; + } + const backupPath = resolveBackupPath(backup.backupFileName, sessionId); + return readPathWithSizeGuard(backupPath, 'backup'); +} + +/** + * Stat-then-read against a single open file descriptor. Using `open()` + + * `fstat()` + `readFile({ fd })` closes the TOCTOU window that a separate + * `stat()` + `readFile()` pair would leave open: a concurrent `write_file` + * appending to the same path between the two syscalls would otherwise grow + * past `MAX_DIFF_SIZE_BYTES` and slip the OOM guard. + * + * Operating on the same inode also means the size we check matches the + * bytes we read — Node's `readFile(fd)` reads the underlying file from + * offset 0 regardless of how the path entry shifts in the meantime. + */ +async function readPathWithSizeGuard( + path: string, + kind: 'worktree' | 'backup', +): Promise { + let fh: Awaited>; + try { + fh = await open(path, 'r'); + } catch (e: unknown) { + if (isENOENT(e)) { + // Worktree: genuine deletion → absence. Backup: snapshot recorded a + // file we can no longer find → unreadable (lying about an empty + // before-state would synthesize a fake every-line-added hunk). + if (kind === 'worktree') { + return { kind: 'ok', content: '', exists: false }; + } + return { kind: 'unreadable' }; + } + debugLogger.error(`FileHistory: ${kind} open failed for ${path}: ${e}`); + return { kind: 'unreadable' }; + } + try { + const st = await fh.stat(); + if (st.size > MAX_DIFF_SIZE_BYTES) { + return { kind: 'oversized', exists: true }; + } + try { + const text = await fh.readFile('utf-8'); + return { kind: 'ok', content: text, exists: true }; + } catch (e: unknown) { + debugLogger.error(`FileHistory: ${kind} read failed for ${path}: ${e}`); + return { kind: 'unreadable' }; + } + } finally { + await fh.close().catch(() => undefined); + } +} + +/** + * Binary sniff. Scans both the head and the tail of the string so a long + * text prefix can't bury a binary payload past the head window — git's + * heuristic only looks at the head, which is sufficient when invoked on + * file open but not when an attacker / faulty generator can craft mixed + * inputs. Content past MAX_DIFF_SIZE_BYTES is already short-circuited as + * `oversized` upstream, so this stays cheap. + */ +function looksBinary(content: string): boolean { + const len = content.length; + if (len === 0) return false; + const headEnd = Math.min(len, BINARY_SNIFF_BYTES); + for (let i = 0; i < headEnd; i++) { + if (content.charCodeAt(i) === 0) return true; + } + if (len > BINARY_SNIFF_BYTES) { + const tailStart = Math.max(headEnd, len - BINARY_SNIFF_BYTES); + for (let i = tailStart; i < len; i++) { + if (content.charCodeAt(i) === 0) return true; + } + } + return false; +} + +function countLines(text: string): number { + if (text === '') return 0; + let count = 1; + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) === 10 /* \n */) count++; + } + // A trailing newline already accounted for the final empty token; don't + // double-count it as an extra line. + if (text.charCodeAt(text.length - 1) === 10) count--; + return count; +} + /** * Tracks file edits made through the assistant's `edit` and `write_file` * tools so `/rewind` can roll the workspace back to the state at a chosen @@ -565,13 +761,312 @@ export class FileHistoryService { return { filesChanged, insertions, deletions }; } + /** + * Compute the file-level diff produced *during* the turn identified by + * `promptId`. The turn's snapshot captures the workspace state at the + * start of that turn (before any of its tool-driven edits), so: + * - "before" = this snapshot's backups + * - "after" = the next snapshot's backups, or the live worktree if this + * is the most recent turn + * + * Only files whose backup pointer differs between the two endpoints (or + * whose content differs in the most-recent-turn case) are returned. + * Files that the snapshotter failed to capture are silently skipped: + * we can't produce a meaningful per-turn diff without a known "before", + * and surfacing a wrong hunk is worse than hiding the row. + */ + async getTurnDiff(promptId: string): Promise { + if (!this.enabled) return undefined; + + // `findSnapshotIndex` mirrors `findSnapshot`'s last-occurrence-wins + // tie-break so `/rewind` and `/diff` agree on which snapshot a reused + // promptId resolves to. In normal sessions promptIds are unique per + // submission, so this is defensive. + const targetIdx = this.findSnapshotIndex(promptId); + if (targetIdx < 0) return undefined; + + const target = this.state.snapshots[targetIdx]!; + const nextSnapshot = + targetIdx + 1 < this.state.snapshots.length + ? this.state.snapshots[targetIdx + 1] + : undefined; + + // Candidates are restricted to files that target's snapshot actually + // tracked. A file that first shows up in the *next* snapshot's backups + // (because trackEdit added it during turn N+1) didn't change during + // turn N — including it would either fast-path to no-op or, worse, + // produce a phantom "new file" hunk attributed to the wrong turn. + // `trackEdit` mutates `mostRecent` in place, so by the time we read + // target.trackedFileBackups it already contains every file touched + // during target's turn, including newly created or deleted ones. + // Sort so the cap below is deterministic. `Object.keys` order is + // spec-defined as insertion order for string keys, but sorting makes + // the kept-vs-dropped split reproducible across runs that may insert + // in different orders (e.g. a session resumed from disk vs. one that + // grew live), which matters for both reviewer reproducibility and + // for the truncation log line below. + const candidatePaths = Object.keys(target.trackedFileBackups).sort((a, b) => + a.localeCompare(b), + ); + + // Cap concurrent file reads. Each candidate reads up to two backups, + // so a 250-file turn would issue ~500 simultaneous opens — enough to + // hit ulimit -n on common CI configurations. The cap is bounded by + // the same constant the git path uses (MAX_FILES_FOR_DETAILS = 500 + // files total), with two reads each → 1000 open()s worst case, still + // comfortably below the typical 4096 fd ceiling. + const filesOmitted = Math.max( + 0, + candidatePaths.length - MAX_TURN_DIFF_FILES, + ); + if (filesOmitted > 0) { + debugLogger.warn( + `FileHistory: getTurnDiff truncating ${filesOmitted} files for prompt ${promptId} (cap: ${MAX_TURN_DIFF_FILES})`, + ); + } + const cappedPaths = candidatePaths.slice(0, MAX_TURN_DIFF_FILES); + const results = await Promise.all( + cappedPaths.map((trackingPath) => + this.computeTurnFileDiff(trackingPath, target, nextSnapshot), + ), + ); + + const files: TurnFileDiff[] = []; + let totalAdded = 0; + let totalRemoved = 0; + for (const r of results) { + if (!r) continue; + files.push(r); + totalAdded += r.linesAdded; + totalRemoved += r.linesRemoved; + } + files.sort((a, b) => a.filePath.localeCompare(b.filePath)); + + return { + promptId, + timestamp: target.timestamp, + files, + stats: { + filesChanged: files.length, + linesAdded: totalAdded, + linesRemoved: totalRemoved, + filesOmitted, + }, + }; + } + + private async computeTurnFileDiff( + trackingPath: string, + before: FileHistorySnapshot, + after: FileHistorySnapshot | undefined, + ): Promise { + try { + return await this.computeTurnFileDiffUnsafe(trackingPath, before, after); + } catch (e) { + // Per-file isolation: a structuredPatch crash, a transient read + // error, anything thrown from a single candidate must not poison + // the whole turn's Promise.all and silently erase every row. + // Log + drop the row, surface the rest. + debugLogger.error( + `FileHistory: computeTurnFileDiff failed for ${trackingPath}: ${e}`, + ); + return null; + } + } + + private async computeTurnFileDiffUnsafe( + trackingPath: string, + before: FileHistorySnapshot, + after: FileHistorySnapshot | undefined, + ): Promise { + // `trackingPath` is repo-relative (or absolute for files outside cwd) + // per `maybeShortenFilePath`; matches the convention `fetchGitDiff` uses + // for the Current source so the dialog renders both consistently. + // `absoluteFilePath` is used only for live-worktree I/O below. + const absoluteFilePath = this.maybeExpandFilePath(trackingPath); + + const beforeBackup = before.trackedFileBackups[trackingPath]; + if (beforeBackup?.failed) return null; + + let afterBackup: FileHistoryBackup | undefined; + let afterFromWorktree = false; + if (after) { + afterBackup = after.trackedFileBackups[trackingPath]; + if (afterBackup?.failed) return null; + } else { + afterFromWorktree = true; + } + + // Fast path: when both endpoints point at the exact same backup file, + // we know without reading anything that the file did not change during + // this turn. `makeSnapshot` reuses unchanged backups verbatim, so this + // skips the bulk of files in any long-running session. + // + // Guard: require `beforeBackup !== undefined`. With the current + // candidatePaths construction (keys(target.trackedFileBackups) only) + // both endpoints can never both be undefined for a real input, but + // future refactors that broaden the candidate set should not let an + // `undefined === undefined` match silently swallow a newly created + // file as "unchanged". + if ( + !afterFromWorktree && + beforeBackup !== undefined && + beforeBackup.backupFileName === afterBackup?.backupFileName && + beforeBackup.version === afterBackup?.version + ) { + return null; + } + + const beforeRead = await readEndpointContent( + beforeBackup, + undefined, + this.sessionId, + ); + // A non-null backup name that fails to read means we cannot produce a + // trustworthy "before" content — fabricating an empty string would + // present every line as a fresh addition. Skip the row instead, but + // log so a missing/permission-flipped backup leaves a trace. + if (beforeRead.kind === 'unreadable') { + debugLogger.warn( + `FileHistory: skipping turn diff for ${trackingPath}: before backup unreadable`, + ); + return null; + } + + const afterRead = afterFromWorktree + ? await readEndpointContent(undefined, absoluteFilePath, this.sessionId) + : await readEndpointContent(afterBackup, undefined, this.sessionId); + if (afterRead.kind === 'unreadable') { + debugLogger.warn( + `FileHistory: skipping turn diff for ${trackingPath}: after ${afterFromWorktree ? 'worktree' : 'backup'} unreadable`, + ); + return null; + } + + // Pre-read size guard tripped — either endpoint sits above the cap. + // Bail before any content work so a 2 GB blob never lands in the heap; + // we cannot compute precise +N/-M stats without reading, but the row + // still shows up with the oversized badge and is treated correctly by + // the dialog (Enter is gated, hint surfaces "use git diff"). The + // discriminated union (.kind) lets tsc narrow `.exists` access without + // any manual casts. + if (beforeRead.kind === 'oversized' || afterRead.kind === 'oversized') { + const beforeExists = beforeRead.exists; + const afterExists = afterRead.exists; + return { + filePath: trackingPath, + hunks: [], + isNewFile: !beforeExists && afterExists, + isDeleted: beforeExists && !afterExists, + linesAdded: 0, + linesRemoved: 0, + oversized: true, + isBinary: false, + }; + } + + // Both endpoints now narrow to EndpointReadOk. + const { content: beforeContent, exists: beforeExists } = beforeRead; + const { content: afterContent, exists: afterExists } = afterRead; + + if (beforeContent === afterContent && beforeExists === afterExists) { + return null; + } + + // Binary sniff: scanning either endpoint catches changes against a + // text→binary or binary→text flip. Feeding NUL-laced strings into + // `structuredPatch` and then through `DiffRenderer` can produce + // garbage output or hang the terminal, so surface them as a binary + // row with no hunks (mirrors the git path's binary handling). + const isBinary = looksBinary(beforeContent) || looksBinary(afterContent); + if (isBinary) { + return { + filePath: trackingPath, + hunks: [], + isNewFile: !beforeExists && afterExists, + isDeleted: beforeExists && !afterExists, + linesAdded: 0, + linesRemoved: 0, + oversized: false, + isBinary: true, + }; + } + + // Cap the patch input to keep dialog memory bounded: a single 50MB + // generated file should not allocate hundreds of MB of hunk strings + // when `/diff` opens. Report the file with stats but no hunks; the + // dialog renders an "(oversized — diff omitted)" tag for these. + const oversized = + Buffer.byteLength(beforeContent, 'utf8') > MAX_DIFF_SIZE_BYTES || + Buffer.byteLength(afterContent, 'utf8') > MAX_DIFF_SIZE_BYTES; + + if (oversized) { + // Coarse line-count delta so the file row still shows a meaningful + // `+N -M` summary. Counting newlines is O(n) but allocates nothing + // extra past the strings we already hold. + const beforeLines = beforeExists ? countLines(beforeContent) : 0; + const afterLines = afterExists ? countLines(afterContent) : 0; + return { + filePath: trackingPath, + hunks: [], + isNewFile: !beforeExists && afterExists, + isDeleted: beforeExists && !afterExists, + linesAdded: Math.max(0, afterLines - beforeLines), + linesRemoved: Math.max(0, beforeLines - afterLines), + oversized: true, + isBinary: false, + }; + } + + const patch = structuredPatch( + trackingPath, + trackingPath, + beforeContent, + afterContent, + '', + '', + { context: 3 }, + ); + + let linesAdded = 0; + let linesRemoved = 0; + for (const h of patch.hunks) { + for (const line of h.lines) { + if (line.startsWith('+')) linesAdded++; + else if (line.startsWith('-')) linesRemoved++; + } + } + + if (patch.hunks.length === 0 && linesAdded === 0 && linesRemoved === 0) { + return null; + } + + return { + filePath: trackingPath, + hunks: patch.hunks, + isNewFile: !beforeExists && afterExists, + isDeleted: beforeExists && !afterExists, + linesAdded, + linesRemoved, + oversized: false, + isBinary: false, + }; + } + private findSnapshot(promptId: string): FileHistorySnapshot | undefined { + return this.state.snapshots[this.findSnapshotIndex(promptId)]; + } + + /** Same matching rule as `findSnapshot` (last occurrence wins) but + * returns the slot index so callers that need the neighbour snapshot + * (e.g. `getTurnDiff`) don't have to re-scan. Returns -1 on miss. */ + private findSnapshotIndex(promptId: string): number { for (let i = this.state.snapshots.length - 1; i >= 0; i--) { if (this.state.snapshots[i]!.promptId === promptId) { - return this.state.snapshots[i]; + return i; } } - return undefined; + return -1; } private async applySnapshot(