From 965ca5a07a9d2aa1b8fffb5000ae82981e59f94a Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Mon, 11 May 2026 21:46:57 +0800 Subject: [PATCH 01/31] feat(rewind): add file restoration support to /rewind command (#3697) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously /rewind only truncated conversation history — files modified by the assistant remained on disk. This adds a file-copy-based backup system (ported from claude-code's fileHistory) so users can optionally roll back file changes when rewinding. Core changes: - New FileHistoryService with snapshot/backup/restore lifecycle - trackEdit() called before each file write in edit and write-file tools - makeSnapshot() at each user turn boundary in client.ts - Three-phase RewindSelector UI: pick turn → choose restore option → execute - RestoreOption type: 'both' | 'conversation' | 'code' | 'cancel' Closes #3697 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) --- packages/cli/src/i18n/locales/en.js | 7 + packages/cli/src/i18n/locales/zh.js | 7 + packages/cli/src/ui/AppContainer.tsx | 165 +++-- .../cli/src/ui/components/DialogManager.tsx | 2 + .../cli/src/ui/components/RewindSelector.tsx | 240 ++++++- .../cli/src/ui/contexts/UIActionsContext.tsx | 3 +- packages/cli/src/ui/hooks/useGeminiStream.ts | 2 +- packages/cli/src/ui/types.ts | 1 + packages/core/src/config/config.ts | 21 + packages/core/src/core/client.ts | 10 + packages/core/src/index.ts | 1 + .../core/src/services/fileHistoryService.ts | 620 ++++++++++++++++++ packages/core/src/tools/edit.ts | 4 + packages/core/src/tools/write-file.ts | 2 + 14 files changed, 1010 insertions(+), 75 deletions(-) create mode 100644 packages/core/src/services/fileHistoryService.ts diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index ecf68654964..e8d02f91c00 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -132,6 +132,13 @@ export default { 'Rename the current conversation. --auto lets the fast model pick a title.', 'Rewind conversation to a previous turn': 'Rewind conversation to a previous turn', + 'Restore code and conversation': 'Restore code and conversation', + 'Restore conversation only': 'Restore conversation only', + 'Restore code only': 'Restore code only', + 'Never mind': 'Never mind', + 'Computing file changes...': 'Computing file changes...', + 'Restored {{count}} file(s).': 'Restored {{count}} file(s).', + 'Failed to restore files: {{error}}': 'Failed to restore files: {{error}}', 'change the theme': 'change the theme', 'Select Theme': 'Select Theme', Preview: 'Preview', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index fd430b8c539..516f11b00f4 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -126,6 +126,13 @@ export default { 'Rename the current conversation. --auto lets the fast model pick a title.': '重命名当前对话。--auto 会让快速模型自动生成标题。', 'Rewind conversation to a previous turn': '将对话回退到之前的某一轮', + 'Restore code and conversation': '恢复代码和对话', + 'Restore conversation only': '仅恢复对话', + 'Restore code only': '仅恢复代码', + 'Never mind': '算了', + 'Computing file changes...': '正在计算文件变更...', + 'Restored {{count}} file(s).': '已恢复 {{count}} 个文件。', + 'Failed to restore files: {{error}}': '恢复文件失败:{{error}}', 'change the theme': '更改主题', 'Select Theme': '选择主题', Preview: '预览', diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 99c011be583..093fa3aa4c2 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -25,9 +25,11 @@ import { import { ConfigContext } from './contexts/ConfigContext.js'; import { type HistoryItem, + type HistoryItemUser, ToolCallStatus, type HistoryItemWithoutId, } from './types.js'; +import type { RestoreOption } from './components/RewindSelector.js'; import { MessageType, StreamingState } from './types.js'; import { type EditorType, @@ -1975,75 +1977,120 @@ export const AppContainer = (props: AppContainerProps) => { }, []); const handleRewindConfirm = useCallback( - (userItem: HistoryItem) => { - const geminiClient = config.getGeminiClient(); - if (!geminiClient) return; - - // 1. Compute values from current history BEFORE truncation - const originalHistory = historyManager.history; - const originalLength = originalHistory.length; - - let targetTurnIndex = 0; - for (const h of originalHistory) { - if (h.id === userItem.id) break; - if (isRealUserTurn(h)) targetTurnIndex++; + async (userItem: HistoryItem, option: RestoreOption) => { + // Close the selector immediately to prevent double submission + // while the async file restore is in progress. + setIsRewindSelectorOpen(false); + + // Restore code (files on disk) — do this before conversation + // truncation so the promptId is still accessible, but defer info + // messages until after truncation so they aren't immediately removed. + let fileRestoreMessage: string | undefined; + let fileRestoreError: string | undefined; + if (option === 'code' || option === 'both') { + const promptId = (userItem as HistoryItemUser).promptId; + if (promptId) { + try { + const filesChanged = await config + .getFileHistoryService() + .rewind(promptId); + if (filesChanged.length > 0) { + fileRestoreMessage = t('Restored {{count}} file(s).', { count: String(filesChanged.length) }); + } + } catch (error) { + fileRestoreError = t('Failed to restore files: {{error}}', { error: error instanceof Error ? error.message : String(error) }); + } + } } - // 2. Compute API truncation point - const apiHistory = geminiClient.getHistory(); - const apiTruncateIndex = computeApiTruncationIndex( - originalHistory, - userItem.id, - apiHistory, - ); + // Restore conversation + if (option === 'conversation' || option === 'both') { + const geminiClient = config.getGeminiClient(); + const canTruncate = (() => { + if (!geminiClient) return false; + + const apiHistory = geminiClient.getHistory(); + const apiTruncateIndex = computeApiTruncationIndex( + historyManager.history, + userItem.id, + apiHistory, + ); - // Abort if the target turn is unreachable (e.g., absorbed by compression) - if (apiTruncateIndex < 0) { - historyManager.addItem( - { - type: 'error', - text: 'Cannot rewind to a turn that was compressed. Try a more recent turn.', - }, - Date.now(), - ); - setIsRewindSelectorOpen(false); - return; - } + if (apiTruncateIndex < 0) { + historyManager.addItem( + { + type: 'error', + text: 'Cannot rewind to a turn that was compressed. Try a more recent turn.', + }, + Date.now(), + ); + return false; + } - // 3. Truncate API history to the target point. - // Do NOT strip thought parts — reasoning models (e.g. DeepSeek) require - // reasoning_content continuity across all turns in the conversation. - geminiClient.truncateHistory(apiTruncateIndex); + // 1. Compute values from current history BEFORE truncation + const originalHistory = historyManager.history; + const originalLength = originalHistory.length; - // 4. Truncate UI history (keep everything before the target item) - const truncatedUi = originalHistory.filter((h) => h.id < userItem.id); - historyManager.loadHistory(truncatedUi); + let targetTurnIndex = 0; + for (const h of originalHistory) { + if (h.id === userItem.id) break; + if (isRealUserTurn(h)) targetTurnIndex++; + } - // 5. Re-render the terminal - refreshStatic(); + // 2. Truncate API history to the target point. + // Do NOT strip thought parts — reasoning models (e.g. DeepSeek) require + // reasoning_content continuity across all turns in the conversation. + geminiClient.truncateHistory(apiTruncateIndex); - // 6. Pre-populate input with the original user text - if (userItem.type === 'user' && userItem.text) { - buffer.setText(userItem.text); - } + // 3. Truncate UI history (keep everything before the target item) + const truncatedUi = originalHistory.filter((h) => h.id < userItem.id); + historyManager.loadHistory(truncatedUi); - // 7. Add info message - historyManager.addItem( - { - type: 'info', - text: 'Conversation rewound. Edit your prompt and press Enter to continue.', - }, - Date.now(), - ); + // 4. Re-render the terminal + refreshStatic(); - // 8. Record the rewind event — re-roots the parentUuid chain so - // rewound messages end up on a dead branch during resume. - config.getChatRecordingService()?.rewindRecording(targetTurnIndex, { - truncatedCount: originalLength - truncatedUi.length, - }); + // 5. Pre-populate input with the original user text + if (userItem.type === 'user' && userItem.text) { + buffer.setText(userItem.text); + } + + // 6. Add info message + historyManager.addItem( + { + type: 'info', + text: 'Conversation rewound. Edit your prompt and press Enter to continue.', + }, + Date.now(), + ); + + // 7. Record the rewind event — re-roots the parentUuid chain so + // rewound messages end up on a dead branch during resume. + config.getChatRecordingService()?.rewindRecording(targetTurnIndex, { + truncatedCount: originalLength - truncatedUi.length, + }); + return true; + })(); + + // If conversation truncation failed but we're in 'both' mode, + // still fall through to show file restore messages below. + if (!canTruncate && option === 'conversation') return; + } + + // Show file restore result after conversation truncation so the + // message isn't immediately removed by loadHistory. + if (fileRestoreMessage) { + historyManager.addItem( + { type: 'info', text: fileRestoreMessage }, + Date.now(), + ); + } + if (fileRestoreError) { + historyManager.addItem( + { type: 'error', text: fileRestoreError }, + Date.now(), + ); + } - // 9. Close the selector - setIsRewindSelectorOpen(false); }, [config, historyManager, refreshStatic, buffer], ); diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 22944dc0237..2593396cf9c 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -448,6 +448,8 @@ export const DialogManager = ({ history={uiState.history} onRewind={uiActions.handleRewindConfirm} onCancel={uiActions.closeRewindSelector} + fileCheckpointingEnabled={config.getFileCheckpointingEnabled()} + fileHistoryService={config.getFileHistoryService()} /> ); } diff --git a/packages/cli/src/ui/components/RewindSelector.tsx b/packages/cli/src/ui/components/RewindSelector.tsx index 5da006cadbe..fa72daf3542 100644 --- a/packages/cli/src/ui/components/RewindSelector.tsx +++ b/packages/cli/src/ui/components/RewindSelector.tsx @@ -4,27 +4,29 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useMemo, useCallback } from 'react'; +import { useState, useMemo, useCallback, useEffect } from 'react'; import { Box, Text } from 'ink'; -import type { HistoryItem } from '../types.js'; +import type { HistoryItem, HistoryItemUser } from '../types.js'; import { theme } from '../semantic-colors.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { truncateText } from '../utils/sessionPickerUtils.js'; import { isRealUserTurn } from '../utils/historyMapping.js'; import { t } from '../../i18n/index.js'; +import type { FileHistoryService, DiffStats } from '@qwen-code/qwen-code-core'; + +export type RestoreOption = 'both' | 'conversation' | 'code' | 'cancel'; export interface RewindSelectorProps { history: HistoryItem[]; - onRewind: (userItem: HistoryItem) => void; + onRewind: (userItem: HistoryItem, option: RestoreOption) => void; onCancel: () => void; + fileCheckpointingEnabled: boolean; + fileHistoryService: FileHistoryService; } const MAX_VISIBLE_ITEMS = 7; -/** - * Extract user-type items from UI history for the rewind pick list. - */ function getUserTurns(history: HistoryItem[]): HistoryItem[] { return history.filter(isRealUserTurn); } @@ -91,26 +93,80 @@ function TurnItemView({ ); } +interface RestoreOptionItem { + key: RestoreOption; + label: string; + detail?: string; +} + +function getRestoreOptions( + diffStats: DiffStats | undefined, +): RestoreOptionItem[] { + const hasChanges = + diffStats && + diffStats.filesChanged && + diffStats.filesChanged.length > 0; + + const options: RestoreOptionItem[] = []; + + if (hasChanges) { + const fileCount = diffStats!.filesChanged!.length; + const detail = `(+${diffStats!.insertions} -${diffStats!.deletions} in ${fileCount} file${fileCount !== 1 ? 's' : ''})`; + options.push({ + key: 'both', + label: t('Restore code and conversation'), + detail, + }); + } + + options.push({ + key: 'conversation', + label: t('Restore conversation only'), + }); + + if (hasChanges) { + options.push({ + key: 'code', + label: t('Restore code only'), + }); + } + + options.push({ + key: 'cancel', + label: t('Never mind'), + }); + + return options; +} + /** - * Two-phase rewind selector: + * Multi-phase rewind selector: * 1. Pick list — choose which user turn to rewind to - * 2. Confirm — confirm the rewind action + * 2. Restore options — choose what to restore (when file checkpointing enabled) + * 3. Confirm — Y/N confirm (when file checkpointing disabled, legacy fallback) */ export function RewindSelector({ history, onRewind, onCancel, + fileCheckpointingEnabled, + fileHistoryService, }: RewindSelectorProps) { const { columns: width, rows: height } = useTerminalSize(); const userTurns = useMemo(() => getUserTurns(history), [history]); const [selectedIndex, setSelectedIndex] = useState(userTurns.length - 1); + // Legacy confirm (when file checkpointing is off) const [confirmItem, setConfirmItem] = useState(null); + // Restore option phase (when file checkpointing is on) + const [restoreItem, setRestoreItem] = useState(null); + const [restoreOptionIndex, setRestoreOptionIndex] = useState(0); + const [diffStats, setDiffStats] = useState(undefined); + const [loadingDiff, setLoadingDiff] = useState(false); const boxWidth = width - 4; const maxVisibleItems = Math.min(MAX_VISIBLE_ITEMS, userTurns.length); - // Centered scroll offset const scrollOffset = useMemo(() => { if (userTurns.length <= maxVisibleItems) return 0; const halfVisible = Math.floor(maxVisibleItems / 2); @@ -127,10 +183,48 @@ export function RewindSelector({ const showScrollUp = scrollOffset > 0; const showScrollDown = scrollOffset + maxVisibleItems < userTurns.length; + const restoreOptions = useMemo( + () => getRestoreOptions(diffStats), + [diffStats], + ); + + // Load diff stats when entering restore option phase + useEffect(() => { + if (!restoreItem || !fileCheckpointingEnabled) return; + const promptId = (restoreItem as HistoryItemUser).promptId; + if (!promptId) { + setDiffStats(undefined); + setLoadingDiff(false); + return; + } + let cancelled = false; + setLoadingDiff(true); + fileHistoryService + .getDiffStats(promptId) + .then((stats) => { + if (!cancelled) { + setDiffStats(stats); + setRestoreOptionIndex(0); + setLoadingDiff(false); + } + }) + .catch(() => { + if (!cancelled) { + setDiffStats(undefined); + setRestoreOptionIndex(0); + setLoadingDiff(false); + } + }); + return () => { + cancelled = true; + }; + }, [restoreItem, fileCheckpointingEnabled, fileHistoryService]); + + // Legacy confirm handler const handleConfirmSelect = useCallback( (confirmed: boolean) => { if (confirmed && confirmItem) { - onRewind(confirmItem); + onRewind(confirmItem, 'conversation'); } else { setConfirmItem(null); } @@ -151,7 +245,12 @@ export function RewindSelector({ if (name === 'return') { const selected = userTurns[selectedIndex]; if (selected) { - setConfirmItem(selected); + if (fileCheckpointingEnabled) { + setRestoreItem(selected); + setRestoreOptionIndex(0); + } else { + setConfirmItem(selected); + } } return; } @@ -166,10 +265,49 @@ export function RewindSelector({ return; } }, - { isActive: confirmItem === null }, + { isActive: confirmItem === null && restoreItem === null }, ); - // Confirm key handler + // Restore option key handler + useKeypress( + (key) => { + const { name, ctrl } = key; + + if (name === 'escape' || (ctrl && name === 'c')) { + setRestoreItem(null); + setDiffStats(undefined); + return; + } + + if (name === 'return') { + const option = restoreOptions[restoreOptionIndex]; + if (option) { + if (option.key === 'cancel') { + setRestoreItem(null); + setDiffStats(undefined); + } else { + onRewind(restoreItem!, option.key); + } + } + return; + } + + if (name === 'up' || name === 'k') { + setRestoreOptionIndex((prev) => Math.max(0, prev - 1)); + return; + } + + if (name === 'down' || name === 'j') { + setRestoreOptionIndex((prev) => + Math.min(restoreOptions.length - 1, prev + 1), + ); + return; + } + }, + { isActive: restoreItem !== null && !loadingDiff }, + ); + + // Legacy confirm key handler useKeypress( (key) => { const { name, ctrl, sequence } = key; @@ -211,7 +349,81 @@ export function RewindSelector({ ); } - // Confirm phase + // Restore option phase + if (restoreItem) { + const promptPreview = truncateText( + restoreItem.text || '(empty)', + boxWidth - 10, + ); + return ( + + + + + {t('Rewind Conversation')} + + + + {'─'.repeat(boxWidth - 2)} + + + + {t('Rewind to: ')} + + {promptPreview} + + + {loadingDiff ? ( + + {t('Computing file changes...')} + + ) : ( + + {restoreOptions.map((option, idx) => { + const isSelected = idx === restoreOptionIndex; + const prefix = isSelected ? '› ' : ' '; + return ( + + + {prefix} + {option.label} + + {option.detail && ( + + {' '} + {option.detail} + + )} + + ); + })} + + )} + + + {'─'.repeat(boxWidth - 2)} + + + + {t('↑↓ to navigate · Enter to select · Esc to go back')} + + + + + ); + } + + // Legacy confirm phase (when file checkpointing is off) if (confirmItem) { const promptPreview = truncateText( confirmItem.text || '(empty)', diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index bb214137711..c225ad4a9dc 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -13,6 +13,7 @@ import { type EditorType, type ApprovalMode } from '@qwen-code/qwen-code-core'; import { type SettingScope } from '../../config/settings.js'; import type { AuthController } from '../auth/useAuth.js'; import type { HistoryItem } from '../types.js'; +import type { RestoreOption } from '../components/RewindSelector.js'; import { type ArenaDialogType } from '../hooks/useArenaCommand.js'; export type HelpTab = 'general' | 'commands' | 'custom-commands'; @@ -97,7 +98,7 @@ export interface UIActions { // Rewind selector openRewindSelector: () => void; closeRewindSelector: () => void; - handleRewindConfirm: (userItem: HistoryItem) => void; + handleRewindConfirm: (userItem: HistoryItem, option: RestoreOption) => void; } export const UIActionsContext = createContext(null); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 8b203b1de49..bd9f208c61d 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -796,7 +796,7 @@ export const useGeminiStream = ( // a duplicate `> …` line. Preprocessing (@/slash/shell) still runs. if (submitType !== SendMessageType.Cron) { const insertedId = addItem( - { type: MessageType.USER, text: trimmedQuery }, + { type: MessageType.USER, text: trimmedQuery, promptId: prompt_id }, userMessageTimestamp, ); // Capture id+text so the cancel handler can verify identity, diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index db2e3b9c83c..b29d114eb3e 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -95,6 +95,7 @@ export interface HistoryItemBase { export type HistoryItemUser = HistoryItemBase & { type: 'user'; text: string; + promptId?: string; }; export type HistoryItemGemini = HistoryItemBase & { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index b439abf66b6..d173f32c31b 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -37,6 +37,7 @@ import { getRuntimeContentGenerator } from '../agents/runtime/agent-context.js'; // Services import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; +import { FileHistoryService } from '../services/fileHistoryService.js'; import { type FileSystemService, StandardFileSystemService, @@ -461,6 +462,7 @@ export interface ConfigParameters { enableFuzzySearch?: boolean; }; checkpointing?: boolean; + fileCheckpointingEnabled?: boolean; proxy?: string; cwd: string; fileDiscoveryService?: FileDiscoveryService; @@ -721,6 +723,8 @@ export class Config { private sessionService: SessionService | undefined = undefined; private chatRecordingService: ChatRecordingService | undefined = undefined; private readonly checkpointing: boolean; + private readonly fileCheckpointingEnabled: boolean; + private fileHistoryService: FileHistoryService | undefined; private readonly proxy: string | undefined; private readonly cwd: string; private readonly explicitIncludeDirectories: string[]; @@ -877,6 +881,8 @@ export class Config { enableFuzzySearch: params.fileFiltering?.enableFuzzySearch ?? true, }; this.checkpointing = params.checkpointing ?? false; + this.fileCheckpointingEnabled = + params.fileCheckpointingEnabled ?? !params.sdkMode; this.proxy = params.proxy; this.cwd = params.cwd ?? process.cwd(); this.fileDiscoveryService = params.fileDiscoveryService ?? null; @@ -2279,6 +2285,21 @@ export class Config { return this.checkpointing; } + getFileCheckpointingEnabled(): boolean { + return this.fileCheckpointingEnabled; + } + + getFileHistoryService(): FileHistoryService { + if (!this.fileHistoryService) { + this.fileHistoryService = new FileHistoryService( + this.sessionId, + this.fileCheckpointingEnabled, + this.cwd, + ); + } + return this.fileHistoryService; + } + getProxy(): string | undefined { return normalizeProxyUrl(this.proxy); } diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 6d6d841576c..d7bddebe917 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -1065,6 +1065,16 @@ export class GeminiClient { this.sessionTurnCount++; + if (messageType === SendMessageType.UserQuery) { + try { + await this.config + .getFileHistoryService() + .makeSnapshot(prompt_id); + } catch (e) { + debugLogger.error(`FileHistory: makeSnapshot failed: ${e}`); + } + } + if ( this.config.getMaxSessionTurns() > 0 && this.sessionTurnCount > this.config.getMaxSessionTurns() diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8a67f88155a..67fe89425e0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -134,6 +134,7 @@ export type { ToolSearchTool, ToolSearchParams } from './tools/tool-search.js'; export * from './services/chatRecordingService.js'; export * from './services/cronScheduler.js'; export * from './services/fileDiscoveryService.js'; +export * from './services/fileHistoryService.js'; export * from './services/fileReadCache.js'; export * from './services/fileSystemService.js'; export * from './services/gitService.js'; diff --git a/packages/core/src/services/fileHistoryService.ts b/packages/core/src/services/fileHistoryService.ts new file mode 100644 index 00000000000..1553de0dbf0 --- /dev/null +++ b/packages/core/src/services/fileHistoryService.ts @@ -0,0 +1,620 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; +import type { Stats } from 'node:fs'; +import { + chmod, + copyFile, + mkdir, + readFile, + stat, + unlink, +} from 'node:fs/promises'; +import { dirname, isAbsolute, join, relative } from 'node:path'; +import { diffLines } from 'diff'; +import { Storage } from '../config/storage.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('FILE_HISTORY'); + +type BackupFileName = string | null; + +export interface FileHistoryBackup { + backupFileName: BackupFileName; + version: number; + backupTime: Date; +} + +export interface FileHistorySnapshot { + promptId: string; + trackedFileBackups: Record; + timestamp: Date; +} + +export interface FileHistoryState { + snapshots: FileHistorySnapshot[]; + trackedFiles: Set; + snapshotSequence: number; +} + +export interface DiffStats { + filesChanged?: string[]; + insertions: number; + deletions: number; +} + +const MAX_SNAPSHOTS = 100; +const FILE_HISTORY_DIR = 'file-history'; + +function isENOENT(e: unknown): boolean { + return ( + typeof e === 'object' && + e !== null && + 'code' in e && + (e as { code: string }).code === 'ENOENT' + ); +} + +async function pathExists(filePath: string): Promise { + try { + await stat(filePath); + return true; + } catch { + return false; + } +} + +async function readFileOrNull(filePath: string): Promise { + try { + return await readFile(filePath, 'utf-8'); + } catch { + return null; + } +} + +function getBackupFileName(filePath: string, version: number): string { + const fileNameHash = createHash('sha256') + .update(filePath) + .digest('hex') + .slice(0, 16); + return `${fileNameHash}@v${version}`; +} + +function resolveBackupPath( + backupFileName: string, + sessionId: string, +): string { + return join( + Storage.getGlobalQwenDir(), + FILE_HISTORY_DIR, + sessionId, + backupFileName, + ); +} + +async function createBackup( + filePath: string | null, + version: number, + sessionId: string, +): Promise { + if (filePath === null) { + return { backupFileName: null, version, backupTime: new Date() }; + } + + const backupFileName = getBackupFileName(filePath, version); + const backupPath = resolveBackupPath(backupFileName, sessionId); + + let srcStats: Stats; + try { + srcStats = await stat(filePath); + } catch (e: unknown) { + if (isENOENT(e)) { + return { backupFileName: null, version, backupTime: new Date() }; + } + throw e; + } + + try { + await copyFile(filePath, backupPath); + } catch (e: unknown) { + if (!isENOENT(e)) throw e; + await mkdir(dirname(backupPath), { recursive: true }); + await copyFile(filePath, backupPath); + } + + await chmod(backupPath, srcStats.mode); + + return { backupFileName, version, backupTime: new Date() }; +} + +async function restoreBackup( + filePath: string, + backupFileName: string, + sessionId: string, +): Promise { + const backupPath = resolveBackupPath(backupFileName, sessionId); + + let backupStats: Stats; + try { + backupStats = await stat(backupPath); + } catch (e: unknown) { + if (isENOENT(e)) { + debugLogger.error( + `FileHistory: Backup file not found: ${backupPath}`, + ); + return; + } + throw e; + } + + try { + await copyFile(backupPath, filePath); + } catch (e: unknown) { + if (!isENOENT(e)) throw e; + await mkdir(dirname(filePath), { recursive: true }); + await copyFile(backupPath, filePath); + } + + await chmod(filePath, backupStats.mode); +} + +async function checkOriginFileChanged( + originalFile: string, + backupFileName: string, + sessionId: string, + originalStatsHint?: Stats, +): Promise { + const backupPath = resolveBackupPath(backupFileName, sessionId); + + let originalStats: Stats | null = originalStatsHint ?? null; + if (!originalStats) { + try { + originalStats = await stat(originalFile); + } catch (e: unknown) { + if (!isENOENT(e)) return true; + } + } + + let backupStats: Stats | null = null; + try { + backupStats = await stat(backupPath); + } catch (e: unknown) { + if (!isENOENT(e)) return true; + } + + if ((originalStats === null) !== (backupStats === null)) return true; + if (originalStats === null || backupStats === null) return false; + + if ( + originalStats.mode !== backupStats.mode || + originalStats.size !== backupStats.size + ) { + return true; + } + + if (originalStats.mtimeMs < backupStats.mtimeMs) return false; + + try { + const [originalContent, backupContent] = await Promise.all([ + readFile(originalFile, 'utf-8'), + readFile(backupPath, 'utf-8'), + ]); + return originalContent !== backupContent; + } catch { + return true; + } +} + +async function computeDiffStatsForFile( + originalFile: string, + backupFileName: string | undefined, + sessionId: string, +): Promise { + const filesChanged: string[] = []; + let insertions = 0; + let deletions = 0; + + try { + const backupPath = backupFileName + ? resolveBackupPath(backupFileName, sessionId) + : undefined; + + const [originalContent, backupContent] = await Promise.all([ + readFileOrNull(originalFile), + backupPath ? readFileOrNull(backupPath) : null, + ]); + + if (originalContent === null && backupContent === null) { + return { filesChanged, insertions, deletions }; + } + + filesChanged.push(originalFile); + + const changes = diffLines(originalContent ?? '', backupContent ?? ''); + for (const c of changes) { + if (c.added) insertions += c.count || 0; + if (c.removed) deletions += c.count || 0; + } + } catch (error) { + debugLogger.error(`FileHistory: Error generating diffStats: ${error}`); + } + + return { filesChanged, insertions, deletions }; +} + +export class FileHistoryService { + private state: FileHistoryState = { + snapshots: [], + trackedFiles: new Set(), + snapshotSequence: 0, + }; + + private currentPromptId = ''; + private readonly sessionId: string; + private readonly enabled: boolean; + private readonly cwd: string; + + constructor( + sessionId: string, + enabled: boolean, + cwd: string, + ) { + this.sessionId = sessionId; + this.enabled = enabled; + this.cwd = cwd; + } + + isEnabled(): boolean { + return this.enabled; + } + + setCurrentPromptId(id: string): void { + this.currentPromptId = id; + } + + getCurrentPromptId(): string { + return this.currentPromptId; + } + + getSnapshots(): FileHistorySnapshot[] { + return this.state.snapshots; + } + + restoreFromSnapshots(snapshots: FileHistorySnapshot[]): void { + const trackedFiles = new Set(); + const migrated: FileHistorySnapshot[] = []; + for (const snapshot of snapshots) { + const trackedFileBackups: Record = {}; + for (const [p, backup] of Object.entries(snapshot.trackedFileBackups)) { + const trackingPath = this.maybeShortenFilePath(p); + trackedFiles.add(trackingPath); + trackedFileBackups[trackingPath] = backup; + } + migrated.push({ ...snapshot, trackedFileBackups }); + } + this.state = { + snapshots: migrated, + trackedFiles, + snapshotSequence: migrated.length, + }; + } + + async trackEdit(filePath: string): Promise { + if (!this.enabled) return; + + const trackingPath = this.maybeShortenFilePath(filePath); + const mostRecent = this.state.snapshots.at(-1); + + if (!mostRecent) { + debugLogger.error('FileHistory: Missing most recent snapshot'); + return; + } + + if (mostRecent.trackedFileBackups[trackingPath]) { + return; + } + + let backup: FileHistoryBackup; + try { + backup = await createBackup(filePath, 1, this.sessionId); + } catch (error) { + debugLogger.error(`FileHistory: trackEdit failed: ${error}`); + return; + } + + // Re-check after async backup — concurrent trackEdit for the same path + // may have won the race. The losing call's backup file becomes orphaned + // on disk but data state stays correct. + if (!mostRecent.trackedFileBackups[trackingPath]) { + mostRecent.trackedFileBackups[trackingPath] = backup; + this.state.trackedFiles.add(trackingPath); + } + + debugLogger.debug( + `FileHistory: Tracked file modification for ${filePath}`, + ); + } + + async makeSnapshot(promptId: string): Promise { + if (!this.enabled) return; + + this.currentPromptId = promptId; + + const trackedFileBackups: Record = {}; + const mostRecent = this.state.snapshots.at(-1); + + if (mostRecent) { + await Promise.all( + Array.from(this.state.trackedFiles, async (trackingPath) => { + try { + const filePath = this.maybeExpandFilePath(trackingPath); + const latestBackup = + mostRecent.trackedFileBackups[trackingPath]; + const nextVersion = latestBackup ? latestBackup.version + 1 : 1; + + let fileStats: Stats | undefined; + try { + fileStats = await stat(filePath); + } catch (e: unknown) { + if (!isENOENT(e)) throw e; + } + + if (!fileStats) { + trackedFileBackups[trackingPath] = { + backupFileName: null, + version: nextVersion, + backupTime: new Date(), + }; + return; + } + + if ( + latestBackup && + latestBackup.backupFileName !== null && + !(await checkOriginFileChanged( + filePath, + latestBackup.backupFileName, + this.sessionId, + fileStats, + )) + ) { + trackedFileBackups[trackingPath] = latestBackup; + return; + } + + trackedFileBackups[trackingPath] = await createBackup( + filePath, + nextVersion, + this.sessionId, + ); + } catch (error) { + debugLogger.error( + `FileHistory: Failed to backup file ${trackingPath}: ${error}`, + ); + } + }), + ); + } + + for (const trackingPath of this.state.trackedFiles) { + if (trackingPath in trackedFileBackups) continue; + const inherited = mostRecent?.trackedFileBackups[trackingPath]; + if (inherited) trackedFileBackups[trackingPath] = inherited; + } + + const newSnapshot: FileHistorySnapshot = { + promptId, + trackedFileBackups, + timestamp: new Date(), + }; + + this.state.snapshots.push(newSnapshot); + if (this.state.snapshots.length > MAX_SNAPSHOTS) { + this.state.snapshots = this.state.snapshots.slice(-MAX_SNAPSHOTS); + } + this.state.snapshotSequence++; + + debugLogger.debug( + `FileHistory: Added snapshot for ${promptId}, tracking ${this.state.trackedFiles.size} files`, + ); + } + + async rewind(promptId: string): Promise { + if (!this.enabled) return []; + + const targetSnapshot = this.findSnapshot(promptId); + if (!targetSnapshot) { + throw new Error('The selected snapshot was not found'); + } + + debugLogger.debug( + `FileHistory: Rewinding to snapshot for ${promptId}`, + ); + const filesChanged = await this.applySnapshot(targetSnapshot); + debugLogger.debug( + `FileHistory: Finished rewinding to ${promptId}`, + ); + return filesChanged; + } + + canRestore(promptId: string): boolean { + if (!this.enabled) return false; + return this.state.snapshots.some((s) => s.promptId === promptId); + } + + async getDiffStats(promptId: string): Promise { + if (!this.enabled) return undefined; + + const targetSnapshot = this.findSnapshot(promptId); + if (!targetSnapshot) return undefined; + + const results = await Promise.all( + Array.from(this.state.trackedFiles, async (trackingPath) => { + try { + const filePath = this.maybeExpandFilePath(trackingPath); + const targetBackup = targetSnapshot.trackedFileBackups[trackingPath]; + + const backupFileName: BackupFileName | undefined = targetBackup + ? targetBackup.backupFileName + : this.getBackupFileNameFirstVersion(trackingPath); + + if (backupFileName === undefined) return null; + + const stats = await computeDiffStatsForFile( + filePath, + backupFileName === null ? undefined : backupFileName, + this.sessionId, + ); + if (stats?.insertions || stats?.deletions) { + return { filePath, stats }; + } + if (backupFileName === null && (await pathExists(filePath))) { + return { filePath, stats }; + } + return null; + } catch (error) { + debugLogger.error( + `FileHistory: Error computing diff stats: ${error}`, + ); + return null; + } + }), + ); + + const filesChanged: string[] = []; + let insertions = 0; + let deletions = 0; + for (const r of results) { + if (!r) continue; + filesChanged.push(r.filePath); + insertions += r.stats?.insertions || 0; + deletions += r.stats?.deletions || 0; + } + return { filesChanged, insertions, deletions }; + } + + async hasAnyChanges(promptId: string): Promise { + if (!this.enabled) return false; + + const targetSnapshot = this.findSnapshot(promptId); + if (!targetSnapshot) return false; + + for (const trackingPath of this.state.trackedFiles) { + try { + const filePath = this.maybeExpandFilePath(trackingPath); + const targetBackup = targetSnapshot.trackedFileBackups[trackingPath]; + const backupFileName: BackupFileName | undefined = targetBackup + ? targetBackup.backupFileName + : this.getBackupFileNameFirstVersion(trackingPath); + + if (backupFileName === undefined) continue; + if (backupFileName === null) { + if (await pathExists(filePath)) return true; + continue; + } + if ( + await checkOriginFileChanged( + filePath, + backupFileName, + this.sessionId, + ) + ) + return true; + } catch (error) { + debugLogger.error( + `FileHistory: Error checking changes: ${error}`, + ); + } + } + return false; + } + + private findSnapshot(promptId: string): FileHistorySnapshot | undefined { + return this.state.snapshots.findLast((s) => s.promptId === promptId); + } + + private async applySnapshot( + targetSnapshot: FileHistorySnapshot, + ): Promise { + const filesChanged: string[] = []; + for (const trackingPath of this.state.trackedFiles) { + try { + const filePath = this.maybeExpandFilePath(trackingPath); + const targetBackup = targetSnapshot.trackedFileBackups[trackingPath]; + + const backupFileName: BackupFileName | undefined = targetBackup + ? targetBackup.backupFileName + : this.getBackupFileNameFirstVersion(trackingPath); + + if (backupFileName === undefined) { + debugLogger.error( + 'FileHistory: Error finding the backup file to apply', + ); + continue; + } + + if (backupFileName === null) { + try { + await unlink(filePath); + debugLogger.debug(`FileHistory: Deleted ${filePath}`); + filesChanged.push(filePath); + } catch (e: unknown) { + if (!isENOENT(e)) throw e; + } + continue; + } + + if ( + await checkOriginFileChanged( + filePath, + backupFileName, + this.sessionId, + ) + ) { + await restoreBackup(filePath, backupFileName, this.sessionId); + debugLogger.debug( + `FileHistory: Restored ${filePath} from ${backupFileName}`, + ); + filesChanged.push(filePath); + } + } catch (error) { + debugLogger.error( + `FileHistory: Error restoring file ${trackingPath}: ${error}`, + ); + } + } + return filesChanged; + } + + private getBackupFileNameFirstVersion( + trackingPath: string, + ): BackupFileName | undefined { + for (const snapshot of this.state.snapshots) { + const backup = snapshot.trackedFileBackups[trackingPath]; + if (backup !== undefined && backup.version === 1) { + return backup.backupFileName; + } + } + return undefined; + } + + private maybeShortenFilePath(filePath: string): string { + if (!isAbsolute(filePath)) return filePath; + if ( + filePath.startsWith(this.cwd + '/') || + filePath === this.cwd + ) { + return relative(this.cwd, filePath); + } + return filePath; + } + + private maybeExpandFilePath(filePath: string): string { + if (isAbsolute(filePath)) return filePath; + return join(this.cwd, filePath); + } +} diff --git a/packages/core/src/tools/edit.ts b/packages/core/src/tools/edit.ts index 7ec717829e4..c8b89ed7247 100644 --- a/packages/core/src/tools/edit.ts +++ b/packages/core/src/tools/edit.ts @@ -539,6 +539,10 @@ class EditToolInvocation implements ToolInvocation { // edit. this.ensureParentDirectoriesExist(this.params.file_path); + await this.config + .getFileHistoryService() + .trackEdit(this.params.file_path); + // For new files, apply default file encoding setting // For existing files, preserve the original encoding (BOM and charset) if (editData.isNewFile) { diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts index 751c5372ef2..2d91db99765 100644 --- a/packages/core/src/tools/write-file.ts +++ b/packages/core/src/tools/write-file.ts @@ -430,6 +430,8 @@ class WriteFileToolInvocation extends BaseToolInvocation< fs.mkdirSync(dirName, { recursive: true }); } + await this.config.getFileHistoryService().trackEdit(file_path); + try { await this.config.getFileSystemService().writeTextFile({ path: file_path, From cd292776736dadacfa76cc3b25efcba0dc4ead4a Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Mon, 11 May 2026 22:09:17 +0800 Subject: [PATCH 02/31] fix(rewind): replace findLast with reverse loop for ES2022 compat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vscode-ide-companion targets ES2022 which lacks Array.findLast. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) --- .../core/src/services/fileHistoryService.ts | 58 ++++++------------- 1 file changed, 17 insertions(+), 41 deletions(-) diff --git a/packages/core/src/services/fileHistoryService.ts b/packages/core/src/services/fileHistoryService.ts index 1553de0dbf0..aac310e7e7c 100644 --- a/packages/core/src/services/fileHistoryService.ts +++ b/packages/core/src/services/fileHistoryService.ts @@ -84,10 +84,7 @@ function getBackupFileName(filePath: string, version: number): string { return `${fileNameHash}@v${version}`; } -function resolveBackupPath( - backupFileName: string, - sessionId: string, -): string { +function resolveBackupPath(backupFileName: string, sessionId: string): string { return join( Storage.getGlobalQwenDir(), FILE_HISTORY_DIR, @@ -143,9 +140,7 @@ async function restoreBackup( backupStats = await stat(backupPath); } catch (e: unknown) { if (isENOENT(e)) { - debugLogger.error( - `FileHistory: Backup file not found: ${backupPath}`, - ); + debugLogger.error(`FileHistory: Backup file not found: ${backupPath}`); return; } throw e; @@ -258,11 +253,7 @@ export class FileHistoryService { private readonly enabled: boolean; private readonly cwd: string; - constructor( - sessionId: string, - enabled: boolean, - cwd: string, - ) { + constructor(sessionId: string, enabled: boolean, cwd: string) { this.sessionId = sessionId; this.enabled = enabled; this.cwd = cwd; @@ -334,9 +325,7 @@ export class FileHistoryService { this.state.trackedFiles.add(trackingPath); } - debugLogger.debug( - `FileHistory: Tracked file modification for ${filePath}`, - ); + debugLogger.debug(`FileHistory: Tracked file modification for ${filePath}`); } async makeSnapshot(promptId: string): Promise { @@ -352,8 +341,7 @@ export class FileHistoryService { Array.from(this.state.trackedFiles, async (trackingPath) => { try { const filePath = this.maybeExpandFilePath(trackingPath); - const latestBackup = - mostRecent.trackedFileBackups[trackingPath]; + const latestBackup = mostRecent.trackedFileBackups[trackingPath]; const nextVersion = latestBackup ? latestBackup.version + 1 : 1; let fileStats: Stats | undefined; @@ -431,13 +419,9 @@ export class FileHistoryService { throw new Error('The selected snapshot was not found'); } - debugLogger.debug( - `FileHistory: Rewinding to snapshot for ${promptId}`, - ); + debugLogger.debug(`FileHistory: Rewinding to snapshot for ${promptId}`); const filesChanged = await this.applySnapshot(targetSnapshot); - debugLogger.debug( - `FileHistory: Finished rewinding to ${promptId}`, - ); + debugLogger.debug(`FileHistory: Finished rewinding to ${promptId}`); return filesChanged; } @@ -517,24 +501,23 @@ export class FileHistoryService { continue; } if ( - await checkOriginFileChanged( - filePath, - backupFileName, - this.sessionId, - ) + await checkOriginFileChanged(filePath, backupFileName, this.sessionId) ) return true; } catch (error) { - debugLogger.error( - `FileHistory: Error checking changes: ${error}`, - ); + debugLogger.error(`FileHistory: Error checking changes: ${error}`); } } return false; } private findSnapshot(promptId: string): FileHistorySnapshot | undefined { - return this.state.snapshots.findLast((s) => s.promptId === promptId); + for (let i = this.state.snapshots.length - 1; i >= 0; i--) { + if (this.state.snapshots[i]!.promptId === promptId) { + return this.state.snapshots[i]; + } + } + return undefined; } private async applySnapshot( @@ -569,11 +552,7 @@ export class FileHistoryService { } if ( - await checkOriginFileChanged( - filePath, - backupFileName, - this.sessionId, - ) + await checkOriginFileChanged(filePath, backupFileName, this.sessionId) ) { await restoreBackup(filePath, backupFileName, this.sessionId); debugLogger.debug( @@ -604,10 +583,7 @@ export class FileHistoryService { private maybeShortenFilePath(filePath: string): string { if (!isAbsolute(filePath)) return filePath; - if ( - filePath.startsWith(this.cwd + '/') || - filePath === this.cwd - ) { + if (filePath.startsWith(this.cwd + '/') || filePath === this.cwd) { return relative(this.cwd, filePath); } return filePath; From 1b7192ed2995aeec7426afbde213fabf3f02e116 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Mon, 11 May 2026 23:07:15 +0800 Subject: [PATCH 03/31] fix(rewind): add missing i18n translations and fix test expectation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add file restore i18n keys to all 8 locale files (zh-TW, ca, de, fr, ja, pt, ru were missing) - Update useGeminiStream test to expect promptId in user history item 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) --- packages/cli/src/i18n/locales/ca.js | 8 ++++++++ packages/cli/src/i18n/locales/de.js | 8 ++++++++ packages/cli/src/i18n/locales/fr.js | 8 ++++++++ packages/cli/src/i18n/locales/ja.js | 8 ++++++++ packages/cli/src/i18n/locales/pt.js | 8 ++++++++ packages/cli/src/i18n/locales/ru.js | 8 ++++++++ packages/cli/src/i18n/locales/zh-TW.js | 7 +++++++ packages/cli/src/ui/hooks/useGeminiStream.test.tsx | 1 + 8 files changed, 56 insertions(+) diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index f5168f8f272..d67bc10698d 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -131,6 +131,14 @@ export default { 'Canviar el nom de la conversa actual. --auto permet que el model ràpid triï un títol.', 'Rewind conversation to a previous turn': 'Rebobinar la conversa fins a un torn anterior', + 'Restore code and conversation': 'Restaura el codi i la conversa', + 'Restore conversation only': 'Restaura només la conversa', + 'Restore code only': 'Restaura només el codi', + 'Never mind': 'Tant és', + 'Computing file changes...': "S'estan calculant els canvis als fitxers...", + 'Restored {{count}} file(s).': "S'han restaurat {{count}} fitxer(s).", + 'Failed to restore files: {{error}}': + 'Error en restaurar els fitxers: {{error}}', 'change the theme': 'canviar el tema', 'Select Theme': 'Seleccionar tema', Preview: 'Previsualització', diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index c236d64938f..7adc7dddba0 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -110,6 +110,14 @@ export default { 'Die aktuelle Unterhaltung umbenennen. Mit --auto lässt du das schnelle Modell einen Titel wählen.', 'Rewind conversation to a previous turn': 'Die Unterhaltung auf einen früheren Gesprächsschritt zurücksetzen', + 'Restore code and conversation': 'Code und Unterhaltung wiederherstellen', + 'Restore conversation only': 'Nur Unterhaltung wiederherstellen', + 'Restore code only': 'Nur Code wiederherstellen', + 'Never mind': 'Egal', + 'Computing file changes...': 'Dateiänderungen werden berechnet...', + 'Restored {{count}} file(s).': '{{count}} Datei(en) wiederhergestellt.', + 'Failed to restore files: {{error}}': + 'Fehler beim Wiederherstellen der Dateien: {{error}}', 'change the theme': 'Design ändern', 'Select Theme': 'Design auswählen', Preview: 'Vorschau', diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index 28b532ac73e..b4afc349900 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -130,6 +130,14 @@ export default { 'Renommer la conversation en cours. --auto laisse le modèle rapide choisir un titre.', 'Rewind conversation to a previous turn': 'Revenir à un tour précédent de la conversation', + 'Restore code and conversation': 'Restaurer le code et la conversation', + 'Restore conversation only': 'Restaurer la conversation uniquement', + 'Restore code only': 'Restaurer le code uniquement', + 'Never mind': 'Annuler', + 'Computing file changes...': 'Calcul des modifications de fichiers...', + 'Restored {{count}} file(s).': '{{count}} fichier(s) restauré(s).', + 'Failed to restore files: {{error}}': + 'Échec de la restauration des fichiers : {{error}}', 'change the theme': 'changer le thème', 'Select Theme': 'Sélectionner un thème', Preview: 'Aperçu', diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 93a005b293e..fc23dd7429c 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -91,6 +91,14 @@ export default { 'Rename the current conversation. --auto lets the fast model pick a title.': '現在の会話の名前を変更する。--auto を使うと高速モデルがタイトルを決めます。', 'Rewind conversation to a previous turn': '会話を前のターンまで巻き戻す', + 'Restore code and conversation': 'コードと会話を復元', + 'Restore conversation only': '会話のみ復元', + 'Restore code only': 'コードのみ復元', + 'Never mind': 'やめる', + 'Computing file changes...': 'ファイルの変更を計算中...', + 'Restored {{count}} file(s).': '{{count}} 個のファイルを復元しました。', + 'Failed to restore files: {{error}}': + 'ファイルの復元に失敗しました:{{error}}', 'change the theme': 'テーマを変更', 'Select Theme': 'テーマを選択', Preview: 'プレビュー', diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index c7b3331741f..4e28e9be857 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -125,6 +125,14 @@ export default { 'Renomear a conversa atual. --auto permite que o modelo rápido escolha um título.', 'Rewind conversation to a previous turn': 'Voltar a conversa para um turno anterior', + 'Restore code and conversation': 'Restaurar código e conversa', + 'Restore conversation only': 'Restaurar apenas a conversa', + 'Restore code only': 'Restaurar apenas o código', + 'Never mind': 'Deixa pra lá', + 'Computing file changes...': 'Calculando alterações de arquivo...', + 'Restored {{count}} file(s).': '{{count}} arquivo(s) restaurado(s).', + 'Failed to restore files: {{error}}': + 'Falha ao restaurar arquivos: {{error}}', 'change the theme': 'alterar o tema', 'Select Theme': 'Selecionar Tema', Preview: 'Visualizar', diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index d4e4238c558..88c507af5e3 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -134,6 +134,14 @@ export default { 'Переименовать текущий разговор. --auto позволит быстрой модели выбрать заголовок.', 'Rewind conversation to a previous turn': 'Откатить разговор к предыдущему ходу', + 'Restore code and conversation': 'Восстановить код и беседу', + 'Restore conversation only': 'Восстановить только беседу', + 'Restore code only': 'Восстановить только код', + 'Never mind': 'Неважно', + 'Computing file changes...': 'Вычисление изменений файлов...', + 'Restored {{count}} file(s).': 'Восстановлено файлов: {{count}}.', + 'Failed to restore files: {{error}}': + 'Не удалось восстановить файлы: {{error}}', 'change the theme': 'Изменение темы', 'Select Theme': 'Выбор темы', Preview: 'Предпросмотр', diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index d28b53665a9..a75b330239a 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -113,6 +113,13 @@ export default { 'Rename the current conversation. --auto lets the fast model pick a title.': '重新命名目前的對話。--auto 會讓快速模型自動產生標題。', 'Rewind conversation to a previous turn': '將對話回退到先前的某一輪', + 'Restore code and conversation': '恢復程式碼和對話', + 'Restore conversation only': '僅恢復對話', + 'Restore code only': '僅恢復程式碼', + 'Never mind': '算了', + 'Computing file changes...': '正在計算檔案變更...', + 'Restored {{count}} file(s).': '已恢復 {{count}} 個檔案。', + 'Failed to restore files: {{error}}': '恢復檔案失敗:{{error}}', 'change the theme': '更改主題', 'Select Theme': '選擇主題', Preview: '預覽', diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 6230b9009b1..323b5f8acf8 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -3140,6 +3140,7 @@ describe('useGeminiStream', () => { { type: MessageType.USER, text: rawQuery, + promptId: expect.any(String), }, userMessageTimestamp, ); From 6de02c2026ea9e6ce24f32fc61fb30ae2adc2020 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Mon, 11 May 2026 23:26:59 +0800 Subject: [PATCH 04/31] fix(rewind): add getFileHistoryService mock to tool tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit edit.test.ts and write-file.test.ts mock configs lacked the new getFileHistoryService method, causing trackEdit calls to throw. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) --- packages/core/src/tools/edit.test.ts | 1 + packages/core/src/tools/write-file.test.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/core/src/tools/edit.test.ts b/packages/core/src/tools/edit.test.ts index 1d246e70b35..fd05b5d1a74 100644 --- a/packages/core/src/tools/edit.test.ts +++ b/packages/core/src/tools/edit.test.ts @@ -84,6 +84,7 @@ describe('EditTool', () => { getDefaultFileEncoding: vi.fn().mockReturnValue('utf-8'), getFileReadCache: () => fileReadCache, getFileReadCacheDisabled: vi.fn().mockReturnValue(false), + getFileHistoryService: () => ({ trackEdit: vi.fn() }), } as unknown as Config; // Reset mocks before each test diff --git a/packages/core/src/tools/write-file.test.ts b/packages/core/src/tools/write-file.test.ts index a5bc325c71a..f7d31f15aab 100644 --- a/packages/core/src/tools/write-file.test.ts +++ b/packages/core/src/tools/write-file.test.ts @@ -72,6 +72,7 @@ const mockConfigInternal = { getDefaultFileEncoding: () => 'utf-8', getFileReadCache: () => fileReadCache, getFileReadCacheDisabled: () => false, + getFileHistoryService: () => ({ trackEdit: vi.fn() }), }; const mockConfig = mockConfigInternal as unknown as Config; From 6e7d445d187364d2970926d35e57cb84f02ffd13 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Mon, 11 May 2026 23:46:46 +0800 Subject: [PATCH 05/31] fix(rewind): allow Esc during diff loading and add missing i18n footer strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow users to press Esc/Ctrl+C to cancel during diff stats loading phase. Add three missing footer navigation strings to all 9 locale files. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) --- packages/cli/src/i18n/locales/ca.js | 6 ++++++ packages/cli/src/i18n/locales/de.js | 5 +++++ packages/cli/src/i18n/locales/en.js | 6 ++++++ packages/cli/src/i18n/locales/fr.js | 5 +++++ packages/cli/src/i18n/locales/ja.js | 5 +++++ packages/cli/src/i18n/locales/pt.js | 5 +++++ packages/cli/src/i18n/locales/ru.js | 5 +++++ packages/cli/src/i18n/locales/zh-TW.js | 5 +++++ packages/cli/src/i18n/locales/zh.js | 5 +++++ packages/cli/src/ui/components/RewindSelector.tsx | 8 ++++---- 10 files changed, 51 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index d67bc10698d..dae8b70d813 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -139,6 +139,12 @@ export default { 'Restored {{count}} file(s).': "S'han restaurat {{count}} fitxer(s).", 'Failed to restore files: {{error}}': 'Error en restaurar els fitxers: {{error}}', + '↑↓ to navigate · Enter to select · Esc to go back': + '↑↓ per navegar · Enter per seleccionar · Esc per tornar', + '↑↓ to navigate · Enter to select · Esc to cancel': + '↑↓ per navegar · Enter per seleccionar · Esc per cancel·lar', + 'Enter/Y to confirm · Esc/N to go back': + 'Enter/Y per confirmar · Esc/N per tornar', 'change the theme': 'canviar el tema', 'Select Theme': 'Seleccionar tema', Preview: 'Previsualització', diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 7adc7dddba0..d2bddfe4a53 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -118,6 +118,11 @@ export default { 'Restored {{count}} file(s).': '{{count}} Datei(en) wiederhergestellt.', 'Failed to restore files: {{error}}': 'Fehler beim Wiederherstellen der Dateien: {{error}}', + '↑↓ to navigate · Enter to select · Esc to go back': + '↑↓ navigieren · Enter auswählen · Esc zurück', + '↑↓ to navigate · Enter to select · Esc to cancel': + '↑↓ navigieren · Enter auswählen · Esc abbrechen', + 'Enter/Y to confirm · Esc/N to go back': 'Enter/Y bestätigen · Esc/N zurück', 'change the theme': 'Design ändern', 'Select Theme': 'Design auswählen', Preview: 'Vorschau', diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index e8d02f91c00..45f352eb04c 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -139,6 +139,12 @@ export default { 'Computing file changes...': 'Computing file changes...', 'Restored {{count}} file(s).': 'Restored {{count}} file(s).', 'Failed to restore files: {{error}}': 'Failed to restore files: {{error}}', + '↑↓ to navigate · Enter to select · Esc to go back': + '↑↓ to navigate · Enter to select · Esc to go back', + '↑↓ to navigate · Enter to select · Esc to cancel': + '↑↓ to navigate · Enter to select · Esc to cancel', + 'Enter/Y to confirm · Esc/N to go back': + 'Enter/Y to confirm · Esc/N to go back', 'change the theme': 'change the theme', 'Select Theme': 'Select Theme', Preview: 'Preview', diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index b4afc349900..d3102e6a598 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -138,6 +138,11 @@ export default { 'Restored {{count}} file(s).': '{{count}} fichier(s) restauré(s).', 'Failed to restore files: {{error}}': 'Échec de la restauration des fichiers : {{error}}', + '↑↓ to navigate · Enter to select · Esc to go back': + '↑↓ naviguer · Enter sélectionner · Esc retour', + '↑↓ to navigate · Enter to select · Esc to cancel': + '↑↓ naviguer · Enter sélectionner · Esc annuler', + 'Enter/Y to confirm · Esc/N to go back': 'Enter/Y confirmer · Esc/N retour', 'change the theme': 'changer le thème', 'Select Theme': 'Sélectionner un thème', Preview: 'Aperçu', diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index fc23dd7429c..3e3eb66f194 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -99,6 +99,11 @@ export default { 'Restored {{count}} file(s).': '{{count}} 個のファイルを復元しました。', 'Failed to restore files: {{error}}': 'ファイルの復元に失敗しました:{{error}}', + '↑↓ to navigate · Enter to select · Esc to go back': + '↑↓ 移動 · Enter 選択 · Esc 戻る', + '↑↓ to navigate · Enter to select · Esc to cancel': + '↑↓ 移動 · Enter 選択 · Esc キャンセル', + 'Enter/Y to confirm · Esc/N to go back': 'Enter/Y 確認 · Esc/N 戻る', 'change the theme': 'テーマを変更', 'Select Theme': 'テーマを選択', Preview: 'プレビュー', diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 4e28e9be857..97190f8ab80 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -133,6 +133,11 @@ export default { 'Restored {{count}} file(s).': '{{count}} arquivo(s) restaurado(s).', 'Failed to restore files: {{error}}': 'Falha ao restaurar arquivos: {{error}}', + '↑↓ to navigate · Enter to select · Esc to go back': + '↑↓ navegar · Enter selecionar · Esc voltar', + '↑↓ to navigate · Enter to select · Esc to cancel': + '↑↓ navegar · Enter selecionar · Esc cancelar', + 'Enter/Y to confirm · Esc/N to go back': 'Enter/Y confirmar · Esc/N voltar', 'change the theme': 'alterar o tema', 'Select Theme': 'Selecionar Tema', Preview: 'Visualizar', diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 88c507af5e3..fbf598ec683 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -142,6 +142,11 @@ export default { 'Restored {{count}} file(s).': 'Восстановлено файлов: {{count}}.', 'Failed to restore files: {{error}}': 'Не удалось восстановить файлы: {{error}}', + '↑↓ to navigate · Enter to select · Esc to go back': + '↑↓ навигация · Enter выбор · Esc назад', + '↑↓ to navigate · Enter to select · Esc to cancel': + '↑↓ навигация · Enter выбор · Esc отмена', + 'Enter/Y to confirm · Esc/N to go back': 'Enter/Y подтвердить · Esc/N назад', 'change the theme': 'Изменение темы', 'Select Theme': 'Выбор темы', Preview: 'Предпросмотр', diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index a75b330239a..f4b1645bafa 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -120,6 +120,11 @@ export default { 'Computing file changes...': '正在計算檔案變更...', 'Restored {{count}} file(s).': '已恢復 {{count}} 個檔案。', 'Failed to restore files: {{error}}': '恢復檔案失敗:{{error}}', + '↑↓ to navigate · Enter to select · Esc to go back': + '↑↓ 導覽 · Enter 選取 · Esc 返回', + '↑↓ to navigate · Enter to select · Esc to cancel': + '↑↓ 導覽 · Enter 選取 · Esc 取消', + 'Enter/Y to confirm · Esc/N to go back': 'Enter/Y 確認 · Esc/N 返回', 'change the theme': '更改主題', 'Select Theme': '選擇主題', Preview: '預覽', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 516f11b00f4..4063f82a427 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -133,6 +133,11 @@ export default { 'Computing file changes...': '正在计算文件变更...', 'Restored {{count}} file(s).': '已恢复 {{count}} 个文件。', 'Failed to restore files: {{error}}': '恢复文件失败:{{error}}', + '↑↓ to navigate · Enter to select · Esc to go back': + '↑↓ 导航 · Enter 选择 · Esc 返回', + '↑↓ to navigate · Enter to select · Esc to cancel': + '↑↓ 导航 · Enter 选择 · Esc 取消', + 'Enter/Y to confirm · Esc/N to go back': 'Enter/Y 确认 · Esc/N 返回', 'change the theme': '更改主题', 'Select Theme': '选择主题', Preview: '预览', diff --git a/packages/cli/src/ui/components/RewindSelector.tsx b/packages/cli/src/ui/components/RewindSelector.tsx index fa72daf3542..9fa09736de9 100644 --- a/packages/cli/src/ui/components/RewindSelector.tsx +++ b/packages/cli/src/ui/components/RewindSelector.tsx @@ -103,9 +103,7 @@ function getRestoreOptions( diffStats: DiffStats | undefined, ): RestoreOptionItem[] { const hasChanges = - diffStats && - diffStats.filesChanged && - diffStats.filesChanged.length > 0; + diffStats && diffStats.filesChanged && diffStats.filesChanged.length > 0; const options: RestoreOptionItem[] = []; @@ -279,6 +277,8 @@ export function RewindSelector({ return; } + if (loadingDiff) return; + if (name === 'return') { const option = restoreOptions[restoreOptionIndex]; if (option) { @@ -304,7 +304,7 @@ export function RewindSelector({ return; } }, - { isActive: restoreItem !== null && !loadingDiff }, + { isActive: restoreItem !== null }, ); // Legacy confirm key handler From bcfe0ba3123a96a822b5e59470b9de74f5ab2c38 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Tue, 12 May 2026 11:39:39 +0800 Subject: [PATCH 06/31] =?UTF-8?q?fix(rewind):=20address=20review=20feedbac?= =?UTF-8?q?k=20=E2=80=94=20restoreBackup=20correctness,=20missing=20prompt?= =?UTF-8?q?Id=20warning,=20dead=20code=20removal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - restoreBackup now returns boolean; applySnapshot only counts a file as restored when the backup was actually applied (fixes misleading "Restored N file(s)" when backup is missing on disk) - Show warning when user selects file restore on a turn created before file checkpointing was enabled (promptId undefined) - Remove unused snapshotSequence field, canRestore(), and hasAnyChanges() methods that had no callers --- packages/cli/src/i18n/locales/ca.js | 2 + packages/cli/src/i18n/locales/de.js | 2 + packages/cli/src/i18n/locales/en.js | 2 + packages/cli/src/i18n/locales/fr.js | 2 + packages/cli/src/i18n/locales/ja.js | 2 + packages/cli/src/i18n/locales/pt.js | 2 + packages/cli/src/i18n/locales/ru.js | 2 + packages/cli/src/i18n/locales/zh-TW.js | 2 + packages/cli/src/i18n/locales/zh.js | 2 + packages/cli/src/ui/AppContainer.tsx | 13 ++++- .../core/src/services/fileHistoryService.ts | 58 +++++-------------- 11 files changed, 41 insertions(+), 48 deletions(-) diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index dae8b70d813..7380c7879b4 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -139,6 +139,8 @@ export default { 'Restored {{count}} file(s).': "S'han restaurat {{count}} fitxer(s).", 'Failed to restore files: {{error}}': 'Error en restaurar els fitxers: {{error}}', + 'Cannot restore files: this turn was created before file checkpointing was enabled.': + 'No es poden restaurar els fitxers: aquest torn es va crear abans que el punt de control de fitxers estigués habilitat.', '↑↓ to navigate · Enter to select · Esc to go back': '↑↓ per navegar · Enter per seleccionar · Esc per tornar', '↑↓ to navigate · Enter to select · Esc to cancel': diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index d2bddfe4a53..321d5cc6e61 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -118,6 +118,8 @@ export default { 'Restored {{count}} file(s).': '{{count}} Datei(en) wiederhergestellt.', 'Failed to restore files: {{error}}': 'Fehler beim Wiederherstellen der Dateien: {{error}}', + 'Cannot restore files: this turn was created before file checkpointing was enabled.': + 'Dateien können nicht wiederhergestellt werden: Dieser Turn wurde erstellt, bevor Datei-Checkpointing aktiviert war.', '↑↓ to navigate · Enter to select · Esc to go back': '↑↓ navigieren · Enter auswählen · Esc zurück', '↑↓ to navigate · Enter to select · Esc to cancel': diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 45f352eb04c..911deb71de6 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -139,6 +139,8 @@ export default { 'Computing file changes...': 'Computing file changes...', 'Restored {{count}} file(s).': 'Restored {{count}} file(s).', 'Failed to restore files: {{error}}': 'Failed to restore files: {{error}}', + 'Cannot restore files: this turn was created before file checkpointing was enabled.': + 'Cannot restore files: this turn was created before file checkpointing was enabled.', '↑↓ to navigate · Enter to select · Esc to go back': '↑↓ to navigate · Enter to select · Esc to go back', '↑↓ to navigate · Enter to select · Esc to cancel': diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index d3102e6a598..f199b04ed44 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -138,6 +138,8 @@ export default { 'Restored {{count}} file(s).': '{{count}} fichier(s) restauré(s).', 'Failed to restore files: {{error}}': 'Échec de la restauration des fichiers : {{error}}', + 'Cannot restore files: this turn was created before file checkpointing was enabled.': + "Impossible de restaurer les fichiers : ce tour a été créé avant l'activation des points de contrôle de fichiers.", '↑↓ to navigate · Enter to select · Esc to go back': '↑↓ naviguer · Enter sélectionner · Esc retour', '↑↓ to navigate · Enter to select · Esc to cancel': diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 3e3eb66f194..5723555c173 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -99,6 +99,8 @@ export default { 'Restored {{count}} file(s).': '{{count}} 個のファイルを復元しました。', 'Failed to restore files: {{error}}': 'ファイルの復元に失敗しました:{{error}}', + 'Cannot restore files: this turn was created before file checkpointing was enabled.': + 'ファイルを復元できません:このターンはファイルチェックポイントが有効になる前に作成されました。', '↑↓ to navigate · Enter to select · Esc to go back': '↑↓ 移動 · Enter 選択 · Esc 戻る', '↑↓ to navigate · Enter to select · Esc to cancel': diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 97190f8ab80..7d97dd3f8e1 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -133,6 +133,8 @@ export default { 'Restored {{count}} file(s).': '{{count}} arquivo(s) restaurado(s).', 'Failed to restore files: {{error}}': 'Falha ao restaurar arquivos: {{error}}', + 'Cannot restore files: this turn was created before file checkpointing was enabled.': + 'Não é possível restaurar arquivos: este turno foi criado antes do checkpoint de arquivos ser ativado.', '↑↓ to navigate · Enter to select · Esc to go back': '↑↓ navegar · Enter selecionar · Esc voltar', '↑↓ to navigate · Enter to select · Esc to cancel': diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index fbf598ec683..4736c67afc3 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -142,6 +142,8 @@ export default { 'Restored {{count}} file(s).': 'Восстановлено файлов: {{count}}.', 'Failed to restore files: {{error}}': 'Не удалось восстановить файлы: {{error}}', + 'Cannot restore files: this turn was created before file checkpointing was enabled.': + 'Невозможно восстановить файлы: этот ход был создан до включения контрольных точек файлов.', '↑↓ to navigate · Enter to select · Esc to go back': '↑↓ навигация · Enter выбор · Esc назад', '↑↓ to navigate · Enter to select · Esc to cancel': diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index f4b1645bafa..a166a69318f 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -120,6 +120,8 @@ export default { 'Computing file changes...': '正在計算檔案變更...', 'Restored {{count}} file(s).': '已恢復 {{count}} 個檔案。', 'Failed to restore files: {{error}}': '恢復檔案失敗:{{error}}', + 'Cannot restore files: this turn was created before file checkpointing was enabled.': + '無法恢復檔案:該輪對話建立時尚未啟用檔案檢查點功能。', '↑↓ to navigate · Enter to select · Esc to go back': '↑↓ 導覽 · Enter 選取 · Esc 返回', '↑↓ to navigate · Enter to select · Esc to cancel': diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 4063f82a427..b94d014bb45 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -133,6 +133,8 @@ export default { 'Computing file changes...': '正在计算文件变更...', 'Restored {{count}} file(s).': '已恢复 {{count}} 个文件。', 'Failed to restore files: {{error}}': '恢复文件失败:{{error}}', + 'Cannot restore files: this turn was created before file checkpointing was enabled.': + '无法恢复文件:该轮对话创建时尚未启用文件检查点功能。', '↑↓ to navigate · Enter to select · Esc to go back': '↑↓ 导航 · Enter 选择 · Esc 返回', '↑↓ to navigate · Enter to select · Esc to cancel': diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 093fa3aa4c2..02ab8dc4b92 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1995,11 +1995,19 @@ export const AppContainer = (props: AppContainerProps) => { .getFileHistoryService() .rewind(promptId); if (filesChanged.length > 0) { - fileRestoreMessage = t('Restored {{count}} file(s).', { count: String(filesChanged.length) }); + fileRestoreMessage = t('Restored {{count}} file(s).', { + count: String(filesChanged.length), + }); } } catch (error) { - fileRestoreError = t('Failed to restore files: {{error}}', { error: error instanceof Error ? error.message : String(error) }); + fileRestoreError = t('Failed to restore files: {{error}}', { + error: error instanceof Error ? error.message : String(error), + }); } + } else { + fileRestoreError = t( + 'Cannot restore files: this turn was created before file checkpointing was enabled.', + ); } } @@ -2090,7 +2098,6 @@ export const AppContainer = (props: AppContainerProps) => { Date.now(), ); } - }, [config, historyManager, refreshStatic, buffer], ); diff --git a/packages/core/src/services/fileHistoryService.ts b/packages/core/src/services/fileHistoryService.ts index aac310e7e7c..f1e76b5935f 100644 --- a/packages/core/src/services/fileHistoryService.ts +++ b/packages/core/src/services/fileHistoryService.ts @@ -38,7 +38,6 @@ export interface FileHistorySnapshot { export interface FileHistoryState { snapshots: FileHistorySnapshot[]; trackedFiles: Set; - snapshotSequence: number; } export interface DiffStats { @@ -132,7 +131,7 @@ async function restoreBackup( filePath: string, backupFileName: string, sessionId: string, -): Promise { +): Promise { const backupPath = resolveBackupPath(backupFileName, sessionId); let backupStats: Stats; @@ -141,7 +140,7 @@ async function restoreBackup( } catch (e: unknown) { if (isENOENT(e)) { debugLogger.error(`FileHistory: Backup file not found: ${backupPath}`); - return; + return false; } throw e; } @@ -155,6 +154,7 @@ async function restoreBackup( } await chmod(filePath, backupStats.mode); + return true; } async function checkOriginFileChanged( @@ -245,7 +245,6 @@ export class FileHistoryService { private state: FileHistoryState = { snapshots: [], trackedFiles: new Set(), - snapshotSequence: 0, }; private currentPromptId = ''; @@ -290,7 +289,6 @@ export class FileHistoryService { this.state = { snapshots: migrated, trackedFiles, - snapshotSequence: migrated.length, }; } @@ -404,7 +402,6 @@ export class FileHistoryService { if (this.state.snapshots.length > MAX_SNAPSHOTS) { this.state.snapshots = this.state.snapshots.slice(-MAX_SNAPSHOTS); } - this.state.snapshotSequence++; debugLogger.debug( `FileHistory: Added snapshot for ${promptId}, tracking ${this.state.trackedFiles.size} files`, @@ -425,11 +422,6 @@ export class FileHistoryService { return filesChanged; } - canRestore(promptId: string): boolean { - if (!this.enabled) return false; - return this.state.snapshots.some((s) => s.promptId === promptId); - } - async getDiffStats(promptId: string): Promise { if (!this.enabled) return undefined; @@ -481,36 +473,6 @@ export class FileHistoryService { return { filesChanged, insertions, deletions }; } - async hasAnyChanges(promptId: string): Promise { - if (!this.enabled) return false; - - const targetSnapshot = this.findSnapshot(promptId); - if (!targetSnapshot) return false; - - for (const trackingPath of this.state.trackedFiles) { - try { - const filePath = this.maybeExpandFilePath(trackingPath); - const targetBackup = targetSnapshot.trackedFileBackups[trackingPath]; - const backupFileName: BackupFileName | undefined = targetBackup - ? targetBackup.backupFileName - : this.getBackupFileNameFirstVersion(trackingPath); - - if (backupFileName === undefined) continue; - if (backupFileName === null) { - if (await pathExists(filePath)) return true; - continue; - } - if ( - await checkOriginFileChanged(filePath, backupFileName, this.sessionId) - ) - return true; - } catch (error) { - debugLogger.error(`FileHistory: Error checking changes: ${error}`); - } - } - return false; - } - private findSnapshot(promptId: string): FileHistorySnapshot | undefined { for (let i = this.state.snapshots.length - 1; i >= 0; i--) { if (this.state.snapshots[i]!.promptId === promptId) { @@ -554,11 +516,17 @@ export class FileHistoryService { if ( await checkOriginFileChanged(filePath, backupFileName, this.sessionId) ) { - await restoreBackup(filePath, backupFileName, this.sessionId); - debugLogger.debug( - `FileHistory: Restored ${filePath} from ${backupFileName}`, + const restored = await restoreBackup( + filePath, + backupFileName, + this.sessionId, ); - filesChanged.push(filePath); + if (restored) { + debugLogger.debug( + `FileHistory: Restored ${filePath} from ${backupFileName}`, + ); + filesChanged.push(filePath); + } } } catch (error) { debugLogger.error( From f12dcf210004daa1d83795e847d7e4477536d558 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Tue, 12 May 2026 14:47:00 +0800 Subject: [PATCH 07/31] fix(rewind): correct diff direction, truncate snapshots on rewind, add zero-files feedback - Swap diffLines args to diffLines(backup, current) so +/- stats match git convention (insertions = lines added since checkpoint) - Truncate snapshots after rewind to discard stale timeline state, preventing makeSnapshot from using wrong baseline - Show "No files needed restoration." when rewind finds files already at target state (all 9 locales) --- packages/cli/src/i18n/locales/ca.js | 1 + packages/cli/src/i18n/locales/de.js | 2 ++ packages/cli/src/i18n/locales/en.js | 1 + packages/cli/src/i18n/locales/fr.js | 2 ++ packages/cli/src/i18n/locales/ja.js | 1 + packages/cli/src/i18n/locales/pt.js | 1 + packages/cli/src/i18n/locales/ru.js | 1 + packages/cli/src/i18n/locales/zh-TW.js | 1 + packages/cli/src/i18n/locales/zh.js | 1 + packages/cli/src/ui/AppContainer.tsx | 2 ++ packages/core/src/services/fileHistoryService.ts | 8 +++++++- 11 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index 7380c7879b4..53126eba830 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -141,6 +141,7 @@ export default { 'Error en restaurar els fitxers: {{error}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': 'No es poden restaurar els fitxers: aquest torn es va crear abans que el punt de control de fitxers estigués habilitat.', + 'No files needed restoration.': 'Cap fitxer necessitava restauració.', '↑↓ to navigate · Enter to select · Esc to go back': '↑↓ per navegar · Enter per seleccionar · Esc per tornar', '↑↓ to navigate · Enter to select · Esc to cancel': diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 321d5cc6e61..73b95a27972 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -120,6 +120,8 @@ export default { 'Fehler beim Wiederherstellen der Dateien: {{error}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': 'Dateien können nicht wiederhergestellt werden: Dieser Turn wurde erstellt, bevor Datei-Checkpointing aktiviert war.', + 'No files needed restoration.': + 'Keine Dateien mussten wiederhergestellt werden.', '↑↓ to navigate · Enter to select · Esc to go back': '↑↓ navigieren · Enter auswählen · Esc zurück', '↑↓ to navigate · Enter to select · Esc to cancel': diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 911deb71de6..b9cdf800c03 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -141,6 +141,7 @@ export default { 'Failed to restore files: {{error}}': 'Failed to restore files: {{error}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': 'Cannot restore files: this turn was created before file checkpointing was enabled.', + 'No files needed restoration.': 'No files needed restoration.', '↑↓ to navigate · Enter to select · Esc to go back': '↑↓ to navigate · Enter to select · Esc to go back', '↑↓ to navigate · Enter to select · Esc to cancel': diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index f199b04ed44..487d70eb47a 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -140,6 +140,8 @@ export default { 'Échec de la restauration des fichiers : {{error}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': "Impossible de restaurer les fichiers : ce tour a été créé avant l'activation des points de contrôle de fichiers.", + 'No files needed restoration.': + "Aucun fichier n'a eu besoin d'être restauré.", '↑↓ to navigate · Enter to select · Esc to go back': '↑↓ naviguer · Enter sélectionner · Esc retour', '↑↓ to navigate · Enter to select · Esc to cancel': diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 5723555c173..f71a2326ba1 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -101,6 +101,7 @@ export default { 'ファイルの復元に失敗しました:{{error}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': 'ファイルを復元できません:このターンはファイルチェックポイントが有効になる前に作成されました。', + 'No files needed restoration.': '復元が必要なファイルはありません。', '↑↓ to navigate · Enter to select · Esc to go back': '↑↓ 移動 · Enter 選択 · Esc 戻る', '↑↓ to navigate · Enter to select · Esc to cancel': diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 7d97dd3f8e1..7098f590dad 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -135,6 +135,7 @@ export default { 'Falha ao restaurar arquivos: {{error}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': 'Não é possível restaurar arquivos: este turno foi criado antes do checkpoint de arquivos ser ativado.', + 'No files needed restoration.': 'Nenhum arquivo precisou ser restaurado.', '↑↓ to navigate · Enter to select · Esc to go back': '↑↓ navegar · Enter selecionar · Esc voltar', '↑↓ to navigate · Enter to select · Esc to cancel': diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 4736c67afc3..d6809d844d3 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -144,6 +144,7 @@ export default { 'Не удалось восстановить файлы: {{error}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': 'Невозможно восстановить файлы: этот ход был создан до включения контрольных точек файлов.', + 'No files needed restoration.': 'Файлы не нуждались в восстановлении.', '↑↓ to navigate · Enter to select · Esc to go back': '↑↓ навигация · Enter выбор · Esc назад', '↑↓ to navigate · Enter to select · Esc to cancel': diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index a166a69318f..3b87e20ed25 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -122,6 +122,7 @@ export default { 'Failed to restore files: {{error}}': '恢復檔案失敗:{{error}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': '無法恢復檔案:該輪對話建立時尚未啟用檔案檢查點功能。', + 'No files needed restoration.': '沒有檔案需要恢復。', '↑↓ to navigate · Enter to select · Esc to go back': '↑↓ 導覽 · Enter 選取 · Esc 返回', '↑↓ to navigate · Enter to select · Esc to cancel': diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index b94d014bb45..0a5d3b72089 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -135,6 +135,7 @@ export default { 'Failed to restore files: {{error}}': '恢复文件失败:{{error}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': '无法恢复文件:该轮对话创建时尚未启用文件检查点功能。', + 'No files needed restoration.': '没有文件需要恢复。', '↑↓ to navigate · Enter to select · Esc to go back': '↑↓ 导航 · Enter 选择 · Esc 返回', '↑↓ to navigate · Enter to select · Esc to cancel': diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 02ab8dc4b92..5793736f929 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1998,6 +1998,8 @@ export const AppContainer = (props: AppContainerProps) => { fileRestoreMessage = t('Restored {{count}} file(s).', { count: String(filesChanged.length), }); + } else { + fileRestoreMessage = t('No files needed restoration.'); } } catch (error) { fileRestoreError = t('Failed to restore files: {{error}}', { diff --git a/packages/core/src/services/fileHistoryService.ts b/packages/core/src/services/fileHistoryService.ts index f1e76b5935f..dc30ead0c97 100644 --- a/packages/core/src/services/fileHistoryService.ts +++ b/packages/core/src/services/fileHistoryService.ts @@ -229,7 +229,7 @@ async function computeDiffStatsForFile( filesChanged.push(originalFile); - const changes = diffLines(originalContent ?? '', backupContent ?? ''); + const changes = diffLines(backupContent ?? '', originalContent ?? ''); for (const c of changes) { if (c.added) insertions += c.count || 0; if (c.removed) deletions += c.count || 0; @@ -418,6 +418,12 @@ export class FileHistoryService { debugLogger.debug(`FileHistory: Rewinding to snapshot for ${promptId}`); const filesChanged = await this.applySnapshot(targetSnapshot); + + const targetIdx = this.state.snapshots.indexOf(targetSnapshot); + if (targetIdx >= 0) { + this.state.snapshots = this.state.snapshots.slice(0, targetIdx + 1); + } + debugLogger.debug(`FileHistory: Finished rewinding to ${promptId}`); return filesChanged; } From dcf76d3d45845a7583c61772d3f3ba292e475cd7 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Tue, 12 May 2026 17:08:40 +0800 Subject: [PATCH 08/31] test(tools): assert trackEdit is called before file writes --- packages/core/src/tools/edit.test.ts | 5 ++++- packages/core/src/tools/write-file.test.ts | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/core/src/tools/edit.test.ts b/packages/core/src/tools/edit.test.ts index fd05b5d1a74..e5e57a504b2 100644 --- a/packages/core/src/tools/edit.test.ts +++ b/packages/core/src/tools/edit.test.ts @@ -40,6 +40,7 @@ describe('EditTool', () => { let geminiClient: any; let baseLlmClient: any; let fileReadCache: FileReadCache; + let mockFileHistoryService: { trackEdit: ReturnType }; beforeEach(() => { vi.restoreAllMocks(); @@ -47,6 +48,7 @@ describe('EditTool', () => { rootDir = path.join(tempDir, 'root'); fs.mkdirSync(rootDir); fileReadCache = new FileReadCache(); + mockFileHistoryService = { trackEdit: vi.fn() }; geminiClient = { generateJson: mockGenerateJson, // mockGenerateJson is already defined and hoisted @@ -84,7 +86,7 @@ describe('EditTool', () => { getDefaultFileEncoding: vi.fn().mockReturnValue('utf-8'), getFileReadCache: () => fileReadCache, getFileReadCacheDisabled: vi.fn().mockReturnValue(false), - getFileHistoryService: () => ({ trackEdit: vi.fn() }), + getFileHistoryService: () => mockFileHistoryService, } as unknown as Config; // Reset mocks before each test @@ -496,6 +498,7 @@ describe('EditTool', () => { /Showing lines \d+-\d+ of \d+ from the edited file:/, ); expect(fs.readFileSync(filePath, 'utf8')).toBe(newContent); + expect(mockFileHistoryService.trackEdit).toHaveBeenCalledWith(filePath); const display = result.returnDisplay as FileDiff; expect(display.fileDiff).toMatch(initialContent); expect(display.fileDiff).toMatch(newContent); diff --git a/packages/core/src/tools/write-file.test.ts b/packages/core/src/tools/write-file.test.ts index f7d31f15aab..36d50ce37d0 100644 --- a/packages/core/src/tools/write-file.test.ts +++ b/packages/core/src/tools/write-file.test.ts @@ -40,6 +40,7 @@ let mockGeminiClientInstance: Mocked; // Mock Config const fsService = new StandardFileSystemService(); const fileReadCache = new FileReadCache(); +const mockFileHistoryService = { trackEdit: vi.fn() }; const mockConfigInternal = { getTargetDir: () => rootDir, getProjectRoot: () => rootDir, @@ -72,7 +73,7 @@ const mockConfigInternal = { getDefaultFileEncoding: () => 'utf-8', getFileReadCache: () => fileReadCache, getFileReadCacheDisabled: () => false, - getFileHistoryService: () => ({ trackEdit: vi.fn() }), + getFileHistoryService: () => mockFileHistoryService, }; const mockConfig = mockConfigInternal as unknown as Config; @@ -352,6 +353,7 @@ describe('WriteFileTool', () => { expect(result.llmContent).toMatch( /Successfully created and wrote to new file/, ); + expect(mockFileHistoryService.trackEdit).toHaveBeenCalledWith(filePath); expect(fs.existsSync(filePath)).toBe(true); const { content: writtenContent } = await fsService.readTextFile({ path: filePath, From b7517ec38daae6b8fc72cbae05eb23f4b1c71c4e Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Wed, 13 May 2026 00:59:05 +0800 Subject: [PATCH 09/31] fix(i18n): add missing rewind UI locale keys across all 9 locales --- packages/cli/src/i18n/locales/ca.js | 3 +++ packages/cli/src/i18n/locales/de.js | 3 +++ packages/cli/src/i18n/locales/en.js | 3 +++ packages/cli/src/i18n/locales/fr.js | 4 ++++ packages/cli/src/i18n/locales/ja.js | 3 +++ packages/cli/src/i18n/locales/pt.js | 3 +++ packages/cli/src/i18n/locales/ru.js | 3 +++ packages/cli/src/i18n/locales/zh-TW.js | 3 +++ packages/cli/src/i18n/locales/zh.js | 3 +++ 9 files changed, 28 insertions(+) diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index 53126eba830..6c62b6e250a 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -131,6 +131,9 @@ export default { 'Canviar el nom de la conversa actual. --auto permet que el model ràpid triï un títol.', 'Rewind conversation to a previous turn': 'Rebobinar la conversa fins a un torn anterior', + 'Rewind Conversation': 'Rebobinar la conversa', + 'No user turns to rewind to.': "No hi ha torns d'usuari per rebobinar.", + 'Rewind to: ': 'Rebobinar a: ', 'Restore code and conversation': 'Restaura el codi i la conversa', 'Restore conversation only': 'Restaura només la conversa', 'Restore code only': 'Restaura només el codi', diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 73b95a27972..0cfd7bf944c 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -110,6 +110,9 @@ export default { 'Die aktuelle Unterhaltung umbenennen. Mit --auto lässt du das schnelle Modell einen Titel wählen.', 'Rewind conversation to a previous turn': 'Die Unterhaltung auf einen früheren Gesprächsschritt zurücksetzen', + 'Rewind Conversation': 'Unterhaltung zurückspulen', + 'No user turns to rewind to.': 'Keine Benutzerrunden zum Zurückspulen.', + 'Rewind to: ': 'Zurückspulen zu: ', 'Restore code and conversation': 'Code und Unterhaltung wiederherstellen', 'Restore conversation only': 'Nur Unterhaltung wiederherstellen', 'Restore code only': 'Nur Code wiederherstellen', diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index b9cdf800c03..7e01d747622 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -132,6 +132,9 @@ export default { 'Rename the current conversation. --auto lets the fast model pick a title.', 'Rewind conversation to a previous turn': 'Rewind conversation to a previous turn', + 'Rewind Conversation': 'Rewind Conversation', + 'No user turns to rewind to.': 'No user turns to rewind to.', + 'Rewind to: ': 'Rewind to: ', 'Restore code and conversation': 'Restore code and conversation', 'Restore conversation only': 'Restore conversation only', 'Restore code only': 'Restore code only', diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index 487d70eb47a..7429bcf1557 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -130,6 +130,10 @@ export default { 'Renommer la conversation en cours. --auto laisse le modèle rapide choisir un titre.', 'Rewind conversation to a previous turn': 'Revenir à un tour précédent de la conversation', + 'Rewind Conversation': 'Rembobiner la conversation', + 'No user turns to rewind to.': + 'Aucun tour utilisateur vers lequel rembobiner.', + 'Rewind to: ': 'Rembobiner vers : ', 'Restore code and conversation': 'Restaurer le code et la conversation', 'Restore conversation only': 'Restaurer la conversation uniquement', 'Restore code only': 'Restaurer le code uniquement', diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index f71a2326ba1..e79f146d802 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -91,6 +91,9 @@ export default { 'Rename the current conversation. --auto lets the fast model pick a title.': '現在の会話の名前を変更する。--auto を使うと高速モデルがタイトルを決めます。', 'Rewind conversation to a previous turn': '会話を前のターンまで巻き戻す', + 'Rewind Conversation': '会話を巻き戻す', + 'No user turns to rewind to.': '巻き戻せるユーザーターンがありません。', + 'Rewind to: ': '巻き戻し先:', 'Restore code and conversation': 'コードと会話を復元', 'Restore conversation only': '会話のみ復元', 'Restore code only': 'コードのみ復元', diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 7098f590dad..03b26d18a3f 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -125,6 +125,9 @@ export default { 'Renomear a conversa atual. --auto permite que o modelo rápido escolha um título.', 'Rewind conversation to a previous turn': 'Voltar a conversa para um turno anterior', + 'Rewind Conversation': 'Rebobinar conversa', + 'No user turns to rewind to.': 'Nenhum turno de usuário para rebobinar.', + 'Rewind to: ': 'Rebobinar para: ', 'Restore code and conversation': 'Restaurar código e conversa', 'Restore conversation only': 'Restaurar apenas a conversa', 'Restore code only': 'Restaurar apenas o código', diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index d6809d844d3..82f473b4083 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -134,6 +134,9 @@ export default { 'Переименовать текущий разговор. --auto позволит быстрой модели выбрать заголовок.', 'Rewind conversation to a previous turn': 'Откатить разговор к предыдущему ходу', + 'Rewind Conversation': 'Перемотка разговора', + 'No user turns to rewind to.': 'Нет пользовательских ходов для перемотки.', + 'Rewind to: ': 'Перемотать к: ', 'Restore code and conversation': 'Восстановить код и беседу', 'Restore conversation only': 'Восстановить только беседу', 'Restore code only': 'Восстановить только код', diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 3b87e20ed25..1ea819a93ef 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -113,6 +113,9 @@ export default { 'Rename the current conversation. --auto lets the fast model pick a title.': '重新命名目前的對話。--auto 會讓快速模型自動產生標題。', 'Rewind conversation to a previous turn': '將對話回退到先前的某一輪', + 'Rewind Conversation': '回退對話', + 'No user turns to rewind to.': '沒有可回退的使用者對話輪次。', + 'Rewind to: ': '回退到:', 'Restore code and conversation': '恢復程式碼和對話', 'Restore conversation only': '僅恢復對話', 'Restore code only': '僅恢復程式碼', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 0a5d3b72089..d339136593e 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -126,6 +126,9 @@ export default { 'Rename the current conversation. --auto lets the fast model pick a title.': '重命名当前对话。--auto 会让快速模型自动生成标题。', 'Rewind conversation to a previous turn': '将对话回退到之前的某一轮', + 'Rewind Conversation': '回退对话', + 'No user turns to rewind to.': '没有可回退的用户对话轮次。', + 'Rewind to: ': '回退到:', 'Restore code and conversation': '恢复代码和对话', 'Restore conversation only': '仅恢复对话', 'Restore code only': '仅恢复代码', From 3edc484f2625e341f929c6bafd19397b3e74707c Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Wed, 13 May 2026 01:10:05 +0800 Subject: [PATCH 10/31] fix(core): reset fileHistoryService on session change, clean up dead code - Reset fileHistoryService in startNewSession() so /clear gets a fresh instance with the new sessionId - Rebuild trackedFiles after rewind() to avoid stale stat() calls - Remove unused setCurrentPromptId/getCurrentPromptId dead API --- packages/core/src/config/config.ts | 1 + packages/core/src/services/fileHistoryService.ts | 14 +++----------- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index d173f32c31b..2ac10d2784a 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1470,6 +1470,7 @@ export class Config { // constructed via Object.create — those should clear their own // cache, not the parent's. this.getFileReadCache().clear(); + this.fileHistoryService = undefined; refreshSessionContext(this.sessionId); // The commit-attribution singleton accumulates per-file AI edits // and a session-scoped prompt counter — both stop being meaningful diff --git a/packages/core/src/services/fileHistoryService.ts b/packages/core/src/services/fileHistoryService.ts index dc30ead0c97..6ef5cbbdf17 100644 --- a/packages/core/src/services/fileHistoryService.ts +++ b/packages/core/src/services/fileHistoryService.ts @@ -247,7 +247,6 @@ export class FileHistoryService { trackedFiles: new Set(), }; - private currentPromptId = ''; private readonly sessionId: string; private readonly enabled: boolean; private readonly cwd: string; @@ -262,14 +261,6 @@ export class FileHistoryService { return this.enabled; } - setCurrentPromptId(id: string): void { - this.currentPromptId = id; - } - - getCurrentPromptId(): string { - return this.currentPromptId; - } - getSnapshots(): FileHistorySnapshot[] { return this.state.snapshots; } @@ -329,8 +320,6 @@ export class FileHistoryService { async makeSnapshot(promptId: string): Promise { if (!this.enabled) return; - this.currentPromptId = promptId; - const trackedFileBackups: Record = {}; const mostRecent = this.state.snapshots.at(-1); @@ -422,6 +411,9 @@ export class FileHistoryService { const targetIdx = this.state.snapshots.indexOf(targetSnapshot); if (targetIdx >= 0) { this.state.snapshots = this.state.snapshots.slice(0, targetIdx + 1); + this.state.trackedFiles = new Set( + this.state.snapshots.flatMap((s) => Object.keys(s.trackedFileBackups)), + ); } debugLogger.debug(`FileHistory: Finished rewinding to ${promptId}`); From 4d045828ca8af32ab99ffe667e47099abf5ecc21 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Wed, 13 May 2026 08:00:54 +0800 Subject: [PATCH 11/31] fix(rewind): validate conversation before file restore, preserve snapshots for code-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - For 'both': validate conversation can be truncated before restoring files to prevent inconsistent state (files rolled back but conversation stays at newer state) - For 'code'-only: pass truncateHistory=false so snapshot timeline is preserved — conversation turns remain visible and their snapshots stay available for future rewinds --- packages/cli/src/ui/AppContainer.tsx | 132 +++++++++--------- .../core/src/services/fileHistoryService.ts | 18 ++- 2 files changed, 75 insertions(+), 75 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 5793736f929..b41bd266d0d 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1982,18 +1982,52 @@ export const AppContainer = (props: AppContainerProps) => { // while the async file restore is in progress. setIsRewindSelectorOpen(false); - // Restore code (files on disk) — do this before conversation - // truncation so the promptId is still accessible, but defer info - // messages until after truncation so they aren't immediately removed. + // For 'both', validate that conversation can be truncated BEFORE + // touching files — otherwise we'd roll back the workspace while + // the conversation stays at the newer state. + const needsConversation = option === 'conversation' || option === 'both'; + const geminiClient = needsConversation ? config.getGeminiClient() : null; + let apiTruncateIndex = -1; + if (needsConversation) { + if (!geminiClient) { + if (option === 'conversation') return; + // 'both' with no client: skip conversation, still try files + } else { + apiTruncateIndex = computeApiTruncationIndex( + historyManager.history, + userItem.id, + geminiClient.getHistory(), + ); + if (apiTruncateIndex < 0) { + historyManager.addItem( + { + type: 'error', + text: 'Cannot rewind to a turn that was compressed. Try a more recent turn.', + }, + Date.now(), + ); + if (option === 'both') { + // Abort file restore too — don't create inconsistent state + return; + } + return; + } + } + } + + // Restore code (files on disk). For 'code'-only, don't truncate + // the snapshot timeline — the conversation turns remain visible + // and their snapshots must stay available for future rewinds. let fileRestoreMessage: string | undefined; let fileRestoreError: string | undefined; if (option === 'code' || option === 'both') { const promptId = (userItem as HistoryItemUser).promptId; if (promptId) { try { + const truncateHistory = option === 'both'; const filesChanged = await config .getFileHistoryService() - .rewind(promptId); + .rewind(promptId, truncateHistory); if (filesChanged.length > 0) { fileRestoreMessage = t('Restored {{count}} file(s).', { count: String(filesChanged.length), @@ -2013,77 +2047,39 @@ export const AppContainer = (props: AppContainerProps) => { } } - // Restore conversation - if (option === 'conversation' || option === 'both') { - const geminiClient = config.getGeminiClient(); - const canTruncate = (() => { - if (!geminiClient) return false; - - const apiHistory = geminiClient.getHistory(); - const apiTruncateIndex = computeApiTruncationIndex( - historyManager.history, - userItem.id, - apiHistory, - ); - - if (apiTruncateIndex < 0) { - historyManager.addItem( - { - type: 'error', - text: 'Cannot rewind to a turn that was compressed. Try a more recent turn.', - }, - Date.now(), - ); - return false; - } - - // 1. Compute values from current history BEFORE truncation - const originalHistory = historyManager.history; - const originalLength = originalHistory.length; + // Truncate conversation (already validated above). + if (needsConversation && geminiClient && apiTruncateIndex >= 0) { + const originalHistory = historyManager.history; + const originalLength = originalHistory.length; - let targetTurnIndex = 0; - for (const h of originalHistory) { - if (h.id === userItem.id) break; - if (isRealUserTurn(h)) targetTurnIndex++; - } - - // 2. Truncate API history to the target point. - // Do NOT strip thought parts — reasoning models (e.g. DeepSeek) require - // reasoning_content continuity across all turns in the conversation. - geminiClient.truncateHistory(apiTruncateIndex); + let targetTurnIndex = 0; + for (const h of originalHistory) { + if (h.id === userItem.id) break; + if (isRealUserTurn(h)) targetTurnIndex++; + } - // 3. Truncate UI history (keep everything before the target item) - const truncatedUi = originalHistory.filter((h) => h.id < userItem.id); - historyManager.loadHistory(truncatedUi); + geminiClient.truncateHistory(apiTruncateIndex); - // 4. Re-render the terminal - refreshStatic(); + const truncatedUi = originalHistory.filter((h) => h.id < userItem.id); + historyManager.loadHistory(truncatedUi); - // 5. Pre-populate input with the original user text - if (userItem.type === 'user' && userItem.text) { - buffer.setText(userItem.text); - } + refreshStatic(); - // 6. Add info message - historyManager.addItem( - { - type: 'info', - text: 'Conversation rewound. Edit your prompt and press Enter to continue.', - }, - Date.now(), - ); + if (userItem.type === 'user' && userItem.text) { + buffer.setText(userItem.text); + } - // 7. Record the rewind event — re-roots the parentUuid chain so - // rewound messages end up on a dead branch during resume. - config.getChatRecordingService()?.rewindRecording(targetTurnIndex, { - truncatedCount: originalLength - truncatedUi.length, - }); - return true; - })(); + historyManager.addItem( + { + type: 'info', + text: 'Conversation rewound. Edit your prompt and press Enter to continue.', + }, + Date.now(), + ); - // If conversation truncation failed but we're in 'both' mode, - // still fall through to show file restore messages below. - if (!canTruncate && option === 'conversation') return; + config.getChatRecordingService()?.rewindRecording(targetTurnIndex, { + truncatedCount: originalLength - truncatedUi.length, + }); } // Show file restore result after conversation truncation so the diff --git a/packages/core/src/services/fileHistoryService.ts b/packages/core/src/services/fileHistoryService.ts index 6ef5cbbdf17..4c77b94f1a4 100644 --- a/packages/core/src/services/fileHistoryService.ts +++ b/packages/core/src/services/fileHistoryService.ts @@ -397,7 +397,7 @@ export class FileHistoryService { ); } - async rewind(promptId: string): Promise { + async rewind(promptId: string, truncateHistory = true): Promise { if (!this.enabled) return []; const targetSnapshot = this.findSnapshot(promptId); @@ -408,12 +408,16 @@ export class FileHistoryService { debugLogger.debug(`FileHistory: Rewinding to snapshot for ${promptId}`); const filesChanged = await this.applySnapshot(targetSnapshot); - const targetIdx = this.state.snapshots.indexOf(targetSnapshot); - if (targetIdx >= 0) { - this.state.snapshots = this.state.snapshots.slice(0, targetIdx + 1); - this.state.trackedFiles = new Set( - this.state.snapshots.flatMap((s) => Object.keys(s.trackedFileBackups)), - ); + if (truncateHistory) { + const targetIdx = this.state.snapshots.indexOf(targetSnapshot); + if (targetIdx >= 0) { + this.state.snapshots = this.state.snapshots.slice(0, targetIdx + 1); + this.state.trackedFiles = new Set( + this.state.snapshots.flatMap((s) => + Object.keys(s.trackedFileBackups), + ), + ); + } } debugLogger.debug(`FileHistory: Finished rewinding to ${promptId}`); From 7debc1bc0c8970e6240d033995dccc9aded80d34 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Wed, 13 May 2026 10:57:17 +0800 Subject: [PATCH 12/31] =?UTF-8?q?fix:=20correct=20trackEdit=20race=20comme?= =?UTF-8?q?nt=20=E2=80=94=20overwrite=20not=20orphan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core/src/services/fileHistoryService.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/core/src/services/fileHistoryService.ts b/packages/core/src/services/fileHistoryService.ts index 4c77b94f1a4..81a6680c4d7 100644 --- a/packages/core/src/services/fileHistoryService.ts +++ b/packages/core/src/services/fileHistoryService.ts @@ -306,9 +306,8 @@ export class FileHistoryService { return; } - // Re-check after async backup — concurrent trackEdit for the same path - // may have won the race. The losing call's backup file becomes orphaned - // on disk but data state stays correct. + // Re-check after async backup — concurrent calls write the same + // deterministic path, so the second overwrites the first harmlessly. if (!mostRecent.trackedFileBackups[trackingPath]) { mostRecent.trackedFileBackups[trackingPath] = backup; this.state.trackedFiles.add(trackingPath); From dc96cf5b8665b02b2eca046ae44b70864fafe338 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Wed, 13 May 2026 13:21:50 +0800 Subject: [PATCH 13/31] fix(types): use HistoryItemWithoutId for addItem to preserve union member properties --- packages/cli/src/ui/hooks/useHistoryManager.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/hooks/useHistoryManager.ts b/packages/cli/src/ui/hooks/useHistoryManager.ts index 1d3fc8de116..1ead11eef7f 100644 --- a/packages/cli/src/ui/hooks/useHistoryManager.ts +++ b/packages/cli/src/ui/hooks/useHistoryManager.ts @@ -5,7 +5,7 @@ */ import { useState, useRef, useCallback, useMemo } from 'react'; -import type { HistoryItem } from '../types.js'; +import type { HistoryItem, HistoryItemWithoutId } from '../types.js'; // Type for the updater function passed to updateHistoryItem type HistoryItemUpdater = ( @@ -14,7 +14,7 @@ type HistoryItemUpdater = ( export interface UseHistoryManagerReturn { history: HistoryItem[]; - addItem: (itemData: Omit, baseTimestamp: number) => number; // Returns the generated ID + addItem: (itemData: HistoryItemWithoutId, baseTimestamp: number) => number; // Returns the generated ID updateItem: ( id: number, updates: Partial> | HistoryItemUpdater, From 67148d796066aaecfcd106de94de29c55fdea810 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Wed, 13 May 2026 13:31:29 +0800 Subject: [PATCH 14/31] fix(types): revert addItem type change, use cast at call site for promptId --- packages/cli/src/ui/hooks/useGeminiStream.ts | 6 +++++- packages/cli/src/ui/hooks/useHistoryManager.ts | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index bd9f208c61d..d18421301f7 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -796,7 +796,11 @@ export const useGeminiStream = ( // a duplicate `> …` line. Preprocessing (@/slash/shell) still runs. if (submitType !== SendMessageType.Cron) { const insertedId = addItem( - { type: MessageType.USER, text: trimmedQuery, promptId: prompt_id }, + { + type: MessageType.USER, + text: trimmedQuery, + promptId: prompt_id, + } as HistoryItemWithoutId, userMessageTimestamp, ); // Capture id+text so the cancel handler can verify identity, diff --git a/packages/cli/src/ui/hooks/useHistoryManager.ts b/packages/cli/src/ui/hooks/useHistoryManager.ts index 1ead11eef7f..1d3fc8de116 100644 --- a/packages/cli/src/ui/hooks/useHistoryManager.ts +++ b/packages/cli/src/ui/hooks/useHistoryManager.ts @@ -5,7 +5,7 @@ */ import { useState, useRef, useCallback, useMemo } from 'react'; -import type { HistoryItem, HistoryItemWithoutId } from '../types.js'; +import type { HistoryItem } from '../types.js'; // Type for the updater function passed to updateHistoryItem type HistoryItemUpdater = ( @@ -14,7 +14,7 @@ type HistoryItemUpdater = ( export interface UseHistoryManagerReturn { history: HistoryItem[]; - addItem: (itemData: HistoryItemWithoutId, baseTimestamp: number) => number; // Returns the generated ID + addItem: (itemData: Omit, baseTimestamp: number) => number; // Returns the generated ID updateItem: ( id: number, updates: Partial> | HistoryItemUpdater, From b3fcc97e70166e87892d1956dc6ef75557455f9b Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Wed, 13 May 2026 14:03:05 +0800 Subject: [PATCH 15/31] fix(rewind): guard onRewind calls with .catch() to prevent unhandled rejection --- packages/cli/src/ui/components/RewindSelector.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/components/RewindSelector.tsx b/packages/cli/src/ui/components/RewindSelector.tsx index 9fa09736de9..332e3b137fd 100644 --- a/packages/cli/src/ui/components/RewindSelector.tsx +++ b/packages/cli/src/ui/components/RewindSelector.tsx @@ -222,7 +222,7 @@ export function RewindSelector({ const handleConfirmSelect = useCallback( (confirmed: boolean) => { if (confirmed && confirmItem) { - onRewind(confirmItem, 'conversation'); + Promise.resolve(onRewind(confirmItem, 'conversation')).catch(() => {}); } else { setConfirmItem(null); } @@ -286,7 +286,7 @@ export function RewindSelector({ setRestoreItem(null); setDiffStats(undefined); } else { - onRewind(restoreItem!, option.key); + Promise.resolve(onRewind(restoreItem!, option.key)).catch(() => {}); } } return; From 6942d1cfd4ef492e16b446ca1277b1bfec0e1651 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Wed, 13 May 2026 19:45:56 +0800 Subject: [PATCH 16/31] fix(rewind): only truncate snapshot timeline when conversation truncation will execute --- packages/cli/src/ui/AppContainer.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index b41bd266d0d..dfa1b680ab7 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -2024,7 +2024,8 @@ export const AppContainer = (props: AppContainerProps) => { const promptId = (userItem as HistoryItemUser).promptId; if (promptId) { try { - const truncateHistory = option === 'both'; + const truncateHistory = + option === 'both' && !!geminiClient && apiTruncateIndex >= 0; const filesChanged = await config .getFileHistoryService() .rewind(promptId, truncateHistory); From 73eefdefca3be1e89244f6434760d5ac65c365b2 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Wed, 13 May 2026 21:21:02 +0800 Subject: [PATCH 17/31] fix(rewind): address tanzhenxin review - gate, partial failure, tests 1. Disable file checkpointing for non-interactive (-p) mode by gating on `params.interactive !== false` in addition to `!params.sdkMode`. 2. Surface partial restore failures: `rewind()` now returns `RewindResult { filesChanged, filesFailed }`. In "both" mode, conversation truncation is skipped when any file fails to restore, preventing inconsistent state. 3. Add comprehensive unit tests for FileHistoryService (17 tests covering trackEdit, makeSnapshot, rewind, eviction, diffStats). --- packages/cli/src/i18n/locales/en.js | 2 + packages/cli/src/i18n/locales/zh.js | 2 + packages/cli/src/ui/AppContainer.tsx | 30 +- packages/core/src/config/config.ts | 3 +- .../src/services/fileHistoryService.test.ts | 284 ++++++++++++++++++ .../core/src/services/fileHistoryService.ts | 25 +- 6 files changed, 334 insertions(+), 12 deletions(-) create mode 100644 packages/core/src/services/fileHistoryService.test.ts diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 7e01d747622..353a9119fb6 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -142,6 +142,8 @@ export default { 'Computing file changes...': 'Computing file changes...', 'Restored {{count}} file(s).': 'Restored {{count}} file(s).', 'Failed to restore files: {{error}}': 'Failed to restore files: {{error}}', + 'Failed to restore {{count}} file(s): {{files}}': + 'Failed to restore {{count}} file(s): {{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': 'Cannot restore files: this turn was created before file checkpointing was enabled.', 'No files needed restoration.': 'No files needed restoration.', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index d339136593e..ca362a24951 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -136,6 +136,8 @@ export default { 'Computing file changes...': '正在计算文件变更...', 'Restored {{count}} file(s).': '已恢复 {{count}} 个文件。', 'Failed to restore files: {{error}}': '恢复文件失败:{{error}}', + 'Failed to restore {{count}} file(s): {{files}}': + '恢复 {{count}} 个文件失败:{{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': '无法恢复文件:该轮对话创建时尚未启用文件检查点功能。', 'No files needed restoration.': '没有文件需要恢复。', diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index dfa1b680ab7..cc4bda30833 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -2020,23 +2020,37 @@ export const AppContainer = (props: AppContainerProps) => { // and their snapshots must stay available for future rewinds. let fileRestoreMessage: string | undefined; let fileRestoreError: string | undefined; + let hasRestoreFailure = false; if (option === 'code' || option === 'both') { const promptId = (userItem as HistoryItemUser).promptId; if (promptId) { try { const truncateHistory = option === 'both' && !!geminiClient && apiTruncateIndex >= 0; - const filesChanged = await config + const result = await config .getFileHistoryService() .rewind(promptId, truncateHistory); - if (filesChanged.length > 0) { + if (result.filesChanged.length > 0) { fileRestoreMessage = t('Restored {{count}} file(s).', { - count: String(filesChanged.length), + count: String(result.filesChanged.length), }); - } else { + } else if (result.filesFailed.length === 0) { fileRestoreMessage = t('No files needed restoration.'); } + if (result.filesFailed.length > 0) { + hasRestoreFailure = true; + fileRestoreError = t( + 'Failed to restore {{count}} file(s): {{files}}', + { + count: String(result.filesFailed.length), + files: result.filesFailed + .map((f) => f.split('/').pop()) + .join(', '), + }, + ); + } } catch (error) { + hasRestoreFailure = true; fileRestoreError = t('Failed to restore files: {{error}}', { error: error instanceof Error ? error.message : String(error), }); @@ -2049,7 +2063,13 @@ export const AppContainer = (props: AppContainerProps) => { } // Truncate conversation (already validated above). - if (needsConversation && geminiClient && apiTruncateIndex >= 0) { + // Skip if file restore had failures in "both" mode to avoid inconsistent state. + if ( + needsConversation && + geminiClient && + apiTruncateIndex >= 0 && + !(option === 'both' && hasRestoreFailure) + ) { const originalHistory = historyManager.history; const originalLength = originalHistory.length; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 2ac10d2784a..277e4a43053 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -882,7 +882,8 @@ export class Config { }; this.checkpointing = params.checkpointing ?? false; this.fileCheckpointingEnabled = - params.fileCheckpointingEnabled ?? !params.sdkMode; + params.fileCheckpointingEnabled ?? + (!params.sdkMode && params.interactive !== false); this.proxy = params.proxy; this.cwd = params.cwd ?? process.cwd(); this.fileDiscoveryService = params.fileDiscoveryService ?? null; diff --git a/packages/core/src/services/fileHistoryService.test.ts b/packages/core/src/services/fileHistoryService.test.ts new file mode 100644 index 00000000000..e8672abf79d --- /dev/null +++ b/packages/core/src/services/fileHistoryService.test.ts @@ -0,0 +1,284 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtemp, rm, writeFile, readFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +const mockStorageDir = vi.hoisted(() => vi.fn()); +vi.mock('../config/storage.js', () => ({ + Storage: { getGlobalQwenDir: mockStorageDir }, +})); + +vi.mock('../utils/debugLogger.js', () => ({ + createDebugLogger: () => ({ + debug: vi.fn(), + error: vi.fn(), + }), +})); + +import { FileHistoryService } from './fileHistoryService.js'; + +describe('FileHistoryService', () => { + let projectDir: string; + let storageDir: string; + let service: FileHistoryService; + + beforeEach(async () => { + projectDir = await mkdtemp(join(tmpdir(), 'fh-project-')); + storageDir = await mkdtemp(join(tmpdir(), 'fh-storage-')); + mockStorageDir.mockReturnValue(storageDir); + service = new FileHistoryService('test-session', true, projectDir); + }); + + afterEach(async () => { + await rm(projectDir, { recursive: true, force: true }); + await rm(storageDir, { recursive: true, force: true }); + }); + + describe('disabled service', () => { + it('should no-op all operations when disabled', async () => { + const disabled = new FileHistoryService('s', false, projectDir); + await disabled.makeSnapshot('p1'); + await disabled.trackEdit('/foo'); + const result = await disabled.rewind('p1'); + expect(result).toEqual({ filesChanged: [], filesFailed: [] }); + expect(disabled.getSnapshots()).toEqual([]); + expect(await disabled.getDiffStats('p1')).toBeUndefined(); + }); + }); + + describe('trackEdit', () => { + it('should back up file before first edit in a snapshot', async () => { + const file = join(projectDir, 'a.txt'); + await writeFile(file, 'original'); + + await service.makeSnapshot('p1'); + await service.trackEdit(file); + + const snapshots = service.getSnapshots(); + expect(snapshots).toHaveLength(1); + const backups = snapshots[0].trackedFileBackups; + const key = Object.keys(backups)[0]; + expect(key).toBeDefined(); + expect(backups[key].version).toBe(1); + expect(backups[key].backupFileName).not.toBeNull(); + }); + + it('should skip if file already tracked in current snapshot', async () => { + const file = join(projectDir, 'a.txt'); + await writeFile(file, 'original'); + + await service.makeSnapshot('p1'); + await service.trackEdit(file); + await service.trackEdit(file); // second call + + const snapshots = service.getSnapshots(); + const backups = snapshots[0].trackedFileBackups; + expect(Object.keys(backups)).toHaveLength(1); + }); + + it('should record null backup for non-existent file', async () => { + const file = join(projectDir, 'nonexistent.txt'); + + await service.makeSnapshot('p1'); + await service.trackEdit(file); + + const snapshots = service.getSnapshots(); + const backups = snapshots[0].trackedFileBackups; + const key = Object.keys(backups)[0]; + expect(backups[key].backupFileName).toBeNull(); + }); + }); + + describe('makeSnapshot', () => { + it('should create snapshot with correct promptId', async () => { + await service.makeSnapshot('prompt-abc'); + const snapshots = service.getSnapshots(); + expect(snapshots).toHaveLength(1); + expect(snapshots[0].promptId).toBe('prompt-abc'); + }); + + it('should re-backup files that changed since last snapshot', async () => { + const file = join(projectDir, 'a.txt'); + await writeFile(file, 'v1'); + + await service.makeSnapshot('p1'); + await service.trackEdit(file); + + // Modify the file after tracking + await writeFile(file, 'v2-modified'); + + await service.makeSnapshot('p2'); + + const snapshots = service.getSnapshots(); + expect(snapshots).toHaveLength(2); + const p2Backups = snapshots[1].trackedFileBackups; + const key = Object.keys(p2Backups)[0]; + // Version should increment + expect(p2Backups[key].version).toBe(2); + }); + + it('should inherit version for unchanged files', async () => { + const file = join(projectDir, 'a.txt'); + await writeFile(file, 'unchanged'); + + await service.makeSnapshot('p1'); + await service.trackEdit(file); + await service.makeSnapshot('p2'); + + const snapshots = service.getSnapshots(); + const p1Key = Object.keys(snapshots[0].trackedFileBackups)[0]; + const p2Key = Object.keys(snapshots[1].trackedFileBackups)[0]; + // Same backup reference (version unchanged) + expect(snapshots[1].trackedFileBackups[p2Key].backupFileName).toBe( + snapshots[0].trackedFileBackups[p1Key].backupFileName, + ); + }); + }); + + describe('rewind', () => { + it('should restore file to target snapshot state', async () => { + const file = join(projectDir, 'a.txt'); + await writeFile(file, 'original'); + + await service.makeSnapshot('p1'); + await service.trackEdit(file); + await writeFile(file, 'modified'); + await service.makeSnapshot('p2'); + + const result = await service.rewind('p1'); + expect(result.filesChanged).toContain(file); + expect(result.filesFailed).toHaveLength(0); + + const content = await readFile(file, 'utf-8'); + expect(content).toBe('original'); + }); + + it('should delete file that did not exist at target snapshot', async () => { + await service.makeSnapshot('p1'); + + const file = join(projectDir, 'new-file.txt'); + await service.trackEdit(file); // non-existent → null backup + await writeFile(file, 'created'); + await service.makeSnapshot('p2'); + + const result = await service.rewind('p1'); + expect(result.filesChanged).toContain(file); + expect(existsSync(file)).toBe(false); + }); + + it('should return filesFailed when backup file is missing on disk', async () => { + const file = join(projectDir, 'a.txt'); + await writeFile(file, 'original'); + + await service.makeSnapshot('p1'); + await service.trackEdit(file); + await writeFile(file, 'modified'); + await service.makeSnapshot('p2'); + + // Delete the backup file to simulate corruption + const snapshots = service.getSnapshots(); + const key = Object.keys(snapshots[0].trackedFileBackups)[0]; + const backupFileName = + snapshots[0].trackedFileBackups[key].backupFileName; + expect(backupFileName).not.toBeNull(); + const backupPath = join( + storageDir, + 'file-history', + 'test-session', + backupFileName!, + ); + await rm(backupPath, { force: true }); + + const result = await service.rewind('p1'); + expect(result.filesFailed.length).toBeGreaterThan(0); + }); + + it('should preserve snapshot timeline when truncateHistory=false', async () => { + const file = join(projectDir, 'a.txt'); + await writeFile(file, 'original'); + + await service.makeSnapshot('p1'); + await service.trackEdit(file); + await writeFile(file, 'modified'); + await service.makeSnapshot('p2'); + + await service.rewind('p1', false); + + const snapshots = service.getSnapshots(); + expect(snapshots).toHaveLength(2); + expect(snapshots[0].promptId).toBe('p1'); + expect(snapshots[1].promptId).toBe('p2'); + }); + + it('should truncate snapshot timeline when truncateHistory=true', async () => { + const file = join(projectDir, 'a.txt'); + await writeFile(file, 'original'); + + await service.makeSnapshot('p1'); + await service.trackEdit(file); + await writeFile(file, 'modified'); + await service.makeSnapshot('p2'); + await service.makeSnapshot('p3'); + + await service.rewind('p1', true); + + const snapshots = service.getSnapshots(); + expect(snapshots).toHaveLength(1); + expect(snapshots[0].promptId).toBe('p1'); + }); + + it('should throw when snapshot not found', async () => { + await service.makeSnapshot('p1'); + await expect(service.rewind('nonexistent')).rejects.toThrow( + 'The selected snapshot was not found', + ); + }); + }); + + describe('snapshot eviction', () => { + it('should keep at most MAX_SNAPSHOTS (100) snapshots', async () => { + for (let i = 0; i < 105; i++) { + await service.makeSnapshot(`p${i}`); + } + const snapshots = service.getSnapshots(); + expect(snapshots.length).toBeLessThanOrEqual(100); + expect(snapshots[snapshots.length - 1].promptId).toBe('p104'); + }); + }); + + describe('getDiffStats', () => { + it('should compute correct insertions and deletions', async () => { + const file = join(projectDir, 'a.txt'); + await writeFile(file, 'line1\nline2\nline3\n'); + + await service.makeSnapshot('p1'); + await service.trackEdit(file); + await writeFile(file, 'line1\nmodified\nline3\nnewline\n'); + await service.makeSnapshot('p2'); + + const stats = await service.getDiffStats('p1'); + expect(stats).toBeDefined(); + expect(stats!.insertions).toBeGreaterThan(0); + expect(stats!.deletions).toBeGreaterThan(0); + expect(stats!.filesChanged).toContain(file); + }); + + it('should return undefined when disabled', async () => { + const disabled = new FileHistoryService('s', false, projectDir); + const stats = await disabled.getDiffStats('p1'); + expect(stats).toBeUndefined(); + }); + + it('should return undefined when snapshot not found', async () => { + const stats = await service.getDiffStats('nonexistent'); + expect(stats).toBeUndefined(); + }); + }); +}); diff --git a/packages/core/src/services/fileHistoryService.ts b/packages/core/src/services/fileHistoryService.ts index 81a6680c4d7..d94586e9e1f 100644 --- a/packages/core/src/services/fileHistoryService.ts +++ b/packages/core/src/services/fileHistoryService.ts @@ -46,6 +46,11 @@ export interface DiffStats { deletions: number; } +export interface RewindResult { + filesChanged: string[]; + filesFailed: string[]; +} + const MAX_SNAPSHOTS = 100; const FILE_HISTORY_DIR = 'file-history'; @@ -396,8 +401,11 @@ export class FileHistoryService { ); } - async rewind(promptId: string, truncateHistory = true): Promise { - if (!this.enabled) return []; + async rewind( + promptId: string, + truncateHistory = true, + ): Promise { + if (!this.enabled) return { filesChanged: [], filesFailed: [] }; const targetSnapshot = this.findSnapshot(promptId); if (!targetSnapshot) { @@ -405,7 +413,7 @@ export class FileHistoryService { } debugLogger.debug(`FileHistory: Rewinding to snapshot for ${promptId}`); - const filesChanged = await this.applySnapshot(targetSnapshot); + const result = await this.applySnapshot(targetSnapshot); if (truncateHistory) { const targetIdx = this.state.snapshots.indexOf(targetSnapshot); @@ -420,7 +428,7 @@ export class FileHistoryService { } debugLogger.debug(`FileHistory: Finished rewinding to ${promptId}`); - return filesChanged; + return result; } async getDiffStats(promptId: string): Promise { @@ -485,8 +493,9 @@ export class FileHistoryService { private async applySnapshot( targetSnapshot: FileHistorySnapshot, - ): Promise { + ): Promise { const filesChanged: string[] = []; + const filesFailed: string[] = []; for (const trackingPath of this.state.trackedFiles) { try { const filePath = this.maybeExpandFilePath(trackingPath); @@ -500,6 +509,7 @@ export class FileHistoryService { debugLogger.error( 'FileHistory: Error finding the backup file to apply', ); + filesFailed.push(filePath); continue; } @@ -527,15 +537,18 @@ export class FileHistoryService { `FileHistory: Restored ${filePath} from ${backupFileName}`, ); filesChanged.push(filePath); + } else { + filesFailed.push(filePath); } } } catch (error) { debugLogger.error( `FileHistory: Error restoring file ${trackingPath}: ${error}`, ); + filesFailed.push(this.maybeExpandFilePath(trackingPath)); } } - return filesChanged; + return { filesChanged, filesFailed }; } private getBackupFileNameFirstVersion( From c0e26f8fa3fefb6edeba6dd788238986b007ea63 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Wed, 13 May 2026 21:31:20 +0800 Subject: [PATCH 18/31] fix(rewind): defensive trackEdit + fix version collision on re-track 1. Wrap trackEdit calls in edit.ts and write-file.ts with try/catch so file history failures never break core tool operations. 2. Replace hardcoded version:1 in trackEdit with max-version lookup across all snapshots. Prevents backup file overwrite when the same file is re-tracked after a code-only rewind (truncateHistory=false). --- packages/core/src/services/fileHistoryService.ts | 10 +++++++++- packages/core/src/tools/edit.ts | 10 +++++++--- packages/core/src/tools/write-file.ts | 6 +++++- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/core/src/services/fileHistoryService.ts b/packages/core/src/services/fileHistoryService.ts index d94586e9e1f..c860548dcc0 100644 --- a/packages/core/src/services/fileHistoryService.ts +++ b/packages/core/src/services/fileHistoryService.ts @@ -303,9 +303,17 @@ export class FileHistoryService { return; } + let maxVersion = 0; + for (const snapshot of this.state.snapshots) { + const existing = snapshot.trackedFileBackups[trackingPath]; + if (existing && existing.version > maxVersion) { + maxVersion = existing.version; + } + } + let backup: FileHistoryBackup; try { - backup = await createBackup(filePath, 1, this.sessionId); + backup = await createBackup(filePath, maxVersion + 1, this.sessionId); } catch (error) { debugLogger.error(`FileHistory: trackEdit failed: ${error}`); return; diff --git a/packages/core/src/tools/edit.ts b/packages/core/src/tools/edit.ts index c8b89ed7247..553dc4cdba8 100644 --- a/packages/core/src/tools/edit.ts +++ b/packages/core/src/tools/edit.ts @@ -539,9 +539,13 @@ class EditToolInvocation implements ToolInvocation { // edit. this.ensureParentDirectoriesExist(this.params.file_path); - await this.config - .getFileHistoryService() - .trackEdit(this.params.file_path); + try { + await this.config + .getFileHistoryService() + .trackEdit(this.params.file_path); + } catch { + // File history is best-effort; never block core tool operations. + } // For new files, apply default file encoding setting // For existing files, preserve the original encoding (BOM and charset) diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts index 2d91db99765..ec9cfef75d0 100644 --- a/packages/core/src/tools/write-file.ts +++ b/packages/core/src/tools/write-file.ts @@ -430,7 +430,11 @@ class WriteFileToolInvocation extends BaseToolInvocation< fs.mkdirSync(dirName, { recursive: true }); } - await this.config.getFileHistoryService().trackEdit(file_path); + try { + await this.config.getFileHistoryService().trackEdit(file_path); + } catch { + // File history is best-effort; never block core tool operations. + } try { await this.config.getFileSystemService().writeTextFile({ From 66ce09f28d34e5d550884e698edd824d7ed63d1d Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Wed, 13 May 2026 21:38:42 +0800 Subject: [PATCH 19/31] fix(rewind): add missing i18n keys + fix makeSnapshot version collision 1. Add 'Failed to restore {{count}} file(s): {{files}}' to all 7 missing locales (ca, de, fr, ja, pt, ru, zh-TW). 2. Use global max-version scan in makeSnapshot (same as trackEdit) to prevent backup filename collisions after snapshot eviction. --- packages/cli/src/i18n/locales/ca.js | 2 ++ packages/cli/src/i18n/locales/de.js | 2 ++ packages/cli/src/i18n/locales/fr.js | 2 ++ packages/cli/src/i18n/locales/ja.js | 2 ++ packages/cli/src/i18n/locales/pt.js | 2 ++ packages/cli/src/i18n/locales/ru.js | 2 ++ packages/cli/src/i18n/locales/zh-TW.js | 2 ++ packages/core/src/services/fileHistoryService.ts | 7 ++++++- 8 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index 6c62b6e250a..d58db58dbe0 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -142,6 +142,8 @@ export default { 'Restored {{count}} file(s).': "S'han restaurat {{count}} fitxer(s).", 'Failed to restore files: {{error}}': 'Error en restaurar els fitxers: {{error}}', + 'Failed to restore {{count}} file(s): {{files}}': + 'Error en restaurar {{count}} fitxer(s): {{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': 'No es poden restaurar els fitxers: aquest torn es va crear abans que el punt de control de fitxers estigués habilitat.', 'No files needed restoration.': 'Cap fitxer necessitava restauració.', diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 0cfd7bf944c..cb32bafbac5 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -121,6 +121,8 @@ export default { 'Restored {{count}} file(s).': '{{count}} Datei(en) wiederhergestellt.', 'Failed to restore files: {{error}}': 'Fehler beim Wiederherstellen der Dateien: {{error}}', + 'Failed to restore {{count}} file(s): {{files}}': + '{{count}} Datei(en) konnten nicht wiederhergestellt werden: {{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': 'Dateien können nicht wiederhergestellt werden: Dieser Turn wurde erstellt, bevor Datei-Checkpointing aktiviert war.', 'No files needed restoration.': diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index 7429bcf1557..228f176412f 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -142,6 +142,8 @@ export default { 'Restored {{count}} file(s).': '{{count}} fichier(s) restauré(s).', 'Failed to restore files: {{error}}': 'Échec de la restauration des fichiers : {{error}}', + 'Failed to restore {{count}} file(s): {{files}}': + 'Échec de la restauration de {{count}} fichier(s) : {{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': "Impossible de restaurer les fichiers : ce tour a été créé avant l'activation des points de contrôle de fichiers.", 'No files needed restoration.': diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index e79f146d802..6ae0a84d37a 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -102,6 +102,8 @@ export default { 'Restored {{count}} file(s).': '{{count}} 個のファイルを復元しました。', 'Failed to restore files: {{error}}': 'ファイルの復元に失敗しました:{{error}}', + 'Failed to restore {{count}} file(s): {{files}}': + '{{count}} 個のファイルの復元に失敗しました:{{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': 'ファイルを復元できません:このターンはファイルチェックポイントが有効になる前に作成されました。', 'No files needed restoration.': '復元が必要なファイルはありません。', diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 03b26d18a3f..1aae2d3ec22 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -136,6 +136,8 @@ export default { 'Restored {{count}} file(s).': '{{count}} arquivo(s) restaurado(s).', 'Failed to restore files: {{error}}': 'Falha ao restaurar arquivos: {{error}}', + 'Failed to restore {{count}} file(s): {{files}}': + 'Falha ao restaurar {{count}} arquivo(s): {{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': 'Não é possível restaurar arquivos: este turno foi criado antes do checkpoint de arquivos ser ativado.', 'No files needed restoration.': 'Nenhum arquivo precisou ser restaurado.', diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 82f473b4083..ef3b302d842 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -145,6 +145,8 @@ export default { 'Restored {{count}} file(s).': 'Восстановлено файлов: {{count}}.', 'Failed to restore files: {{error}}': 'Не удалось восстановить файлы: {{error}}', + 'Failed to restore {{count}} file(s): {{files}}': + 'Не удалось восстановить {{count}} файл(ов): {{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': 'Невозможно восстановить файлы: этот ход был создан до включения контрольных точек файлов.', 'No files needed restoration.': 'Файлы не нуждались в восстановлении.', diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 1ea819a93ef..804ea2cf7d2 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -123,6 +123,8 @@ export default { 'Computing file changes...': '正在計算檔案變更...', 'Restored {{count}} file(s).': '已恢復 {{count}} 個檔案。', 'Failed to restore files: {{error}}': '恢復檔案失敗:{{error}}', + 'Failed to restore {{count}} file(s): {{files}}': + '恢復 {{count}} 個檔案失敗:{{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': '無法恢復檔案:該輪對話建立時尚未啟用檔案檢查點功能。', 'No files needed restoration.': '沒有檔案需要恢復。', diff --git a/packages/core/src/services/fileHistoryService.ts b/packages/core/src/services/fileHistoryService.ts index c860548dcc0..f679f4175f3 100644 --- a/packages/core/src/services/fileHistoryService.ts +++ b/packages/core/src/services/fileHistoryService.ts @@ -341,7 +341,12 @@ export class FileHistoryService { try { const filePath = this.maybeExpandFilePath(trackingPath); const latestBackup = mostRecent.trackedFileBackups[trackingPath]; - const nextVersion = latestBackup ? latestBackup.version + 1 : 1; + let maxVersion = 0; + for (const s of this.state.snapshots) { + const b = s.trackedFileBackups[trackingPath]; + if (b && b.version > maxVersion) maxVersion = b.version; + } + const nextVersion = maxVersion + 1; let fileStats: Stats | undefined; try { From 750c4df9237cfa49642a5e88a947ab1b2e34b4d2 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Wed, 13 May 2026 22:43:49 +0800 Subject: [PATCH 20/31] fix(rewind): set hasRestoreFailure when promptId is missing In "both" mode, if the target turn has no promptId, conversation truncation was still proceeding because hasRestoreFailure was not set. Now correctly blocks truncation to prevent inconsistent state. --- packages/cli/src/ui/AppContainer.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index cc4bda30833..c40091ef256 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -2056,6 +2056,7 @@ export const AppContainer = (props: AppContainerProps) => { }); } } else { + hasRestoreFailure = true; fileRestoreError = t( 'Cannot restore files: this turn was created before file checkpointing was enabled.', ); From 4fc6a83f05df10bbd68e8bec27cf8c4ea417bdc4 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Thu, 14 May 2026 14:26:09 +0800 Subject: [PATCH 21/31] fix(rewind): show loading state during async restore, close selector in finally Defer setIsRewindSelectorOpen(false) to a try/finally block so the selector stays visible during async file restore. RewindSelector now manages its own isRestoring state: shows "Restoring..." text and disables all keypress handlers while the restore is in progress. This prevents the user from seeing a bare prompt with no progress indicator during slow restores, and eliminates the race where typing during restore could clobber the pre-filled prompt. --- packages/cli/src/i18n/locales/ca.js | 1 + packages/cli/src/i18n/locales/de.js | 1 + packages/cli/src/i18n/locales/en.js | 1 + packages/cli/src/i18n/locales/fr.js | 1 + packages/cli/src/i18n/locales/ja.js | 1 + packages/cli/src/i18n/locales/pt.js | 1 + packages/cli/src/i18n/locales/ru.js | 1 + packages/cli/src/i18n/locales/zh-TW.js | 1 + packages/cli/src/i18n/locales/zh.js | 1 + packages/cli/src/ui/AppContainer.tsx | 241 +++++++++--------- .../cli/src/ui/components/RewindSelector.tsx | 17 +- 11 files changed, 146 insertions(+), 121 deletions(-) diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index d58db58dbe0..0de3ea101e3 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -139,6 +139,7 @@ export default { 'Restore code only': 'Restaura només el codi', 'Never mind': 'Tant és', 'Computing file changes...': "S'estan calculant els canvis als fitxers...", + 'Restoring...': "S'està restaurant...", 'Restored {{count}} file(s).': "S'han restaurat {{count}} fitxer(s).", 'Failed to restore files: {{error}}': 'Error en restaurar els fitxers: {{error}}', diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index cb32bafbac5..4e2f8a7423e 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -118,6 +118,7 @@ export default { 'Restore code only': 'Nur Code wiederherstellen', 'Never mind': 'Egal', 'Computing file changes...': 'Dateiänderungen werden berechnet...', + 'Restoring...': 'Wiederherstellung läuft...', 'Restored {{count}} file(s).': '{{count}} Datei(en) wiederhergestellt.', 'Failed to restore files: {{error}}': 'Fehler beim Wiederherstellen der Dateien: {{error}}', diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 353a9119fb6..44684cbfc93 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -140,6 +140,7 @@ export default { 'Restore code only': 'Restore code only', 'Never mind': 'Never mind', 'Computing file changes...': 'Computing file changes...', + 'Restoring...': 'Restoring...', 'Restored {{count}} file(s).': 'Restored {{count}} file(s).', 'Failed to restore files: {{error}}': 'Failed to restore files: {{error}}', 'Failed to restore {{count}} file(s): {{files}}': diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index 228f176412f..12e77c95176 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -139,6 +139,7 @@ export default { 'Restore code only': 'Restaurer le code uniquement', 'Never mind': 'Annuler', 'Computing file changes...': 'Calcul des modifications de fichiers...', + 'Restoring...': 'Restauration en cours...', 'Restored {{count}} file(s).': '{{count}} fichier(s) restauré(s).', 'Failed to restore files: {{error}}': 'Échec de la restauration des fichiers : {{error}}', diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 6ae0a84d37a..a927190cb30 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -99,6 +99,7 @@ export default { 'Restore code only': 'コードのみ復元', 'Never mind': 'やめる', 'Computing file changes...': 'ファイルの変更を計算中...', + 'Restoring...': '復元中...', 'Restored {{count}} file(s).': '{{count}} 個のファイルを復元しました。', 'Failed to restore files: {{error}}': 'ファイルの復元に失敗しました:{{error}}', diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 1aae2d3ec22..f7bfbfab24b 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -133,6 +133,7 @@ export default { 'Restore code only': 'Restaurar apenas o código', 'Never mind': 'Deixa pra lá', 'Computing file changes...': 'Calculando alterações de arquivo...', + 'Restoring...': 'Restaurando...', 'Restored {{count}} file(s).': '{{count}} arquivo(s) restaurado(s).', 'Failed to restore files: {{error}}': 'Falha ao restaurar arquivos: {{error}}', diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index ef3b302d842..6298b7e1927 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -142,6 +142,7 @@ export default { 'Restore code only': 'Восстановить только код', 'Never mind': 'Неважно', 'Computing file changes...': 'Вычисление изменений файлов...', + 'Restoring...': 'Восстановление...', 'Restored {{count}} file(s).': 'Восстановлено файлов: {{count}}.', 'Failed to restore files: {{error}}': 'Не удалось восстановить файлы: {{error}}', diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 804ea2cf7d2..8d31e9b4173 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -121,6 +121,7 @@ export default { 'Restore code only': '僅恢復程式碼', 'Never mind': '算了', 'Computing file changes...': '正在計算檔案變更...', + 'Restoring...': '正在恢復...', 'Restored {{count}} file(s).': '已恢復 {{count}} 個檔案。', 'Failed to restore files: {{error}}': '恢復檔案失敗:{{error}}', 'Failed to restore {{count}} file(s): {{files}}': diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index ca362a24951..035724273ef 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -134,6 +134,7 @@ export default { 'Restore code only': '仅恢复代码', 'Never mind': '算了', 'Computing file changes...': '正在计算文件变更...', + 'Restoring...': '正在恢复...', 'Restored {{count}} file(s).': '已恢复 {{count}} 个文件。', 'Failed to restore files: {{error}}': '恢复文件失败:{{error}}', 'Failed to restore {{count}} file(s): {{files}}': diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index c40091ef256..b61bdd729c4 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1978,145 +1978,148 @@ export const AppContainer = (props: AppContainerProps) => { const handleRewindConfirm = useCallback( async (userItem: HistoryItem, option: RestoreOption) => { - // Close the selector immediately to prevent double submission - // while the async file restore is in progress. - setIsRewindSelectorOpen(false); - - // For 'both', validate that conversation can be truncated BEFORE - // touching files — otherwise we'd roll back the workspace while - // the conversation stays at the newer state. - const needsConversation = option === 'conversation' || option === 'both'; - const geminiClient = needsConversation ? config.getGeminiClient() : null; - let apiTruncateIndex = -1; - if (needsConversation) { - if (!geminiClient) { - if (option === 'conversation') return; - // 'both' with no client: skip conversation, still try files - } else { - apiTruncateIndex = computeApiTruncationIndex( - historyManager.history, - userItem.id, - geminiClient.getHistory(), - ); - if (apiTruncateIndex < 0) { - historyManager.addItem( - { - type: 'error', - text: 'Cannot rewind to a turn that was compressed. Try a more recent turn.', - }, - Date.now(), + try { + // For 'both', validate that conversation can be truncated BEFORE + // touching files — otherwise we'd roll back the workspace while + // the conversation stays at the newer state. + const needsConversation = + option === 'conversation' || option === 'both'; + const geminiClient = needsConversation + ? config.getGeminiClient() + : null; + let apiTruncateIndex = -1; + if (needsConversation) { + if (!geminiClient) { + if (option === 'conversation') return; + // 'both' with no client: skip conversation, still try files + } else { + apiTruncateIndex = computeApiTruncationIndex( + historyManager.history, + userItem.id, + geminiClient.getHistory(), ); - if (option === 'both') { - // Abort file restore too — don't create inconsistent state + if (apiTruncateIndex < 0) { + historyManager.addItem( + { + type: 'error', + text: 'Cannot rewind to a turn that was compressed. Try a more recent turn.', + }, + Date.now(), + ); + if (option === 'both') { + // Abort file restore too — don't create inconsistent state + return; + } return; } - return; } } - } - // Restore code (files on disk). For 'code'-only, don't truncate - // the snapshot timeline — the conversation turns remain visible - // and their snapshots must stay available for future rewinds. - let fileRestoreMessage: string | undefined; - let fileRestoreError: string | undefined; - let hasRestoreFailure = false; - if (option === 'code' || option === 'both') { - const promptId = (userItem as HistoryItemUser).promptId; - if (promptId) { - try { - const truncateHistory = - option === 'both' && !!geminiClient && apiTruncateIndex >= 0; - const result = await config - .getFileHistoryService() - .rewind(promptId, truncateHistory); - if (result.filesChanged.length > 0) { - fileRestoreMessage = t('Restored {{count}} file(s).', { - count: String(result.filesChanged.length), - }); - } else if (result.filesFailed.length === 0) { - fileRestoreMessage = t('No files needed restoration.'); - } - if (result.filesFailed.length > 0) { + // Restore code (files on disk). For 'code'-only, don't truncate + // the snapshot timeline — the conversation turns remain visible + // and their snapshots must stay available for future rewinds. + let fileRestoreMessage: string | undefined; + let fileRestoreError: string | undefined; + let hasRestoreFailure = false; + if (option === 'code' || option === 'both') { + const promptId = (userItem as HistoryItemUser).promptId; + if (promptId) { + try { + const truncateHistory = + option === 'both' && !!geminiClient && apiTruncateIndex >= 0; + const result = await config + .getFileHistoryService() + .rewind(promptId, truncateHistory); + if (result.filesChanged.length > 0) { + fileRestoreMessage = t('Restored {{count}} file(s).', { + count: String(result.filesChanged.length), + }); + } else if (result.filesFailed.length === 0) { + fileRestoreMessage = t('No files needed restoration.'); + } + if (result.filesFailed.length > 0) { + hasRestoreFailure = true; + fileRestoreError = t( + 'Failed to restore {{count}} file(s): {{files}}', + { + count: String(result.filesFailed.length), + files: result.filesFailed + .map((f) => f.split('/').pop()) + .join(', '), + }, + ); + } + } catch (error) { hasRestoreFailure = true; - fileRestoreError = t( - 'Failed to restore {{count}} file(s): {{files}}', - { - count: String(result.filesFailed.length), - files: result.filesFailed - .map((f) => f.split('/').pop()) - .join(', '), - }, - ); + fileRestoreError = t('Failed to restore files: {{error}}', { + error: error instanceof Error ? error.message : String(error), + }); } - } catch (error) { + } else { hasRestoreFailure = true; - fileRestoreError = t('Failed to restore files: {{error}}', { - error: error instanceof Error ? error.message : String(error), - }); + fileRestoreError = t( + 'Cannot restore files: this turn was created before file checkpointing was enabled.', + ); } - } else { - hasRestoreFailure = true; - fileRestoreError = t( - 'Cannot restore files: this turn was created before file checkpointing was enabled.', - ); } - } - // Truncate conversation (already validated above). - // Skip if file restore had failures in "both" mode to avoid inconsistent state. - if ( - needsConversation && - geminiClient && - apiTruncateIndex >= 0 && - !(option === 'both' && hasRestoreFailure) - ) { - const originalHistory = historyManager.history; - const originalLength = originalHistory.length; + // Truncate conversation (already validated above). + // Skip if file restore had failures in "both" mode to avoid inconsistent state. + if ( + needsConversation && + geminiClient && + apiTruncateIndex >= 0 && + !(option === 'both' && hasRestoreFailure) + ) { + const originalHistory = historyManager.history; + const originalLength = originalHistory.length; - let targetTurnIndex = 0; - for (const h of originalHistory) { - if (h.id === userItem.id) break; - if (isRealUserTurn(h)) targetTurnIndex++; - } + let targetTurnIndex = 0; + for (const h of originalHistory) { + if (h.id === userItem.id) break; + if (isRealUserTurn(h)) targetTurnIndex++; + } - geminiClient.truncateHistory(apiTruncateIndex); + geminiClient.truncateHistory(apiTruncateIndex); - const truncatedUi = originalHistory.filter((h) => h.id < userItem.id); - historyManager.loadHistory(truncatedUi); + const truncatedUi = originalHistory.filter((h) => h.id < userItem.id); + historyManager.loadHistory(truncatedUi); - refreshStatic(); + refreshStatic(); - if (userItem.type === 'user' && userItem.text) { - buffer.setText(userItem.text); - } + if (userItem.type === 'user' && userItem.text) { + buffer.setText(userItem.text); + } - historyManager.addItem( - { - type: 'info', - text: 'Conversation rewound. Edit your prompt and press Enter to continue.', - }, - Date.now(), - ); + historyManager.addItem( + { + type: 'info', + text: 'Conversation rewound. Edit your prompt and press Enter to continue.', + }, + Date.now(), + ); - config.getChatRecordingService()?.rewindRecording(targetTurnIndex, { - truncatedCount: originalLength - truncatedUi.length, - }); - } + config.getChatRecordingService()?.rewindRecording(targetTurnIndex, { + truncatedCount: originalLength - truncatedUi.length, + }); + } - // Show file restore result after conversation truncation so the - // message isn't immediately removed by loadHistory. - if (fileRestoreMessage) { - historyManager.addItem( - { type: 'info', text: fileRestoreMessage }, - Date.now(), - ); - } - if (fileRestoreError) { - historyManager.addItem( - { type: 'error', text: fileRestoreError }, - Date.now(), - ); + // Show file restore result after conversation truncation so the + // message isn't immediately removed by loadHistory. + if (fileRestoreMessage) { + historyManager.addItem( + { type: 'info', text: fileRestoreMessage }, + Date.now(), + ); + } + if (fileRestoreError) { + historyManager.addItem( + { type: 'error', text: fileRestoreError }, + Date.now(), + ); + } + } finally { + setIsRewindSelectorOpen(false); } }, [config, historyManager, refreshStatic, buffer], diff --git a/packages/cli/src/ui/components/RewindSelector.tsx b/packages/cli/src/ui/components/RewindSelector.tsx index 332e3b137fd..d22a3a19658 100644 --- a/packages/cli/src/ui/components/RewindSelector.tsx +++ b/packages/cli/src/ui/components/RewindSelector.tsx @@ -161,6 +161,7 @@ export function RewindSelector({ const [restoreOptionIndex, setRestoreOptionIndex] = useState(0); const [diffStats, setDiffStats] = useState(undefined); const [loadingDiff, setLoadingDiff] = useState(false); + const [isRestoring, setIsRestoring] = useState(false); const boxWidth = width - 4; const maxVisibleItems = Math.min(MAX_VISIBLE_ITEMS, userTurns.length); @@ -222,7 +223,10 @@ export function RewindSelector({ const handleConfirmSelect = useCallback( (confirmed: boolean) => { if (confirmed && confirmItem) { - Promise.resolve(onRewind(confirmItem, 'conversation')).catch(() => {}); + setIsRestoring(true); + Promise.resolve(onRewind(confirmItem, 'conversation')) + .catch(() => {}) + .finally(() => setIsRestoring(false)); } else { setConfirmItem(null); } @@ -269,6 +273,8 @@ export function RewindSelector({ // Restore option key handler useKeypress( (key) => { + if (isRestoring) return; + const { name, ctrl } = key; if (name === 'escape' || (ctrl && name === 'c')) { @@ -286,7 +292,10 @@ export function RewindSelector({ setRestoreItem(null); setDiffStats(undefined); } else { - Promise.resolve(onRewind(restoreItem!, option.key)).catch(() => {}); + setIsRestoring(true); + Promise.resolve(onRewind(restoreItem!, option.key)) + .catch(() => {}) + .finally(() => setIsRestoring(false)); } } return; @@ -310,6 +319,8 @@ export function RewindSelector({ // Legacy confirm key handler useKeypress( (key) => { + if (isRestoring) return; + const { name, ctrl, sequence } = key; if (name === 'escape' || (ctrl && name === 'c')) { @@ -382,6 +393,8 @@ export function RewindSelector({ {t('Computing file changes...')} + ) : isRestoring ? ( + {t('Restoring...')} ) : ( {restoreOptions.map((option, idx) => { From a569e33e77d971eb311cbe1002d268791a1494be Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Thu, 14 May 2026 16:28:00 +0800 Subject: [PATCH 22/31] fix(rewind): skip timeline truncation on partial failure + fix wording 1. rewind() now only truncates the snapshot timeline when filesFailed is empty, preventing loss of future checkpoints when the caller skips conversation truncation due to failures. 2. Change "No files needed restoration." to the more idiomatic "No files needed to be restored." across all 9 locales. --- packages/cli/src/i18n/locales/ca.js | 2 +- packages/cli/src/i18n/locales/de.js | 2 +- packages/cli/src/i18n/locales/en.js | 2 +- packages/cli/src/i18n/locales/fr.js | 2 +- packages/cli/src/i18n/locales/ja.js | 2 +- packages/cli/src/i18n/locales/pt.js | 2 +- packages/cli/src/i18n/locales/ru.js | 2 +- packages/cli/src/i18n/locales/zh-TW.js | 2 +- packages/cli/src/i18n/locales/zh.js | 2 +- packages/cli/src/ui/AppContainer.tsx | 2 +- packages/core/src/services/fileHistoryService.ts | 2 +- 11 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index 0de3ea101e3..f944a4d154d 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -147,7 +147,7 @@ export default { 'Error en restaurar {{count}} fitxer(s): {{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': 'No es poden restaurar els fitxers: aquest torn es va crear abans que el punt de control de fitxers estigués habilitat.', - 'No files needed restoration.': 'Cap fitxer necessitava restauració.', + 'No files needed to be restored.': 'Cap fitxer necessitava restauració.', '↑↓ to navigate · Enter to select · Esc to go back': '↑↓ per navegar · Enter per seleccionar · Esc per tornar', '↑↓ to navigate · Enter to select · Esc to cancel': diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 4e2f8a7423e..a6fa6f37854 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -126,7 +126,7 @@ export default { '{{count}} Datei(en) konnten nicht wiederhergestellt werden: {{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': 'Dateien können nicht wiederhergestellt werden: Dieser Turn wurde erstellt, bevor Datei-Checkpointing aktiviert war.', - 'No files needed restoration.': + 'No files needed to be restored.': 'Keine Dateien mussten wiederhergestellt werden.', '↑↓ to navigate · Enter to select · Esc to go back': '↑↓ navigieren · Enter auswählen · Esc zurück', diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 44684cbfc93..e651034d1fb 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -147,7 +147,7 @@ export default { 'Failed to restore {{count}} file(s): {{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': 'Cannot restore files: this turn was created before file checkpointing was enabled.', - 'No files needed restoration.': 'No files needed restoration.', + 'No files needed to be restored.': 'No files needed to be restored.', '↑↓ to navigate · Enter to select · Esc to go back': '↑↓ to navigate · Enter to select · Esc to go back', '↑↓ to navigate · Enter to select · Esc to cancel': diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index 12e77c95176..366d360caba 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -147,7 +147,7 @@ export default { 'Échec de la restauration de {{count}} fichier(s) : {{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': "Impossible de restaurer les fichiers : ce tour a été créé avant l'activation des points de contrôle de fichiers.", - 'No files needed restoration.': + 'No files needed to be restored.': "Aucun fichier n'a eu besoin d'être restauré.", '↑↓ to navigate · Enter to select · Esc to go back': '↑↓ naviguer · Enter sélectionner · Esc retour', diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index a927190cb30..5dd7b20ebe5 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -107,7 +107,7 @@ export default { '{{count}} 個のファイルの復元に失敗しました:{{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': 'ファイルを復元できません:このターンはファイルチェックポイントが有効になる前に作成されました。', - 'No files needed restoration.': '復元が必要なファイルはありません。', + 'No files needed to be restored.': '復元が必要なファイルはありません。', '↑↓ to navigate · Enter to select · Esc to go back': '↑↓ 移動 · Enter 選択 · Esc 戻る', '↑↓ to navigate · Enter to select · Esc to cancel': diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index f7bfbfab24b..5d10f3b251e 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -141,7 +141,7 @@ export default { 'Falha ao restaurar {{count}} arquivo(s): {{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': 'Não é possível restaurar arquivos: este turno foi criado antes do checkpoint de arquivos ser ativado.', - 'No files needed restoration.': 'Nenhum arquivo precisou ser restaurado.', + 'No files needed to be restored.': 'Nenhum arquivo precisou ser restaurado.', '↑↓ to navigate · Enter to select · Esc to go back': '↑↓ navegar · Enter selecionar · Esc voltar', '↑↓ to navigate · Enter to select · Esc to cancel': diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 6298b7e1927..7ad5a275771 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -150,7 +150,7 @@ export default { 'Не удалось восстановить {{count}} файл(ов): {{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': 'Невозможно восстановить файлы: этот ход был создан до включения контрольных точек файлов.', - 'No files needed restoration.': 'Файлы не нуждались в восстановлении.', + 'No files needed to be restored.': 'Файлы не нуждались в восстановлении.', '↑↓ to navigate · Enter to select · Esc to go back': '↑↓ навигация · Enter выбор · Esc назад', '↑↓ to navigate · Enter to select · Esc to cancel': diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 8d31e9b4173..3ac980fed98 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -128,7 +128,7 @@ export default { '恢復 {{count}} 個檔案失敗:{{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': '無法恢復檔案:該輪對話建立時尚未啟用檔案檢查點功能。', - 'No files needed restoration.': '沒有檔案需要恢復。', + 'No files needed to be restored.': '沒有檔案需要恢復。', '↑↓ to navigate · Enter to select · Esc to go back': '↑↓ 導覽 · Enter 選取 · Esc 返回', '↑↓ to navigate · Enter to select · Esc to cancel': diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 035724273ef..442594bcfc2 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -141,7 +141,7 @@ export default { '恢复 {{count}} 个文件失败:{{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': '无法恢复文件:该轮对话创建时尚未启用文件检查点功能。', - 'No files needed restoration.': '没有文件需要恢复。', + 'No files needed to be restored.': '没有文件需要恢复。', '↑↓ to navigate · Enter to select · Esc to go back': '↑↓ 导航 · Enter 选择 · Esc 返回', '↑↓ to navigate · Enter to select · Esc to cancel': diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index b61bdd729c4..14051940cda 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -2035,7 +2035,7 @@ export const AppContainer = (props: AppContainerProps) => { count: String(result.filesChanged.length), }); } else if (result.filesFailed.length === 0) { - fileRestoreMessage = t('No files needed restoration.'); + fileRestoreMessage = t('No files needed to be restored.'); } if (result.filesFailed.length > 0) { hasRestoreFailure = true; diff --git a/packages/core/src/services/fileHistoryService.ts b/packages/core/src/services/fileHistoryService.ts index f679f4175f3..402cb935099 100644 --- a/packages/core/src/services/fileHistoryService.ts +++ b/packages/core/src/services/fileHistoryService.ts @@ -428,7 +428,7 @@ export class FileHistoryService { debugLogger.debug(`FileHistory: Rewinding to snapshot for ${promptId}`); const result = await this.applySnapshot(targetSnapshot); - if (truncateHistory) { + if (truncateHistory && result.filesFailed.length === 0) { const targetIdx = this.state.snapshots.indexOf(targetSnapshot); if (targetIdx >= 0) { this.state.snapshots = this.state.snapshots.slice(0, targetIdx + 1); From 34d355af38d16f09c25d6a0fe77e8547c8cf3fa0 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Fri, 15 May 2026 01:51:30 +0800 Subject: [PATCH 23/31] =?UTF-8?q?fix(rewind):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20TOCTOU=20in=20createBackup=20+=20outer=20catch=20in?= =?UTF-8?q?=20handleRewindConfirm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract safeCopyFile(src, dst) helper that distinguishes source-missing (TOCTOU: file deleted between stat and copyFile) from target-dir-missing, so trackEdit no longer silently fails when a file disappears mid-backup. Same helper now covers restoreBackup. - Wrap handleRewindConfirm with an outer catch that surfaces unexpected failures via historyManager error item; previously a sync throw from the post-rewind block would silently close the selector and leave 'both' mode in a half-applied state. - Add 'Rewind failed: {{error}}' i18n key in all 9 locales. --- packages/cli/src/i18n/locales/ca.js | 1 + packages/cli/src/i18n/locales/de.js | 1 + packages/cli/src/i18n/locales/en.js | 1 + packages/cli/src/i18n/locales/fr.js | 1 + packages/cli/src/i18n/locales/ja.js | 1 + packages/cli/src/i18n/locales/pt.js | 1 + packages/cli/src/i18n/locales/ru.js | 1 + packages/cli/src/i18n/locales/zh-TW.js | 1 + packages/cli/src/i18n/locales/zh.js | 1 + packages/cli/src/ui/AppContainer.tsx | 10 +++++ .../core/src/services/fileHistoryService.ts | 41 +++++++++++++------ 11 files changed, 48 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index f944a4d154d..176e700dda6 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -143,6 +143,7 @@ export default { 'Restored {{count}} file(s).': "S'han restaurat {{count}} fitxer(s).", 'Failed to restore files: {{error}}': 'Error en restaurar els fitxers: {{error}}', + 'Rewind failed: {{error}}': 'Error en retrocedir: {{error}}', 'Failed to restore {{count}} file(s): {{files}}': 'Error en restaurar {{count}} fitxer(s): {{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index a6fa6f37854..9ccd60b9247 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -122,6 +122,7 @@ export default { 'Restored {{count}} file(s).': '{{count}} Datei(en) wiederhergestellt.', 'Failed to restore files: {{error}}': 'Fehler beim Wiederherstellen der Dateien: {{error}}', + 'Rewind failed: {{error}}': 'Zurückspulen fehlgeschlagen: {{error}}', 'Failed to restore {{count}} file(s): {{files}}': '{{count}} Datei(en) konnten nicht wiederhergestellt werden: {{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index e651034d1fb..74804a18955 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -143,6 +143,7 @@ export default { 'Restoring...': 'Restoring...', 'Restored {{count}} file(s).': 'Restored {{count}} file(s).', 'Failed to restore files: {{error}}': 'Failed to restore files: {{error}}', + 'Rewind failed: {{error}}': 'Rewind failed: {{error}}', 'Failed to restore {{count}} file(s): {{files}}': 'Failed to restore {{count}} file(s): {{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index 366d360caba..907bb76417f 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -143,6 +143,7 @@ export default { 'Restored {{count}} file(s).': '{{count}} fichier(s) restauré(s).', 'Failed to restore files: {{error}}': 'Échec de la restauration des fichiers : {{error}}', + 'Rewind failed: {{error}}': 'Échec du retour en arrière : {{error}}', 'Failed to restore {{count}} file(s): {{files}}': 'Échec de la restauration de {{count}} fichier(s) : {{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 5dd7b20ebe5..83ea07e9eac 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -103,6 +103,7 @@ export default { 'Restored {{count}} file(s).': '{{count}} 個のファイルを復元しました。', 'Failed to restore files: {{error}}': 'ファイルの復元に失敗しました:{{error}}', + 'Rewind failed: {{error}}': '巻き戻しに失敗しました:{{error}}', 'Failed to restore {{count}} file(s): {{files}}': '{{count}} 個のファイルの復元に失敗しました:{{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 5d10f3b251e..c24024a0c8a 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -137,6 +137,7 @@ export default { 'Restored {{count}} file(s).': '{{count}} arquivo(s) restaurado(s).', 'Failed to restore files: {{error}}': 'Falha ao restaurar arquivos: {{error}}', + 'Rewind failed: {{error}}': 'Falha ao retroceder: {{error}}', 'Failed to restore {{count}} file(s): {{files}}': 'Falha ao restaurar {{count}} arquivo(s): {{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 7ad5a275771..e598d573a90 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -146,6 +146,7 @@ export default { 'Restored {{count}} file(s).': 'Восстановлено файлов: {{count}}.', 'Failed to restore files: {{error}}': 'Не удалось восстановить файлы: {{error}}', + 'Rewind failed: {{error}}': 'Сбой отката: {{error}}', 'Failed to restore {{count}} file(s): {{files}}': 'Не удалось восстановить {{count}} файл(ов): {{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 3ac980fed98..b8916a2fbd0 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -124,6 +124,7 @@ export default { 'Restoring...': '正在恢復...', 'Restored {{count}} file(s).': '已恢復 {{count}} 個檔案。', 'Failed to restore files: {{error}}': '恢復檔案失敗:{{error}}', + 'Rewind failed: {{error}}': '回退失敗:{{error}}', 'Failed to restore {{count}} file(s): {{files}}': '恢復 {{count}} 個檔案失敗:{{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 442594bcfc2..2628aeaed55 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -137,6 +137,7 @@ export default { 'Restoring...': '正在恢复...', 'Restored {{count}} file(s).': '已恢复 {{count}} 个文件。', 'Failed to restore files: {{error}}': '恢复文件失败:{{error}}', + 'Rewind failed: {{error}}': '回退失败:{{error}}', 'Failed to restore {{count}} file(s): {{files}}': '恢复 {{count}} 个文件失败:{{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 14051940cda..87ece6b92d1 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -2118,6 +2118,16 @@ export const AppContainer = (props: AppContainerProps) => { Date.now(), ); } + } catch (error) { + historyManager.addItem( + { + type: 'error', + text: t('Rewind failed: {{error}}', { + error: error instanceof Error ? error.message : String(error), + }), + }, + Date.now(), + ); } finally { setIsRewindSelectorOpen(false); } diff --git a/packages/core/src/services/fileHistoryService.ts b/packages/core/src/services/fileHistoryService.ts index 402cb935099..ed8fb2af14c 100644 --- a/packages/core/src/services/fileHistoryService.ts +++ b/packages/core/src/services/fileHistoryService.ts @@ -97,6 +97,26 @@ function resolveBackupPath(backupFileName: string, sessionId: string): string { ); } +// Copy `src` to `dst`, creating the destination directory if it doesn't exist. +// Returns 'src-missing' if the source file is gone (e.g. deleted between an +// earlier `stat` and this call) so callers can distinguish that from a real +// I/O failure instead of treating every ENOENT as a missing target dir. +async function safeCopyFile( + src: string, + dst: string, +): Promise<'ok' | 'src-missing'> { + try { + await copyFile(src, dst); + return 'ok'; + } catch (e: unknown) { + if (!isENOENT(e)) throw e; + if (!(await pathExists(src))) return 'src-missing'; + await mkdir(dirname(dst), { recursive: true }); + await copyFile(src, dst); + return 'ok'; + } +} + async function createBackup( filePath: string | null, version: number, @@ -119,12 +139,9 @@ async function createBackup( throw e; } - try { - await copyFile(filePath, backupPath); - } catch (e: unknown) { - if (!isENOENT(e)) throw e; - await mkdir(dirname(backupPath), { recursive: true }); - await copyFile(filePath, backupPath); + const result = await safeCopyFile(filePath, backupPath); + if (result === 'src-missing') { + return { backupFileName: null, version, backupTime: new Date() }; } await chmod(backupPath, srcStats.mode); @@ -150,12 +167,12 @@ async function restoreBackup( throw e; } - try { - await copyFile(backupPath, filePath); - } catch (e: unknown) { - if (!isENOENT(e)) throw e; - await mkdir(dirname(filePath), { recursive: true }); - await copyFile(backupPath, filePath); + const result = await safeCopyFile(backupPath, filePath); + if (result === 'src-missing') { + debugLogger.error( + `FileHistory: Backup file disappeared during restore: ${backupPath}`, + ); + return false; } await chmod(filePath, backupStats.mode); From 5415b7d539f705770e319e4d82bb381a9199e879 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Fri, 15 May 2026 01:56:42 +0800 Subject: [PATCH 24/31] test(rewind): cover restoreFromSnapshots, trackEdit no-snapshot path, partial-failure timeline guard - restoreFromSnapshots: assert relative-path shortening + external-path preservation - trackEdit before any makeSnapshot: assert no-op early return - rewind truncation guard: assert snapshot timeline is preserved when filesFailed > 0 --- .../src/services/fileHistoryService.test.ts | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/packages/core/src/services/fileHistoryService.test.ts b/packages/core/src/services/fileHistoryService.test.ts index e8672abf79d..640de4aa365 100644 --- a/packages/core/src/services/fileHistoryService.test.ts +++ b/packages/core/src/services/fileHistoryService.test.ts @@ -240,6 +240,78 @@ describe('FileHistoryService', () => { 'The selected snapshot was not found', ); }); + + it('should not truncate snapshot timeline when restore has failures', async () => { + const file = join(projectDir, 'a.txt'); + await writeFile(file, 'original'); + + await service.makeSnapshot('p1'); + await service.trackEdit(file); + await writeFile(file, 'modified'); + await service.makeSnapshot('p2'); + await service.makeSnapshot('p3'); + + // Corrupt the p1 backup so applySnapshot reports a failure. + const snapshots = service.getSnapshots(); + const key = Object.keys(snapshots[0].trackedFileBackups)[0]; + const backupFileName = + snapshots[0].trackedFileBackups[key].backupFileName!; + await rm( + join(storageDir, 'file-history', 'test-session', backupFileName), + { force: true }, + ); + + const result = await service.rewind('p1', true); + expect(result.filesFailed.length).toBeGreaterThan(0); + // Timeline must stay intact so the user can retry without losing state. + const after = service.getSnapshots(); + expect(after.map((s) => s.promptId)).toEqual(['p1', 'p2', 'p3']); + }); + }); + + describe('trackEdit before any snapshot', () => { + it('should no-op when there is no most-recent snapshot', async () => { + const file = join(projectDir, 'a.txt'); + await writeFile(file, 'original'); + + await service.trackEdit(file); + + expect(service.getSnapshots()).toEqual([]); + }); + }); + + describe('restoreFromSnapshots', () => { + it('should rehydrate snapshots and derive trackedFiles', async () => { + const fresh = new FileHistoryService('test-session', true, projectDir); + const absPath = join(projectDir, 'a.txt'); + const externalPath = join(tmpdir(), 'fh-external-x.txt'); + + fresh.restoreFromSnapshots([ + { + promptId: 'p1', + trackedFileBackups: { + [absPath]: { + backupFileName: 'deadbeefcafebabe@v1', + version: 1, + backupTime: new Date(), + }, + [externalPath]: { + backupFileName: null, + version: 1, + backupTime: new Date(), + }, + }, + timestamp: new Date(), + }, + ]); + + const snapshots = fresh.getSnapshots(); + expect(snapshots).toHaveLength(1); + // Path under cwd should be shortened to a relative key. + expect(snapshots[0].trackedFileBackups['a.txt']).toBeDefined(); + // Path outside cwd should be preserved as-is. + expect(snapshots[0].trackedFileBackups[externalPath]).toBeDefined(); + }); }); describe('snapshot eviction', () => { From 3b120223b7775dd1cb659162c90a709528d3111b Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Fri, 15 May 2026 10:22:24 +0800 Subject: [PATCH 25/31] fix(rewind): clean up orphaned backups, surface no-client states, polish - Per-eviction backup cleanup: when MAX_SNAPSHOTS overflow or rewind truncation drops snapshots, remove backup files no longer referenced by any surviving snapshot (best-effort, ENOENT-tolerant). Backup files are content-deduplicated across snapshots, so the live-set is computed from survivors before deletion. - Surface no-client failure modes in handleRewindConfirm: 'conversation' mode now shows an error instead of silently returning; 'both' mode shows an info message after restore so the user knows the conversation half was skipped. - i18n the previously hardcoded 'Conversation rewound...' message and add 3 new keys to all 9 locales. - Tighten createBackup signature (drop unreachable null branch). - Extract getMaxVersion helper to deduplicate identical loops in trackEdit and makeSnapshot. Tests added: orphan-cleanup on overflow, dedupe preservation, rewind truncation cleanup. All existing tests continue to pass (23 core, 71 AppContainer, 27 i18n). --- packages/cli/src/i18n/locales/ca.js | 6 ++ packages/cli/src/i18n/locales/de.js | 6 ++ packages/cli/src/i18n/locales/en.js | 6 ++ packages/cli/src/i18n/locales/fr.js | 6 ++ packages/cli/src/i18n/locales/ja.js | 6 ++ packages/cli/src/i18n/locales/pt.js | 6 ++ packages/cli/src/i18n/locales/ru.js | 6 ++ packages/cli/src/i18n/locales/zh-TW.js | 6 ++ packages/cli/src/i18n/locales/zh.js | 6 ++ packages/cli/src/ui/AppContainer.tsx | 33 ++++++- .../src/services/fileHistoryService.test.ts | 88 +++++++++++++++++++ .../core/src/services/fileHistoryService.ts | 75 ++++++++++++---- 12 files changed, 228 insertions(+), 22 deletions(-) diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index 176e700dda6..a598f1de28a 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -144,6 +144,12 @@ export default { 'Failed to restore files: {{error}}': 'Error en restaurar els fitxers: {{error}}', 'Rewind failed: {{error}}': 'Error en retrocedir: {{error}}', + 'Cannot rewind conversation: no active model client.': + 'No es pot retrocedir la conversa: cap client de model actiu.', + 'Code restored, but conversation could not be rewound (no active client).': + 'Codi restaurat, però la conversa no s’ha pogut retrocedir (cap client actiu).', + 'Conversation rewound. Edit your prompt and press Enter to continue.': + 'Conversa retrocedida. Edita la teva indicació i prem Retorn per continuar.', 'Failed to restore {{count}} file(s): {{files}}': 'Error en restaurar {{count}} fitxer(s): {{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 9ccd60b9247..59aa74fe835 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -123,6 +123,12 @@ export default { 'Failed to restore files: {{error}}': 'Fehler beim Wiederherstellen der Dateien: {{error}}', 'Rewind failed: {{error}}': 'Zurückspulen fehlgeschlagen: {{error}}', + 'Cannot rewind conversation: no active model client.': + 'Konversation kann nicht zurückgespult werden: kein aktiver Modell-Client.', + 'Code restored, but conversation could not be rewound (no active client).': + 'Code wiederhergestellt, aber Konversation konnte nicht zurückgespult werden (kein aktiver Client).', + 'Conversation rewound. Edit your prompt and press Enter to continue.': + 'Konversation zurückgespult. Bearbeite deinen Prompt und drücke Enter, um fortzufahren.', 'Failed to restore {{count}} file(s): {{files}}': '{{count}} Datei(en) konnten nicht wiederhergestellt werden: {{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 74804a18955..57d1a3c247e 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -144,6 +144,12 @@ export default { 'Restored {{count}} file(s).': 'Restored {{count}} file(s).', 'Failed to restore files: {{error}}': 'Failed to restore files: {{error}}', 'Rewind failed: {{error}}': 'Rewind failed: {{error}}', + 'Cannot rewind conversation: no active model client.': + 'Cannot rewind conversation: no active model client.', + 'Code restored, but conversation could not be rewound (no active client).': + 'Code restored, but conversation could not be rewound (no active client).', + 'Conversation rewound. Edit your prompt and press Enter to continue.': + 'Conversation rewound. Edit your prompt and press Enter to continue.', 'Failed to restore {{count}} file(s): {{files}}': 'Failed to restore {{count}} file(s): {{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index 907bb76417f..afff6590475 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -144,6 +144,12 @@ export default { 'Failed to restore files: {{error}}': 'Échec de la restauration des fichiers : {{error}}', 'Rewind failed: {{error}}': 'Échec du retour en arrière : {{error}}', + 'Cannot rewind conversation: no active model client.': + 'Impossible de revenir en arrière sur la conversation : aucun client de modèle actif.', + 'Code restored, but conversation could not be rewound (no active client).': + 'Code restauré, mais la conversation n’a pas pu être ramenée en arrière (aucun client actif).', + 'Conversation rewound. Edit your prompt and press Enter to continue.': + 'Conversation ramenée en arrière. Modifiez votre invite et appuyez sur Entrée pour continuer.', 'Failed to restore {{count}} file(s): {{files}}': 'Échec de la restauration de {{count}} fichier(s) : {{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 83ea07e9eac..8ed45af4b80 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -104,6 +104,12 @@ export default { 'Failed to restore files: {{error}}': 'ファイルの復元に失敗しました:{{error}}', 'Rewind failed: {{error}}': '巻き戻しに失敗しました:{{error}}', + 'Cannot rewind conversation: no active model client.': + '会話を巻き戻せません:アクティブなモデルクライアントがありません。', + 'Code restored, but conversation could not be rewound (no active client).': + 'コードは復元されましたが、会話は巻き戻せませんでした(モデルクライアントがアクティブではありません)。', + 'Conversation rewound. Edit your prompt and press Enter to continue.': + '会話を巻き戻しました。プロンプトを編集して Enter キーで続行してください。', 'Failed to restore {{count}} file(s): {{files}}': '{{count}} 個のファイルの復元に失敗しました:{{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index c24024a0c8a..eb17b884a74 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -138,6 +138,12 @@ export default { 'Failed to restore files: {{error}}': 'Falha ao restaurar arquivos: {{error}}', 'Rewind failed: {{error}}': 'Falha ao retroceder: {{error}}', + 'Cannot rewind conversation: no active model client.': + 'Não é possível retroceder a conversa: nenhum cliente de modelo ativo.', + 'Code restored, but conversation could not be rewound (no active client).': + 'Código restaurado, mas a conversa não pôde ser retrocedida (sem cliente ativo).', + 'Conversation rewound. Edit your prompt and press Enter to continue.': + 'Conversa retrocedida. Edite seu prompt e pressione Enter para continuar.', 'Failed to restore {{count}} file(s): {{files}}': 'Falha ao restaurar {{count}} arquivo(s): {{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index e598d573a90..3145ab0738e 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -147,6 +147,12 @@ export default { 'Failed to restore files: {{error}}': 'Не удалось восстановить файлы: {{error}}', 'Rewind failed: {{error}}': 'Сбой отката: {{error}}', + 'Cannot rewind conversation: no active model client.': + 'Невозможно откатить разговор: нет активного клиента модели.', + 'Code restored, but conversation could not be rewound (no active client).': + 'Код восстановлен, но разговор не удалось откатить (нет активного клиента).', + 'Conversation rewound. Edit your prompt and press Enter to continue.': + 'Разговор откатили. Отредактируйте подсказку и нажмите Enter, чтобы продолжить.', 'Failed to restore {{count}} file(s): {{files}}': 'Не удалось восстановить {{count}} файл(ов): {{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index b8916a2fbd0..bbea789d218 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -125,6 +125,12 @@ export default { 'Restored {{count}} file(s).': '已恢復 {{count}} 個檔案。', 'Failed to restore files: {{error}}': '恢復檔案失敗:{{error}}', 'Rewind failed: {{error}}': '回退失敗:{{error}}', + 'Cannot rewind conversation: no active model client.': + '無法回退對話:模型客戶端未啟用。', + 'Code restored, but conversation could not be rewound (no active client).': + '程式碼已恢復,但對話無法回退(模型客戶端未啟用)。', + 'Conversation rewound. Edit your prompt and press Enter to continue.': + '對話已回退。修改提示後按 Enter 繼續。', 'Failed to restore {{count}} file(s): {{files}}': '恢復 {{count}} 個檔案失敗:{{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 2628aeaed55..d4ae126f3b8 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -138,6 +138,12 @@ export default { 'Restored {{count}} file(s).': '已恢复 {{count}} 个文件。', 'Failed to restore files: {{error}}': '恢复文件失败:{{error}}', 'Rewind failed: {{error}}': '回退失败:{{error}}', + 'Cannot rewind conversation: no active model client.': + '无法回退对话:模型客户端未激活。', + 'Code restored, but conversation could not be rewound (no active client).': + '代码已恢复,但对话无法回退(模型客户端未激活)。', + 'Conversation rewound. Edit your prompt and press Enter to continue.': + '对话已回退。修改你的提示后按回车继续。', 'Failed to restore {{count}} file(s): {{files}}': '恢复 {{count}} 个文件失败:{{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 87ece6b92d1..735b1627aad 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1988,10 +1988,24 @@ export const AppContainer = (props: AppContainerProps) => { ? config.getGeminiClient() : null; let apiTruncateIndex = -1; + let conversationSkippedNoClient = false; if (needsConversation) { if (!geminiClient) { - if (option === 'conversation') return; - // 'both' with no client: skip conversation, still try files + if (option === 'conversation') { + historyManager.addItem( + { + type: 'error', + text: t( + 'Cannot rewind conversation: no active model client.', + ), + }, + Date.now(), + ); + return; + } + // 'both' with no client: skip conversation, still try files, + // and surface a warning after the restore output. + conversationSkippedNoClient = true; } else { apiTruncateIndex = computeApiTruncationIndex( historyManager.history, @@ -2094,7 +2108,9 @@ export const AppContainer = (props: AppContainerProps) => { historyManager.addItem( { type: 'info', - text: 'Conversation rewound. Edit your prompt and press Enter to continue.', + text: t( + 'Conversation rewound. Edit your prompt and press Enter to continue.', + ), }, Date.now(), ); @@ -2118,6 +2134,17 @@ export const AppContainer = (props: AppContainerProps) => { Date.now(), ); } + if (conversationSkippedNoClient) { + historyManager.addItem( + { + type: 'info', + text: t( + 'Code restored, but conversation could not be rewound (no active client).', + ), + }, + Date.now(), + ); + } } catch (error) { historyManager.addItem( { diff --git a/packages/core/src/services/fileHistoryService.test.ts b/packages/core/src/services/fileHistoryService.test.ts index 640de4aa365..6ba6ee36ac9 100644 --- a/packages/core/src/services/fileHistoryService.test.ts +++ b/packages/core/src/services/fileHistoryService.test.ts @@ -315,6 +315,9 @@ describe('FileHistoryService', () => { }); describe('snapshot eviction', () => { + const backupPath = (name: string) => + join(storageDir, 'file-history', 'test-session', name); + it('should keep at most MAX_SNAPSHOTS (100) snapshots', async () => { for (let i = 0; i < 105; i++) { await service.makeSnapshot(`p${i}`); @@ -323,6 +326,91 @@ describe('FileHistoryService', () => { expect(snapshots.length).toBeLessThanOrEqual(100); expect(snapshots[snapshots.length - 1].promptId).toBe('p104'); }); + + it('should delete orphaned backup files on overflow', async () => { + const file = join(projectDir, 'a.txt'); + await writeFile(file, 'v0'); + + await service.makeSnapshot('p0'); + await service.trackEdit(file); // version 1, content 'v0' + + const evictedNames: string[] = []; + // Capture v1 from p0 before it gets evicted. + evictedNames.push( + service.getSnapshots()[0].trackedFileBackups['a.txt']!.backupFileName!, + ); + + // 104 more snapshots, each with new content → fresh backup per snapshot. + for (let i = 1; i < 105; i++) { + await writeFile(file, `v${i}`); + await service.makeSnapshot(`p${i}`); + if (i < 5) { + evictedNames.push( + service.getSnapshots()[i].trackedFileBackups['a.txt']! + .backupFileName!, + ); + } + } + + // p0..p4 (versions 1..5) were dropped by slice(-100); their backups should be gone. + for (const name of evictedNames) { + expect(existsSync(backupPath(name))).toBe(false); + } + // The surviving snapshots' backups must still exist. + const survivors = service.getSnapshots(); + for (const s of survivors) { + const bn = s.trackedFileBackups['a.txt']?.backupFileName; + if (bn) expect(existsSync(backupPath(bn))).toBe(true); + } + }); + + it('should preserve deduplicated backup files referenced by survivors', async () => { + const file = join(projectDir, 'a.txt'); + await writeFile(file, 'unchanged'); + + await service.makeSnapshot('p0'); + await service.trackEdit(file); + const sharedName = + service.getSnapshots()[0].trackedFileBackups['a.txt']!.backupFileName!; + + // Content never changes → makeSnapshot reuses the same backup reference. + for (let i = 1; i < 105; i++) { + await service.makeSnapshot(`p${i}`); + } + + // Same backupFileName is held by every survivor → must NOT be deleted. + expect(existsSync(backupPath(sharedName))).toBe(true); + }); + }); + + describe('rewind cleanup', () => { + it('should delete backups orphaned by truncation', async () => { + const file = join(projectDir, 'a.txt'); + await writeFile(file, 'v0'); + + await service.makeSnapshot('p1'); + await service.trackEdit(file); + const v1 = + service.getSnapshots()[0].trackedFileBackups['a.txt']!.backupFileName!; + + await writeFile(file, 'v1'); + await service.makeSnapshot('p2'); + const v2 = + service.getSnapshots()[1].trackedFileBackups['a.txt']!.backupFileName!; + + await writeFile(file, 'v2'); + await service.makeSnapshot('p3'); + const v3 = + service.getSnapshots()[2].trackedFileBackups['a.txt']!.backupFileName!; + + await service.rewind('p1', true); + + const backupsDir = join(storageDir, 'file-history', 'test-session'); + // p1's backup is still referenced; p2 and p3's unique-version backups are gone. + expect(existsSync(join(backupsDir, v1))).toBe(true); + expect(existsSync(join(backupsDir, v2))).toBe(false); + expect(existsSync(join(backupsDir, v3))).toBe(false); + }); }); describe('getDiffStats', () => { diff --git a/packages/core/src/services/fileHistoryService.ts b/packages/core/src/services/fileHistoryService.ts index ed8fb2af14c..a52120886ad 100644 --- a/packages/core/src/services/fileHistoryService.ts +++ b/packages/core/src/services/fileHistoryService.ts @@ -118,14 +118,10 @@ async function safeCopyFile( } async function createBackup( - filePath: string | null, + filePath: string, version: number, sessionId: string, ): Promise { - if (filePath === null) { - return { backupFileName: null, version, backupTime: new Date() }; - } - const backupFileName = getBackupFileName(filePath, version); const backupPath = resolveBackupPath(backupFileName, sessionId); @@ -320,13 +316,7 @@ export class FileHistoryService { return; } - let maxVersion = 0; - for (const snapshot of this.state.snapshots) { - const existing = snapshot.trackedFileBackups[trackingPath]; - if (existing && existing.version > maxVersion) { - maxVersion = existing.version; - } - } + const maxVersion = this.getMaxVersion(trackingPath); let backup: FileHistoryBackup; try { @@ -358,12 +348,7 @@ export class FileHistoryService { try { const filePath = this.maybeExpandFilePath(trackingPath); const latestBackup = mostRecent.trackedFileBackups[trackingPath]; - let maxVersion = 0; - for (const s of this.state.snapshots) { - const b = s.trackedFileBackups[trackingPath]; - if (b && b.version > maxVersion) maxVersion = b.version; - } - const nextVersion = maxVersion + 1; + const nextVersion = this.getMaxVersion(trackingPath) + 1; let fileStats: Stats | undefined; try { @@ -423,7 +408,10 @@ export class FileHistoryService { this.state.snapshots.push(newSnapshot); if (this.state.snapshots.length > MAX_SNAPSHOTS) { - this.state.snapshots = this.state.snapshots.slice(-MAX_SNAPSHOTS); + const overflow = this.state.snapshots.length - MAX_SNAPSHOTS; + const removed = this.state.snapshots.slice(0, overflow); + this.state.snapshots = this.state.snapshots.slice(overflow); + await this.cleanupOrphanedBackups(removed); } debugLogger.debug( @@ -448,12 +436,14 @@ export class FileHistoryService { if (truncateHistory && result.filesFailed.length === 0) { const targetIdx = this.state.snapshots.indexOf(targetSnapshot); if (targetIdx >= 0) { + const removed = this.state.snapshots.slice(targetIdx + 1); this.state.snapshots = this.state.snapshots.slice(0, targetIdx + 1); this.state.trackedFiles = new Set( this.state.snapshots.flatMap((s) => Object.keys(s.trackedFileBackups), ), ); + await this.cleanupOrphanedBackups(removed); } } @@ -593,6 +583,53 @@ export class FileHistoryService { return undefined; } + private getMaxVersion(trackingPath: string): number { + let maxVersion = 0; + for (const snapshot of this.state.snapshots) { + const existing = snapshot.trackedFileBackups[trackingPath]; + if (existing && existing.version > maxVersion) { + maxVersion = existing.version; + } + } + return maxVersion; + } + + // Best-effort: delete on-disk backup files referenced only by `removedSnapshots` + // and not by any surviving snapshot. Backup files are content-deduplicated + // across snapshots (see makeSnapshot's reuse of latestBackup), so we must + // skip any name still in the live set. + private async cleanupOrphanedBackups( + removedSnapshots: FileHistorySnapshot[], + ): Promise { + const liveBackups = new Set(); + for (const s of this.state.snapshots) { + for (const b of Object.values(s.trackedFileBackups)) { + if (b.backupFileName !== null) liveBackups.add(b.backupFileName); + } + } + + const toDelete = new Set(); + for (const s of removedSnapshots) { + for (const b of Object.values(s.trackedFileBackups)) { + if (b.backupFileName !== null && !liveBackups.has(b.backupFileName)) { + toDelete.add(b.backupFileName); + } + } + } + + await Promise.all( + Array.from(toDelete, async (name) => { + try { + await unlink(resolveBackupPath(name, this.sessionId)); + } catch (e: unknown) { + if (!isENOENT(e)) { + debugLogger.error(`FileHistory: cleanup failed for ${name}: ${e}`); + } + } + }), + ); + } + private maybeShortenFilePath(filePath: string): string { if (!isAbsolute(filePath)) return filePath; if (filePath.startsWith(this.cwd + '/') || filePath === this.cwd) { From 1db8429f222977ed066fa9a137129ace78899ec5 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Fri, 15 May 2026 10:51:58 +0800 Subject: [PATCH 26/31] fix(rewind): use path separator constant in maybeShortenFilePath The hardcoded '/' check meant Windows absolute paths (with '\') never matched the cwd prefix, so the shortening was a no-op on Windows. The new cleanup tests revealed this by asserting on the relative-path key: on Windows the key was the full absolute path, so trackedFileBackups lookups returned undefined. Switching to the platform sep also makes Windows snapshots use the relative key like POSIX, improving portability if cwd moves later. restoreFromSnapshots re-runs maybeShortenFilePath on every key, so existing on-disk sessions migrate transparently on resume. --- packages/core/src/services/fileHistoryService.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/services/fileHistoryService.ts b/packages/core/src/services/fileHistoryService.ts index a52120886ad..40d7db83fe6 100644 --- a/packages/core/src/services/fileHistoryService.ts +++ b/packages/core/src/services/fileHistoryService.ts @@ -14,7 +14,7 @@ import { stat, unlink, } from 'node:fs/promises'; -import { dirname, isAbsolute, join, relative } from 'node:path'; +import { dirname, isAbsolute, join, relative, sep } from 'node:path'; import { diffLines } from 'diff'; import { Storage } from '../config/storage.js'; import { createDebugLogger } from '../utils/debugLogger.js'; @@ -632,7 +632,7 @@ export class FileHistoryService { private maybeShortenFilePath(filePath: string): string { if (!isAbsolute(filePath)) return filePath; - if (filePath.startsWith(this.cwd + '/') || filePath === this.cwd) { + if (filePath.startsWith(this.cwd + sep) || filePath === this.cwd) { return relative(this.cwd, filePath); } return filePath; From ceb2e7cfd7677a7fbdae32ccaaa14c3df25ad760 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Sat, 16 May 2026 00:10:20 +0800 Subject: [PATCH 27/31] test(rewind): cover trackEdit best-effort guarantees and unchanged-file rewind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - edit.test.ts: assert tool still completes (file written, llmContent reflects the edit) when FileHistoryService.trackEdit rejects. - write-file.test.ts: same for the write_file tool. - fileHistoryService.test.ts: assert trackEdit swallows createBackup failures (forced via storageDir-replaced-with-file → ENOTDIR in recursive mkdir) without recording any backup. - fileHistoryService.test.ts: assert applySnapshot leaves a file untouched (mtime unchanged, filesChanged empty) when its content already matches the target backup — covers the checkOriginFileChanged short-circuit. --- .../src/services/fileHistoryService.test.ts | 44 ++++++++++++++++++- packages/core/src/tools/edit.test.ts | 27 ++++++++++++ packages/core/src/tools/write-file.test.ts | 35 +++++++++++++++ 3 files changed, 105 insertions(+), 1 deletion(-) diff --git a/packages/core/src/services/fileHistoryService.test.ts b/packages/core/src/services/fileHistoryService.test.ts index 6ba6ee36ac9..b12f67448c6 100644 --- a/packages/core/src/services/fileHistoryService.test.ts +++ b/packages/core/src/services/fileHistoryService.test.ts @@ -5,7 +5,7 @@ */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { mkdtemp, rm, writeFile, readFile } from 'node:fs/promises'; +import { mkdtemp, rm, stat, writeFile, readFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -94,6 +94,24 @@ describe('FileHistoryService', () => { const key = Object.keys(backups)[0]; expect(backups[key].backupFileName).toBeNull(); }); + + // trackEdit must swallow createBackup failures so that the calling tool + // (edit / write_file) is never broken by file-history-side I/O errors. + it('does not throw and records nothing when createBackup fails', async () => { + const file = join(projectDir, 'a.txt'); + await writeFile(file, 'original'); + await service.makeSnapshot('p1'); + + // Replace the backup storage root with a regular file so the recursive + // `mkdir(dirname(backupPath))` inside `safeCopyFile` fails with + // ENOTDIR — a non-ENOENT error that propagates back into `trackEdit`'s + // catch. + await rm(storageDir, { recursive: true, force: true }); + await writeFile(storageDir, ''); + + await expect(service.trackEdit(file)).resolves.toBeUndefined(); + expect(service.getSnapshots()[0].trackedFileBackups).toEqual({}); + }); }); describe('makeSnapshot', () => { @@ -267,6 +285,30 @@ describe('FileHistoryService', () => { const after = service.getSnapshots(); expect(after.map((s) => s.promptId)).toEqual(['p1', 'p2', 'p3']); }); + + // checkOriginFileChanged short-circuits the restore when the file on + // disk already matches the target backup. Cover it explicitly so a + // future regression in stat/content comparison surfaces here instead + // of as silent extra writes (or skipped writes) to user files. + it('does not touch a file whose content matches the target snapshot', async () => { + const file = join(projectDir, 'a.txt'); + await writeFile(file, 'unchanged'); + + await service.makeSnapshot('p1'); + await service.trackEdit(file); + await service.makeSnapshot('p2'); + + // File content has not changed since p1 was tracked. Capture mtime so + // we can verify the file is not rewritten by the rewind. + const mtimeBefore = (await stat(file)).mtimeMs; + + const result = await service.rewind('p1'); + + expect(result.filesChanged).toEqual([]); + expect(result.filesFailed).toEqual([]); + expect(await readFile(file, 'utf-8')).toBe('unchanged'); + expect((await stat(file)).mtimeMs).toBe(mtimeBefore); + }); }); describe('trackEdit before any snapshot', () => { diff --git a/packages/core/src/tools/edit.test.ts b/packages/core/src/tools/edit.test.ts index 08672a17f77..4975a5a98da 100644 --- a/packages/core/src/tools/edit.test.ts +++ b/packages/core/src/tools/edit.test.ts @@ -507,6 +507,33 @@ describe('EditTool', () => { expect(display.fileName).toBe(testFile); }); + // trackEdit is best-effort: a FileHistoryService failure (disk full, + // permissions, corrupted state) must never break the edit tool. + it('completes the edit even when trackEdit throws', async () => { + const initialContent = 'This is some old text.'; + const newContent = 'This is some new text.'; + fs.writeFileSync(filePath, initialContent, 'utf8'); + seedPriorRead(filePath); + mockFileHistoryService.trackEdit.mockRejectedValueOnce( + new Error('disk full'), + ); + + const params: EditToolParams = { + file_path: filePath, + old_string: 'old', + new_string: 'new', + }; + + const invocation = tool.build(params); + const result = await invocation.execute(new AbortController().signal); + + expect(mockFileHistoryService.trackEdit).toHaveBeenCalledWith(filePath); + expect(fs.readFileSync(filePath, 'utf8')).toBe(newContent); + expect(result.llmContent).toMatch( + /Showing lines \d+-\d+ of \d+ from the edited file:/, + ); + }); + // The Edit tool feeds the commit-attribution singleton on success so // commit notes can later report per-file AI/human ratios. Service- // level tests for `recordEdit` already exist; these guard against diff --git a/packages/core/src/tools/write-file.test.ts b/packages/core/src/tools/write-file.test.ts index 36d50ce37d0..5c374c15eb2 100644 --- a/packages/core/src/tools/write-file.test.ts +++ b/packages/core/src/tools/write-file.test.ts @@ -368,6 +368,41 @@ describe('WriteFileTool', () => { ); }); + // trackEdit is best-effort: a FileHistoryService failure (disk full, + // permissions, corrupted state) must never break the write_file tool. + it('completes the write even when trackEdit throws', async () => { + const filePath = path.join(rootDir, 'write_when_trackedit_fails.txt'); + const proposedContent = 'Content that survives trackEdit failure.'; + mockFileHistoryService.trackEdit.mockRejectedValueOnce( + new Error('disk full'), + ); + + const params = { file_path: filePath, content: proposedContent }; + const invocation = tool.build(params); + + const confirmDetails = + await invocation.getConfirmationDetails(abortSignal); + if ( + typeof confirmDetails === 'object' && + 'onConfirm' in confirmDetails && + confirmDetails.onConfirm + ) { + await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce); + } + + const result = await invocation.execute(abortSignal); + + expect(mockFileHistoryService.trackEdit).toHaveBeenCalledWith(filePath); + expect(result.llmContent).toMatch( + /Successfully created and wrote to new file/, + ); + expect(fs.existsSync(filePath)).toBe(true); + const { content: writtenContent } = await fsService.readTextFile({ + path: filePath, + }); + expect(writtenContent).toBe(proposedContent); + }); + it('should overwrite an existing file and return diff', async () => { const filePath = path.join(rootDir, 'execute_existing_file.txt'); const initialContent = 'Initial content for execute.'; From 91fc811eaa47910df090ada07c2b6f5258cef84d Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Sat, 16 May 2026 09:17:27 +0800 Subject: [PATCH 28/31] fix(rewind): align fileCheckpointing default + surface backup-missing on rewind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues from a Codex review pass: - Config: `fileCheckpointingEnabled` defaulted via `params.interactive !== false`, which resolves truthy when the caller omits `interactive` — but `this.interactive` itself defaults to `false`. Headless/programmatic callers that did not set `interactive` would silently start writing file-history backups under `~/.qwen/file-history/`. Use the same `?? false` default so the gate matches the resolved interactive value. - checkOriginFileChanged: when the on-disk backup AND the working file have both been removed externally, the function returned `false` ("unchanged"), so `applySnapshot` skipped `restoreBackup` and rewind reported success even though the target snapshot expected the file to exist. Treat any failure to stat the backup as "changed" so callers attempt the restore: applySnapshot surfaces the missing backup via restoreBackup → filesFailed, makeSnapshot creates a fresh backup. Added a regression test for the both-missing path. --- packages/core/src/config/config.ts | 2 +- .../src/services/fileHistoryService.test.ts | 26 +++++++++++++++++++ .../core/src/services/fileHistoryService.ts | 16 ++++++++---- 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 3df7078eee3..f5f75a9b1c0 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -915,7 +915,7 @@ export class Config { this.checkpointing = params.checkpointing ?? false; this.fileCheckpointingEnabled = params.fileCheckpointingEnabled ?? - (!params.sdkMode && params.interactive !== false); + (!params.sdkMode && (params.interactive ?? false)); this.proxy = params.proxy; this.cwd = params.cwd ?? process.cwd(); this.fileDiscoveryService = params.fileDiscoveryService ?? null; diff --git a/packages/core/src/services/fileHistoryService.test.ts b/packages/core/src/services/fileHistoryService.test.ts index b12f67448c6..66737322831 100644 --- a/packages/core/src/services/fileHistoryService.test.ts +++ b/packages/core/src/services/fileHistoryService.test.ts @@ -218,6 +218,32 @@ describe('FileHistoryService', () => { expect(result.filesFailed.length).toBeGreaterThan(0); }); + // Edge case: both the on-disk backup and the working file have been + // removed externally. The target snapshot still expects the file to + // exist, so rewind must surface this as filesFailed instead of + // silently reporting success. + it('should report filesFailed when both backup and working file are gone', async () => { + const file = join(projectDir, 'a.txt'); + await writeFile(file, 'original'); + + await service.makeSnapshot('p1'); + await service.trackEdit(file); + await writeFile(file, 'modified'); + await service.makeSnapshot('p2'); + + const snapshots = service.getSnapshots(); + const backupName = + snapshots[0].trackedFileBackups['a.txt']!.backupFileName!; + await rm(join(storageDir, 'file-history', 'test-session', backupName), { + force: true, + }); + await rm(file, { force: true }); + + const result = await service.rewind('p1'); + expect(result.filesChanged).toEqual([]); + expect(result.filesFailed.length).toBeGreaterThan(0); + }); + it('should preserve snapshot timeline when truncateHistory=false', async () => { const file = join(projectDir, 'a.txt'); await writeFile(file, 'original'); diff --git a/packages/core/src/services/fileHistoryService.ts b/packages/core/src/services/fileHistoryService.ts index 40d7db83fe6..89901ca27f6 100644 --- a/packages/core/src/services/fileHistoryService.ts +++ b/packages/core/src/services/fileHistoryService.ts @@ -192,15 +192,21 @@ async function checkOriginFileChanged( } } - let backupStats: Stats | null = null; + // Treat any failure to stat the backup (including ENOENT) as "changed" so + // callers attempt the restore: applySnapshot will surface the missing + // backup via restoreBackup → filesFailed, and makeSnapshot will create a + // fresh backup. The previous ENOENT branch silently reported "unchanged" + // when both the working file and the backup had been deleted, which let + // rewind report success even though the snapshot expected the file to + // exist. + let backupStats: Stats; try { backupStats = await stat(backupPath); - } catch (e: unknown) { - if (!isENOENT(e)) return true; + } catch { + return true; } - if ((originalStats === null) !== (backupStats === null)) return true; - if (originalStats === null || backupStats === null) return false; + if (originalStats === null) return true; if ( originalStats.mode !== backupStats.mode || From d5983833843c1d41940e0176c0802a073aaee8df Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Sat, 16 May 2026 16:20:47 +0800 Subject: [PATCH 29/31] fix(rewind): mark per-file backup failures so rewind surfaces them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related issues from a /review pass: 1. Silent data loss in makeSnapshot inheritance: when the per-file backup attempt threw inside makeSnapshot, the catch block left the path missing from `trackedFileBackups`, and the inheritance loop then copied the previous snapshot's backup into the new snapshot. A later rewind to that snapshot would restore older content while reporting success. Now the catch records `{ failed: true, ... }` for the path. The inheritance loop skips paths already present in trackedFileBackups, so failed paths are no longer paved over by stale carryover. Both applySnapshot and getDiffStats honor `failed` — rewind pushes the path to filesFailed and the diff preview omits it. 2. Marketing/scope mismatch: the rewind UI offers "Restore code" but the feature only tracks edits made via the `edit` and `write_file` tools — shell-mediated changes (`sed -i`, `cp`, `rm`, `mv`, `npm`, etc.) and out-of-tool manual edits are not captured. Added a class-level JSDoc on FileHistoryService spelling out the scope, and an inline footer in the restore-options panel: "Rewinding does not affect files edited manually or via shell commands." (matching the upstream claude-code MessageSelector wording). New i18n key in all 9 locales. Test added: trackEdit/makeSnapshot per-file failure path. Asserts the new snapshot has `failed: true`, and that rewind to that snapshot reports the file as filesFailed instead of silently restoring the inherited stale backup. --- packages/cli/src/i18n/locales/ca.js | 2 + packages/cli/src/i18n/locales/de.js | 2 + packages/cli/src/i18n/locales/en.js | 2 + packages/cli/src/i18n/locales/fr.js | 2 + packages/cli/src/i18n/locales/ja.js | 2 + packages/cli/src/i18n/locales/pt.js | 2 + packages/cli/src/i18n/locales/ru.js | 2 + packages/cli/src/i18n/locales/zh-TW.js | 2 + packages/cli/src/i18n/locales/zh.js | 2 + .../cli/src/ui/components/RewindSelector.tsx | 11 +++++ .../src/services/fileHistoryService.test.ts | 34 ++++++++++++++ .../core/src/services/fileHistoryService.ts | 44 +++++++++++++++++++ 12 files changed, 107 insertions(+) diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index 2ac1b5414fa..75ac273d67b 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -150,6 +150,8 @@ export default { 'Codi restaurat, però la conversa no s’ha pogut retrocedir (cap client actiu).', 'Conversation rewound. Edit your prompt and press Enter to continue.': 'Conversa retrocedida. Edita la teva indicació i prem Retorn per continuar.', + 'Rewinding does not affect files edited manually or via shell commands.': + 'El retrocés no afecta els fitxers editats manualment o mitjançant comandes de shell.', 'Failed to restore {{count}} file(s): {{files}}': 'Error en restaurar {{count}} fitxer(s): {{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 581061c167e..a6079d20e3f 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -129,6 +129,8 @@ export default { 'Code wiederhergestellt, aber Konversation konnte nicht zurückgespult werden (kein aktiver Client).', 'Conversation rewound. Edit your prompt and press Enter to continue.': 'Konversation zurückgespult. Bearbeite deinen Prompt und drücke Enter, um fortzufahren.', + 'Rewinding does not affect files edited manually or via shell commands.': + 'Das Zurückspulen wirkt sich nicht auf Dateien aus, die manuell oder per Shell-Befehl geändert wurden.', 'Failed to restore {{count}} file(s): {{files}}': '{{count}} Datei(en) konnten nicht wiederhergestellt werden: {{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index df21b820926..ab19d1f1b97 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -150,6 +150,8 @@ export default { 'Code restored, but conversation could not be rewound (no active client).', 'Conversation rewound. Edit your prompt and press Enter to continue.': 'Conversation rewound. Edit your prompt and press Enter to continue.', + 'Rewinding does not affect files edited manually or via shell commands.': + 'Rewinding does not affect files edited manually or via shell commands.', 'Failed to restore {{count}} file(s): {{files}}': 'Failed to restore {{count}} file(s): {{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index 0424ed7cece..0292418a806 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -150,6 +150,8 @@ export default { 'Code restauré, mais la conversation n’a pas pu être ramenée en arrière (aucun client actif).', 'Conversation rewound. Edit your prompt and press Enter to continue.': 'Conversation ramenée en arrière. Modifiez votre invite et appuyez sur Entrée pour continuer.', + 'Rewinding does not affect files edited manually or via shell commands.': + 'Le retour en arrière n’affecte pas les fichiers édités manuellement ou via des commandes shell.', 'Failed to restore {{count}} file(s): {{files}}': 'Échec de la restauration de {{count}} fichier(s) : {{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index fa89cb3f290..52acec9bde7 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -110,6 +110,8 @@ export default { 'コードは復元されましたが、会話は巻き戻せませんでした(モデルクライアントがアクティブではありません)。', 'Conversation rewound. Edit your prompt and press Enter to continue.': '会話を巻き戻しました。プロンプトを編集して Enter キーで続行してください。', + 'Rewinding does not affect files edited manually or via shell commands.': + '巻き戻しは、手動で編集されたファイルや shell コマンドで変更されたファイルには影響しません。', 'Failed to restore {{count}} file(s): {{files}}': '{{count}} 個のファイルの復元に失敗しました:{{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index c3459c918ab..411ade85d8c 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -144,6 +144,8 @@ export default { 'Código restaurado, mas a conversa não pôde ser retrocedida (sem cliente ativo).', 'Conversation rewound. Edit your prompt and press Enter to continue.': 'Conversa retrocedida. Edite seu prompt e pressione Enter para continuar.', + 'Rewinding does not affect files edited manually or via shell commands.': + 'O retrocesso não afeta arquivos editados manualmente ou por meio de comandos shell.', 'Failed to restore {{count}} file(s): {{files}}': 'Falha ao restaurar {{count}} arquivo(s): {{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index f683e04552d..43c63430e1f 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -153,6 +153,8 @@ export default { 'Код восстановлен, но разговор не удалось откатить (нет активного клиента).', 'Conversation rewound. Edit your prompt and press Enter to continue.': 'Разговор откатили. Отредактируйте подсказку и нажмите Enter, чтобы продолжить.', + 'Rewinding does not affect files edited manually or via shell commands.': + 'Откат не затрагивает файлы, отредактированные вручную или с помощью shell-команд.', 'Failed to restore {{count}} file(s): {{files}}': 'Не удалось восстановить {{count}} файл(ов): {{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index f919ae2ef93..a95131d4ba2 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -133,6 +133,8 @@ export default { '程式碼已恢復,但對話無法回退(模型客戶端未啟用)。', 'Conversation rewound. Edit your prompt and press Enter to continue.': '對話已回退。修改提示後按 Enter 繼續。', + 'Rewinding does not affect files edited manually or via shell commands.': + '回退不會影響手動編輯或透過 shell 命令修改的檔案。', 'Failed to restore {{count}} file(s): {{files}}': '恢復 {{count}} 個檔案失敗:{{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index d08e9695aaa..98b6c8c5a45 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -144,6 +144,8 @@ export default { '代码已恢复,但对话无法回退(模型客户端未激活)。', 'Conversation rewound. Edit your prompt and press Enter to continue.': '对话已回退。修改你的提示后按回车继续。', + 'Rewinding does not affect files edited manually or via shell commands.': + '回退不会影响手工编辑或通过 shell 命令修改的文件。', 'Failed to restore {{count}} file(s): {{files}}': '恢复 {{count}} 个文件失败:{{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/ui/components/RewindSelector.tsx b/packages/cli/src/ui/components/RewindSelector.tsx index d22a3a19658..0bd829d8825 100644 --- a/packages/cli/src/ui/components/RewindSelector.tsx +++ b/packages/cli/src/ui/components/RewindSelector.tsx @@ -420,6 +420,17 @@ export function RewindSelector({ ); })} + {restoreOptions.some( + (o) => o.key === 'code' || o.key === 'both', + ) && ( + + + {t( + 'Rewinding does not affect files edited manually or via shell commands.', + )} + + + )} )} diff --git a/packages/core/src/services/fileHistoryService.test.ts b/packages/core/src/services/fileHistoryService.test.ts index 66737322831..6ae47d07bbf 100644 --- a/packages/core/src/services/fileHistoryService.test.ts +++ b/packages/core/src/services/fileHistoryService.test.ts @@ -158,6 +158,40 @@ describe('FileHistoryService', () => { snapshots[0].trackedFileBackups[p1Key].backupFileName, ); }); + + // When a per-file backup attempt throws inside makeSnapshot, the new + // snapshot must NOT silently inherit the previous snapshot's backup + // and present it as the captured state of this turn — that would + // make a later rewind restore older content while reporting success. + // Instead the snapshot records a `failed: true` marker so rewind + // surfaces the file via filesFailed and getDiffStats omits it. + it('marks per-file backup failures and does not silently inherit', async () => { + const file = join(projectDir, 'a.txt'); + await writeFile(file, 'p1-content'); + + await service.makeSnapshot('p1'); + await service.trackEdit(file); + + // Modify the file and break the backup target (replace storageDir + // with a regular file → ENOTDIR inside `safeCopyFile`'s recursive + // mkdir). The next makeSnapshot's per-file backup attempt throws. + await writeFile(file, 'p2-content'); + await rm(storageDir, { recursive: true, force: true }); + await writeFile(storageDir, ''); + + await service.makeSnapshot('p2'); + + const p2Backups = service.getSnapshots()[1].trackedFileBackups; + const p2Backup = p2Backups['a.txt']; + expect(p2Backup).toBeDefined(); + expect(p2Backup.failed).toBe(true); + + // Rewind to p2 must report the file as failed, not silently + // restore p1-content as if it were the captured state of p2. + const result = await service.rewind('p2'); + expect(result.filesChanged).toEqual([]); + expect(result.filesFailed).toContain(file); + }); }); describe('rewind', () => { diff --git a/packages/core/src/services/fileHistoryService.ts b/packages/core/src/services/fileHistoryService.ts index 89901ca27f6..afb80b016d3 100644 --- a/packages/core/src/services/fileHistoryService.ts +++ b/packages/core/src/services/fileHistoryService.ts @@ -27,6 +27,13 @@ export interface FileHistoryBackup { backupFileName: BackupFileName; version: number; backupTime: Date; + // Set when makeSnapshot's per-file backup attempt threw. Distinguishes + // "we have a confirmed backup of this file at this snapshot" from + // "we tried to capture this file at this snapshot but failed (so the + // attached backup, if any, is older than this turn)". Rewind / diff + // surface failed paths via filesFailed instead of silently restoring + // stale content as if it were current. + failed?: boolean; } export interface FileHistorySnapshot { @@ -265,6 +272,17 @@ async function computeDiffStatsForFile( return { filesChanged, insertions, deletions }; } +/** + * 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 + * turn boundary. + * + * Scope (intentional, mirrors upstream claude-code): only files touched + * via `edit` and `write_file` are tracked. Changes made via + * `run_shell_command` (`sed -i`, `cp`, `mv`, `rm`, `npm` scripts, `git` + * apply, etc.) and any out-of-tool manual edits are NOT captured, and + * `/rewind` cannot restore them. + */ export class FileHistoryService { private state: FileHistoryState = { snapshots: [], @@ -395,6 +413,18 @@ export class FileHistoryService { debugLogger.error( `FileHistory: Failed to backup file ${trackingPath}: ${error}`, ); + // Record the failure rather than letting the inheritance loop + // silently copy the previous snapshot's backup — that would + // make a rewind to this snapshot restore the file to its + // pre-failure content as if it were the captured state of + // this turn. + const previous = mostRecent?.trackedFileBackups[trackingPath]; + trackedFileBackups[trackingPath] = { + backupFileName: previous?.backupFileName ?? null, + version: this.getMaxVersion(trackingPath) + 1, + backupTime: new Date(), + failed: true, + }; } }), ); @@ -469,6 +499,12 @@ export class FileHistoryService { const filePath = this.maybeExpandFilePath(trackingPath); const targetBackup = targetSnapshot.trackedFileBackups[trackingPath]; + // The backup attempt failed at the target snapshot; we cannot + // produce a meaningful diff against a content we never captured, + // so omit this file from the preview rather than show a diff + // versus an older inherited backup. + if (targetBackup?.failed) return null; + const backupFileName: BackupFileName | undefined = targetBackup ? targetBackup.backupFileName : this.getBackupFileNameFirstVersion(trackingPath); @@ -527,6 +563,14 @@ export class FileHistoryService { const filePath = this.maybeExpandFilePath(trackingPath); const targetBackup = targetSnapshot.trackedFileBackups[trackingPath]; + // makeSnapshot couldn't capture this file at the target turn. + // Surface it as failed instead of restoring the carried-over + // (older) backup as if it were the captured state. + if (targetBackup?.failed) { + filesFailed.push(filePath); + continue; + } + const backupFileName: BackupFileName | undefined = targetBackup ? targetBackup.backupFileName : this.getBackupFileNameFirstVersion(trackingPath); From 22d596933794cee5bfad61879a1a32b1729466a2 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Sat, 16 May 2026 16:54:52 +0800 Subject: [PATCH 30/31] =?UTF-8?q?fix(rewind):=20polish=20=E2=80=94=20i18n,?= =?UTF-8?q?=20type=20tightening,=20resumed-session=20UX=20hint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several small wins from the latest /review pass plus a UX mitigation for turns whose file-history snapshot is not present in memory (most often because the conversation came from a resumed session, but also when a turn has no captured edits): - AppContainer: wrap the "Cannot rewind to a turn that was compressed" error in t(); add the new key to all 9 locales. - RewindSelector: replace the inline `(+N -M in K file/files)` template literal with t() using two plural-aware keys; add to all 9 locales. - DiffStats.filesChanged: tighten from optional to required to match reality (every code path that returns a DiffStats sets it). Drops the `!.filesChanged!` non-null cascade in RewindSelector. - RewindSelector phase 2: when the option list does not contain code/both (i.e. no file-restore is actionable for this turn), show an explicit hint instead of leaving the user to guess why those options are missing. Same i18n key in all 9 locales. The mitigation hint covers the resumed-session case Tan raised (snapshots are not rehydrated by `/resume` today) without changing behavior — `getRestoreOptions` already gracefully degrades to conversation-only when `getDiffStats` returns undefined for a snapshot that is not in memory; we just surface the "why" to the user. --- packages/cli/src/i18n/locales/ca.js | 8 +++++ packages/cli/src/i18n/locales/de.js | 8 +++++ packages/cli/src/i18n/locales/en.js | 8 +++++ packages/cli/src/i18n/locales/fr.js | 8 +++++ packages/cli/src/i18n/locales/ja.js | 8 +++++ packages/cli/src/i18n/locales/pt.js | 8 +++++ packages/cli/src/i18n/locales/ru.js | 8 +++++ packages/cli/src/i18n/locales/zh-TW.js | 8 +++++ packages/cli/src/i18n/locales/zh.js | 8 +++++ packages/cli/src/ui/AppContainer.tsx | 4 ++- .../cli/src/ui/components/RewindSelector.tsx | 33 ++++++++++++++++--- .../core/src/services/fileHistoryService.ts | 2 +- 12 files changed, 104 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index 75ac273d67b..7070b3d5936 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -152,6 +152,14 @@ export default { 'Conversa retrocedida. Edita la teva indicació i prem Retorn per continuar.', 'Rewinding does not affect files edited manually or via shell commands.': 'El retrocés no afecta els fitxers editats manualment o mitjançant comandes de shell.', + 'Cannot rewind to a turn that was compressed. Try a more recent turn.': + 'No es pot retrocedir a un torn que ha estat comprimit. Prova amb un torn més recent.', + 'File restore is unavailable for this turn (no captured file changes, or this turn predates the current session).': + 'La restauració de fitxers no està disponible per a aquest torn (no s’han capturat canvis, o aquest torn és anterior a la sessió actual).', + '(+{{insertions}} -{{deletions}} in {{count}} file)': + '(+{{insertions}} -{{deletions}} en {{count}} fitxer)', + '(+{{insertions}} -{{deletions}} in {{count}} files)': + '(+{{insertions}} -{{deletions}} en {{count}} fitxers)', 'Failed to restore {{count}} file(s): {{files}}': 'Error en restaurar {{count}} fitxer(s): {{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index a6079d20e3f..6d855a1ef5e 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -131,6 +131,14 @@ export default { 'Konversation zurückgespult. Bearbeite deinen Prompt und drücke Enter, um fortzufahren.', 'Rewinding does not affect files edited manually or via shell commands.': 'Das Zurückspulen wirkt sich nicht auf Dateien aus, die manuell oder per Shell-Befehl geändert wurden.', + 'Cannot rewind to a turn that was compressed. Try a more recent turn.': + 'Zu einem komprimierten Turn kann nicht zurückgespult werden. Bitte einen aktuelleren Turn versuchen.', + 'File restore is unavailable for this turn (no captured file changes, or this turn predates the current session).': + 'Datei-Wiederherstellung ist für diesen Turn nicht verfügbar (keine erfassten Dateiänderungen, oder dieser Turn liegt vor der aktuellen Sitzung).', + '(+{{insertions}} -{{deletions}} in {{count}} file)': + '(+{{insertions}} -{{deletions}} in {{count}} Datei)', + '(+{{insertions}} -{{deletions}} in {{count}} files)': + '(+{{insertions}} -{{deletions}} in {{count}} Dateien)', 'Failed to restore {{count}} file(s): {{files}}': '{{count}} Datei(en) konnten nicht wiederhergestellt werden: {{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index ab19d1f1b97..e1b931cfdd3 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -152,6 +152,14 @@ export default { 'Conversation rewound. Edit your prompt and press Enter to continue.', 'Rewinding does not affect files edited manually or via shell commands.': 'Rewinding does not affect files edited manually or via shell commands.', + 'Cannot rewind to a turn that was compressed. Try a more recent turn.': + 'Cannot rewind to a turn that was compressed. Try a more recent turn.', + 'File restore is unavailable for this turn (no captured file changes, or this turn predates the current session).': + 'File restore is unavailable for this turn (no captured file changes, or this turn predates the current session).', + '(+{{insertions}} -{{deletions}} in {{count}} file)': + '(+{{insertions}} -{{deletions}} in {{count}} file)', + '(+{{insertions}} -{{deletions}} in {{count}} files)': + '(+{{insertions}} -{{deletions}} in {{count}} files)', 'Failed to restore {{count}} file(s): {{files}}': 'Failed to restore {{count}} file(s): {{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index 0292418a806..cfce04e60ea 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -152,6 +152,14 @@ export default { 'Conversation ramenée en arrière. Modifiez votre invite et appuyez sur Entrée pour continuer.', 'Rewinding does not affect files edited manually or via shell commands.': 'Le retour en arrière n’affecte pas les fichiers édités manuellement ou via des commandes shell.', + 'Cannot rewind to a turn that was compressed. Try a more recent turn.': + 'Impossible de revenir à un tour qui a été compressé. Essayez un tour plus récent.', + 'File restore is unavailable for this turn (no captured file changes, or this turn predates the current session).': + 'La restauration des fichiers est indisponible pour ce tour (aucune modification capturée, ou ce tour est antérieur à la session actuelle).', + '(+{{insertions}} -{{deletions}} in {{count}} file)': + '(+{{insertions}} -{{deletions}} dans {{count}} fichier)', + '(+{{insertions}} -{{deletions}} in {{count}} files)': + '(+{{insertions}} -{{deletions}} dans {{count}} fichiers)', 'Failed to restore {{count}} file(s): {{files}}': 'Échec de la restauration de {{count}} fichier(s) : {{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 52acec9bde7..9b7e9a49318 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -112,6 +112,14 @@ export default { '会話を巻き戻しました。プロンプトを編集して Enter キーで続行してください。', 'Rewinding does not affect files edited manually or via shell commands.': '巻き戻しは、手動で編集されたファイルや shell コマンドで変更されたファイルには影響しません。', + 'Cannot rewind to a turn that was compressed. Try a more recent turn.': + '圧縮されたターンへは巻き戻せません。より最近のターンをお試しください。', + 'File restore is unavailable for this turn (no captured file changes, or this turn predates the current session).': + 'このターンではファイル復元できません(捕捉されたファイル変更がないか、現在のセッションより前のターンです)。', + '(+{{insertions}} -{{deletions}} in {{count}} file)': + '(+{{insertions}} -{{deletions}}、{{count}} 個のファイル)', + '(+{{insertions}} -{{deletions}} in {{count}} files)': + '(+{{insertions}} -{{deletions}}、{{count}} 個のファイル)', 'Failed to restore {{count}} file(s): {{files}}': '{{count}} 個のファイルの復元に失敗しました:{{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 411ade85d8c..b09eabc4fc4 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -146,6 +146,14 @@ export default { 'Conversa retrocedida. Edite seu prompt e pressione Enter para continuar.', 'Rewinding does not affect files edited manually or via shell commands.': 'O retrocesso não afeta arquivos editados manualmente ou por meio de comandos shell.', + 'Cannot rewind to a turn that was compressed. Try a more recent turn.': + 'Não é possível retroceder para um turno que foi compactado. Tente um turno mais recente.', + 'File restore is unavailable for this turn (no captured file changes, or this turn predates the current session).': + 'A restauração de arquivos não está disponível para este turno (sem alterações capturadas, ou o turno é anterior à sessão atual).', + '(+{{insertions}} -{{deletions}} in {{count}} file)': + '(+{{insertions}} -{{deletions}} em {{count}} arquivo)', + '(+{{insertions}} -{{deletions}} in {{count}} files)': + '(+{{insertions}} -{{deletions}} em {{count}} arquivos)', 'Failed to restore {{count}} file(s): {{files}}': 'Falha ao restaurar {{count}} arquivo(s): {{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 43c63430e1f..2d5296501fe 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -155,6 +155,14 @@ export default { 'Разговор откатили. Отредактируйте подсказку и нажмите Enter, чтобы продолжить.', 'Rewinding does not affect files edited manually or via shell commands.': 'Откат не затрагивает файлы, отредактированные вручную или с помощью shell-команд.', + 'Cannot rewind to a turn that was compressed. Try a more recent turn.': + 'Не удаётся откатиться к сжатому ходу. Попробуйте более недавний ход.', + 'File restore is unavailable for this turn (no captured file changes, or this turn predates the current session).': + 'Восстановление файлов недоступно для этого хода (нет записанных изменений или ход был до текущей сессии).', + '(+{{insertions}} -{{deletions}} in {{count}} file)': + '(+{{insertions}} -{{deletions}} в {{count}} файле)', + '(+{{insertions}} -{{deletions}} in {{count}} files)': + '(+{{insertions}} -{{deletions}} в {{count}} файлах)', 'Failed to restore {{count}} file(s): {{files}}': 'Не удалось восстановить {{count}} файл(ов): {{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index a95131d4ba2..06aa4efc4e3 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -135,6 +135,14 @@ export default { '對話已回退。修改提示後按 Enter 繼續。', 'Rewinding does not affect files edited manually or via shell commands.': '回退不會影響手動編輯或透過 shell 命令修改的檔案。', + 'Cannot rewind to a turn that was compressed. Try a more recent turn.': + '無法回退到已被壓縮的輪次,請嘗試更近一些的輪次。', + 'File restore is unavailable for this turn (no captured file changes, or this turn predates the current session).': + '該輪次無法還原檔案(沒有擷取到檔案變更,或該輪次屬於本次會話之前)。', + '(+{{insertions}} -{{deletions}} in {{count}} file)': + '(+{{insertions}} -{{deletions}},{{count}} 個檔案)', + '(+{{insertions}} -{{deletions}} in {{count}} files)': + '(+{{insertions}} -{{deletions}},{{count}} 個檔案)', 'Failed to restore {{count}} file(s): {{files}}': '恢復 {{count}} 個檔案失敗:{{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 98b6c8c5a45..314b2240bc7 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -146,6 +146,14 @@ export default { '对话已回退。修改你的提示后按回车继续。', 'Rewinding does not affect files edited manually or via shell commands.': '回退不会影响手工编辑或通过 shell 命令修改的文件。', + 'Cannot rewind to a turn that was compressed. Try a more recent turn.': + '无法回退到已被压缩的轮次,请尝试更近一些的轮次。', + 'File restore is unavailable for this turn (no captured file changes, or this turn predates the current session).': + '该轮次无法恢复文件(没有捕获到文件变更,或该轮次属于本次会话之前)。', + '(+{{insertions}} -{{deletions}} in {{count}} file)': + '(+{{insertions}} -{{deletions}},{{count}} 个文件)', + '(+{{insertions}} -{{deletions}} in {{count}} files)': + '(+{{insertions}} -{{deletions}},{{count}} 个文件)', 'Failed to restore {{count}} file(s): {{files}}': '恢复 {{count}} 个文件失败:{{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 2c67bc94b04..8b9e5c511cd 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -2216,7 +2216,9 @@ export const AppContainer = (props: AppContainerProps) => { historyManager.addItem( { type: 'error', - text: 'Cannot rewind to a turn that was compressed. Try a more recent turn.', + text: t( + 'Cannot rewind to a turn that was compressed. Try a more recent turn.', + ), }, Date.now(), ); diff --git a/packages/cli/src/ui/components/RewindSelector.tsx b/packages/cli/src/ui/components/RewindSelector.tsx index 0bd829d8825..b6e95d1efb9 100644 --- a/packages/cli/src/ui/components/RewindSelector.tsx +++ b/packages/cli/src/ui/components/RewindSelector.tsx @@ -102,14 +102,22 @@ interface RestoreOptionItem { function getRestoreOptions( diffStats: DiffStats | undefined, ): RestoreOptionItem[] { - const hasChanges = - diffStats && diffStats.filesChanged && diffStats.filesChanged.length > 0; + const hasChanges = !!diffStats && diffStats.filesChanged.length > 0; const options: RestoreOptionItem[] = []; if (hasChanges) { - const fileCount = diffStats!.filesChanged!.length; - const detail = `(+${diffStats!.insertions} -${diffStats!.deletions} in ${fileCount} file${fileCount !== 1 ? 's' : ''})`; + const fileCount = diffStats!.filesChanged.length; + const detail = t( + fileCount === 1 + ? '(+{{insertions}} -{{deletions}} in {{count}} file)' + : '(+{{insertions}} -{{deletions}} in {{count}} files)', + { + insertions: String(diffStats!.insertions), + deletions: String(diffStats!.deletions), + count: String(fileCount), + }, + ); options.push({ key: 'both', label: t('Restore code and conversation'), @@ -422,7 +430,7 @@ export function RewindSelector({ })} {restoreOptions.some( (o) => o.key === 'code' || o.key === 'both', - ) && ( + ) ? ( {t( @@ -430,6 +438,21 @@ export function RewindSelector({ )} + ) : ( + // No file-restore options were offered. Most likely either + // (a) the chosen turn has no captured edits, or (b) the + // turn predates this process / came from a resumed session + // whose snapshots were not rehydrated. Either way the + // "Restore code" path is not actionable for this turn — + // surface that explicitly so the user is not left + // wondering why the option is missing. + + + {t( + 'File restore is unavailable for this turn (no captured file changes, or this turn predates the current session).', + )} + + )} )} diff --git a/packages/core/src/services/fileHistoryService.ts b/packages/core/src/services/fileHistoryService.ts index afb80b016d3..c9b7649327d 100644 --- a/packages/core/src/services/fileHistoryService.ts +++ b/packages/core/src/services/fileHistoryService.ts @@ -48,7 +48,7 @@ export interface FileHistoryState { } export interface DiffStats { - filesChanged?: string[]; + filesChanged: string[]; insertions: number; deletions: number; } From ac07f63e8991d1d702c64202a4aead1847f33dd7 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Sat, 16 May 2026 21:29:12 +0800 Subject: [PATCH 31/31] fix(rewind): unstick failed marker on the unchanged-file fast path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `failed: true` marker added in d59838338 was sticky: once set, the no-change optimization in `makeSnapshot` would copy the failed entry forward into every subsequent snapshot for as long as the file stayed unchanged. A single transient I/O error therefore poisoned `/rewind` for that file until the user happened to modify the content again. Add `!latestBackup.failed` to the no-change reuse guard so a failed entry is never copied forward — the next snapshot retries the backup, which either heals (when the underlying I/O has recovered) or honestly records another failed entry. New regression test (`does not carry a failed marker forward when the file is unchanged`): - Snapshot p1 with file content X - Sabotage the storage dir → p2's per-file backup throws → p2 records failed: true - Restore the storage dir; file still equals X - p3 must NOT copy p2's failed entry; it must retry createBackup and produce a fresh non-failed entry that allows rewind to p3 to succeed --- .../src/services/fileHistoryService.test.ts | 48 ++++++++++++++++++- .../core/src/services/fileHistoryService.ts | 9 ++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/packages/core/src/services/fileHistoryService.test.ts b/packages/core/src/services/fileHistoryService.test.ts index 6ae47d07bbf..c7f2f7c0907 100644 --- a/packages/core/src/services/fileHistoryService.test.ts +++ b/packages/core/src/services/fileHistoryService.test.ts @@ -5,7 +5,14 @@ */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { mkdtemp, rm, stat, writeFile, readFile } from 'node:fs/promises'; +import { + mkdir, + mkdtemp, + rm, + stat, + writeFile, + readFile, +} from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -192,6 +199,45 @@ describe('FileHistoryService', () => { expect(result.filesChanged).toEqual([]); expect(result.filesFailed).toContain(file); }); + + // After a transient backup failure, the no-change optimization must NOT + // copy the failed entry forward into the next snapshot. If we did, the + // failed flag would stay sticky for as long as the file is unchanged, + // permanently poisoning rewind for that file even after the backup + // target recovers. + it('does not carry a failed marker forward when the file is unchanged', async () => { + const file = join(projectDir, 'a.txt'); + await writeFile(file, 'stable-content'); + + await service.makeSnapshot('p1'); + await service.trackEdit(file); + + // Break the backup target so p2's per-file backup throws; do NOT + // change the file content. + await rm(storageDir, { recursive: true, force: true }); + await writeFile(storageDir, ''); + await service.makeSnapshot('p2'); + expect( + service.getSnapshots()[1].trackedFileBackups['a.txt']!.failed, + ).toBe(true); + + // Restore the backup target. The file is still unchanged. p3 must + // retry the backup (instead of copying p2's failed entry forward) and + // record a fresh non-failed entry. + await rm(storageDir, { recursive: true, force: true }); + await mkdir(storageDir, { recursive: true }); + + await service.makeSnapshot('p3'); + + const p3Backup = service.getSnapshots()[2].trackedFileBackups['a.txt']; + expect(p3Backup).toBeDefined(); + expect(p3Backup.failed).toBeFalsy(); + expect(p3Backup.backupFileName).not.toBeNull(); + + // Rewind to p3 succeeds (file is unchanged but the backup is now real). + const result = await service.rewind('p3'); + expect(result.filesFailed).toEqual([]); + }); }); describe('rewind', () => { diff --git a/packages/core/src/services/fileHistoryService.ts b/packages/core/src/services/fileHistoryService.ts index c9b7649327d..047a1efa580 100644 --- a/packages/core/src/services/fileHistoryService.ts +++ b/packages/core/src/services/fileHistoryService.ts @@ -392,6 +392,7 @@ export class FileHistoryService { if ( latestBackup && + !latestBackup.failed && latestBackup.backupFileName !== null && !(await checkOriginFileChanged( filePath, @@ -400,6 +401,14 @@ export class FileHistoryService { fileStats, )) ) { + // The previous snapshot has a confirmed (non-failed) backup of + // an unchanged file — reuse it. We must NOT reach this branch + // when `latestBackup.failed` is set: copying that entry forward + // would carry the `failed` flag into every subsequent snapshot + // for as long as the file stays unchanged, permanently + // poisoning rewind for that file. Instead we fall through and + // retry `createBackup`, which either heals (transient I/O + // recovered) or honestly records another failed entry. trackedFileBackups[trackingPath] = latestBackup; return; }