From a175566b83e83bfa328503ef3fb75cf43b103643 Mon Sep 17 00:00:00 2001 From: iliya Date: Sun, 15 Mar 2026 12:47:52 +0200 Subject: [PATCH] feat: refresh review diffs after external file changes Watch reviewed files for external edits so stale full diffs reload safely, while local drafts get explicit conflict actions instead of silent overwrites. Made-with: Cursor --- src/main/index.ts | 3 + src/main/ipc/review.ts | 69 +++++++++- src/preload/constants/ipcChannels.ts | 9 ++ src/preload/index.ts | 17 +++ src/renderer/api/httpClient.ts | 9 ++ .../team/review/ChangeReviewDialog.tsx | 114 ++++++++++++++-- .../team/review/ContinuousScrollView.tsx | 9 ++ .../team/review/FileSectionHeader.tsx | 41 ++++++ .../store/slices/changeReviewSlice.ts | 124 ++++++++++++++---- src/shared/types/api.ts | 5 +- test/renderer/store/changeReviewSlice.test.ts | 72 ++++++++++ 11 files changed, 439 insertions(+), 33 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index edd7ca92..5a15ee41 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -50,6 +50,7 @@ import { existsSync } from 'fs'; import { join } from 'path'; import { cleanupEditorState, setEditorMainWindow } from './ipc/editor'; +import { setReviewMainWindow } from './ipc/review'; import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers'; import { startEventLoopLagMonitor } from './services/infrastructure/EventLoopLagMonitor'; import { HttpServer } from './services/infrastructure/HttpServer'; @@ -1219,6 +1220,7 @@ function createWindow(): void { ptyTerminalService.setMainWindow(null); } setEditorMainWindow(null); + setReviewMainWindow(null); cleanupEditorState(); }); @@ -1242,6 +1244,7 @@ function createWindow(): void { ptyTerminalService.setMainWindow(mainWindow); } setEditorMainWindow(mainWindow); + setReviewMainWindow(mainWindow); logger.info('Main window created'); } diff --git a/src/main/ipc/review.ts b/src/main/ipc/review.ts index 1bba8f36..7abb5ccc 100644 --- a/src/main/ipc/review.ts +++ b/src/main/ipc/review.ts @@ -5,12 +5,14 @@ */ import { createIpcWrapper } from '@main/ipc/ipcWrapper'; +import { EditorFileWatcher } from '@main/services/editor'; import { ReviewDecisionStore } from '@main/services/team/ReviewDecisionStore'; import { validateFilePath } from '@main/utils/pathValidation'; import { REVIEW_APPLY_DECISIONS, REVIEW_CHECK_CONFLICT, REVIEW_CLEAR_DECISIONS, + REVIEW_FILE_CHANGE, REVIEW_GET_AGENT_CHANGES, REVIEW_GET_CHANGE_STATS, REVIEW_GET_FILE_CONTENT, @@ -23,9 +25,13 @@ import { REVIEW_REJECT_HUNKS, REVIEW_SAVE_DECISIONS, REVIEW_SAVE_EDITED_FILE, + REVIEW_UNWATCH_FILES, + REVIEW_WATCH_FILES, // eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design } from '@preload/constants/ipcChannels'; import { createLogger } from '@shared/utils/logger'; +import * as fs from 'fs/promises'; +import * as path from 'path'; import type { ChangeExtractorService } from '@main/services/team/ChangeExtractorService'; import type { FileContentResolver } from '@main/services/team/FileContentResolver'; @@ -44,7 +50,7 @@ import type { SnippetDiff, TaskChangeSetV2, } from '@shared/types/review'; -import type { IpcMain, IpcMainInvokeEvent } from 'electron'; +import type { BrowserWindow, IpcMain, IpcMainInvokeEvent } from 'electron'; const wrapReviewHandler = createIpcWrapper('IPC:review'); const logger = createLogger('IPC:review'); @@ -56,6 +62,9 @@ let reviewApplier: ReviewApplierService | null = null; let fileContentResolver: FileContentResolver | null = null; let gitDiffFallback: GitDiffFallback | null = null; const reviewDecisionStore = new ReviewDecisionStore(); +const reviewFileWatcher = new EditorFileWatcher(); +let reviewWatcherProjectRoot: string | null = null; +let reviewMainWindowRef: BrowserWindow | null = null; function getChangeExtractor(): ChangeExtractorService { if (!changeExtractor) throw new Error('Review handlers not initialized'); @@ -103,6 +112,8 @@ export function registerReviewHandlers(ipcMain: IpcMain): void { ipcMain.handle(REVIEW_GET_FILE_CONTENT, handleGetFileContent); // Editable diff ipcMain.handle(REVIEW_SAVE_EDITED_FILE, handleSaveEditedFile); + ipcMain.handle(REVIEW_WATCH_FILES, handleWatchReviewFiles); + ipcMain.handle(REVIEW_UNWATCH_FILES, handleUnwatchReviewFiles); // Phase 4 ipcMain.handle(REVIEW_GET_GIT_FILE_LOG, handleGetGitFileLog); // Decision persistence @@ -126,12 +137,20 @@ export function removeReviewHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(REVIEW_GET_FILE_CONTENT); // Editable diff ipcMain.removeHandler(REVIEW_SAVE_EDITED_FILE); + ipcMain.removeHandler(REVIEW_WATCH_FILES); + ipcMain.removeHandler(REVIEW_UNWATCH_FILES); // Phase 4 ipcMain.removeHandler(REVIEW_GET_GIT_FILE_LOG); // Decision persistence ipcMain.removeHandler(REVIEW_LOAD_DECISIONS); ipcMain.removeHandler(REVIEW_SAVE_DECISIONS); ipcMain.removeHandler(REVIEW_CLEAR_DECISIONS); + reviewFileWatcher.stop(); + reviewWatcherProjectRoot = null; +} + +export function setReviewMainWindow(win: BrowserWindow | null): void { + reviewMainWindowRef = win; } // --- Phase 1 Handlers --- @@ -368,8 +387,56 @@ async function handleSaveEditedFile( }); } +async function handleWatchReviewFiles( + _event: IpcMainInvokeEvent, + projectPath: string, + filePaths: string[] +): Promise> { + return wrapReviewHandler('watchFiles', async () => { + const normalizedProjectPath = await validateReviewProjectPath(projectPath); + const shouldRestart = + reviewWatcherProjectRoot !== normalizedProjectPath || !reviewFileWatcher.isWatching(); + + if (shouldRestart) { + reviewFileWatcher.stop(); + reviewWatcherProjectRoot = normalizedProjectPath; + reviewFileWatcher.start(normalizedProjectPath, (event) => { + if (reviewMainWindowRef && !reviewMainWindowRef.isDestroyed()) { + reviewMainWindowRef.webContents.send(REVIEW_FILE_CHANGE, event); + } + }); + } + + reviewFileWatcher.setWatchedFiles(Array.isArray(filePaths) ? filePaths : []); + }); +} + +async function handleUnwatchReviewFiles(): Promise> { + return wrapReviewHandler('unwatchFiles', async () => { + reviewFileWatcher.stop(); + reviewWatcherProjectRoot = null; + }); +} + // --- Phase 4 Handlers --- +async function validateReviewProjectPath(projectPath: string): Promise { + if (!projectPath || typeof projectPath !== 'string') { + throw new Error('Invalid project path'); + } + + if (!path.isAbsolute(projectPath)) { + throw new Error('Project path must be absolute'); + } + + const normalized = path.resolve(path.normalize(projectPath)); + const stat = await fs.stat(normalized); + if (!stat.isDirectory()) { + throw new Error('Project path is not a directory'); + } + return normalized; +} + async function handleGetGitFileLog( _event: IpcMainInvokeEvent, projectPath: string, diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 065d3d99..08e23fac 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -457,6 +457,15 @@ export const REVIEW_APPLY_DECISIONS = 'review:applyDecisions'; /** Получить полное содержимое файла для diff view */ export const REVIEW_GET_FILE_CONTENT = 'review:getFileContent'; +/** Start/update focused file watcher for review surface */ +export const REVIEW_WATCH_FILES = 'review:watchFiles'; + +/** Stop focused file watcher for review surface */ +export const REVIEW_UNWATCH_FILES = 'review:unwatchFiles'; + +/** File change event for review watcher (main -> renderer) */ +export const REVIEW_FILE_CHANGE = 'review:fileChange'; + // Phase 4 — Git fallback /** Save edited file content to disk */ diff --git a/src/preload/index.ts b/src/preload/index.ts index d67c1962..2c52ae01 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -55,6 +55,7 @@ import { REVIEW_GET_AGENT_CHANGES, REVIEW_GET_CHANGE_STATS, REVIEW_GET_FILE_CONTENT, + REVIEW_FILE_CHANGE, REVIEW_GET_GIT_FILE_LOG, REVIEW_GET_TASK_CHANGES, REVIEW_INVALIDATE_TASK_CHANGE_SUMMARIES, @@ -64,6 +65,8 @@ import { REVIEW_REJECT_HUNKS, REVIEW_SAVE_DECISIONS, REVIEW_SAVE_EDITED_FILE, + REVIEW_UNWATCH_FILES, + REVIEW_WATCH_FILES, SSH_CONNECT, SSH_DISCONNECT, SSH_GET_CONFIG_HOSTS, @@ -1206,6 +1209,20 @@ const electronAPI: ElectronAPI = { projectPath ); }, + watchFiles: async (projectPath: string, filePaths: string[]) => { + return invokeIpcWithResult(REVIEW_WATCH_FILES, projectPath, filePaths); + }, + unwatchFiles: async () => { + return invokeIpcWithResult(REVIEW_UNWATCH_FILES); + }, + onExternalFileChange: (callback: (event: EditorFileChangeEvent) => void): (() => void) => { + const handler = (_event: Electron.IpcRendererEvent, data: EditorFileChangeEvent): void => + callback(data); + ipcRenderer.on(REVIEW_FILE_CHANGE, handler); + return (): void => { + ipcRenderer.removeListener(REVIEW_FILE_CHANGE, handler); + }; + }, // Decision persistence loadDecisions: async (teamName: string, scopeKey: string) => { return invokeIpcWithResult<{ diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index da10a13b..07975dfb 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -983,6 +983,15 @@ export class HttpAPIClient implements ElectronAPI { saveEditedFile: async (): Promise => { throw new Error('Review is not available in browser mode'); }, + watchFiles: async (): Promise => { + throw new Error('Review file watching is not available in browser mode'); + }, + unwatchFiles: async (): Promise => { + throw new Error('Review file watching is not available in browser mode'); + }, + onExternalFileChange: (): (() => void) => { + return () => {}; + }, // Decision persistence stubs loadDecisions: async (): Promise => { throw new Error('Review is not available in browser mode'); diff --git a/src/renderer/components/team/review/ChangeReviewDialog.tsx b/src/renderer/components/team/review/ChangeReviewDialog.tsx index 68fe0286..d5c7186e 100644 --- a/src/renderer/components/team/review/ChangeReviewDialog.tsx +++ b/src/renderer/components/team/review/ChangeReviewDialog.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { undo } from '@codemirror/commands'; import { rejectChunk } from '@codemirror/merge'; -import { isElectronMode } from '@renderer/api'; +import { api, isElectronMode } from '@renderer/api'; import { EditorSelectionMenu } from '@renderer/components/team/editor/EditorSelectionMenu'; import { useContinuousScrollNav } from '@renderer/hooks/useContinuousScrollNav'; import { useDiffNavigation } from '@renderer/hooks/useDiffNavigation'; @@ -15,6 +15,7 @@ import { buildSelectionInfo, SELECTION_DEBOUNCE_MS } from '@renderer/utils/codem import { sortItemsAsTree } from '@renderer/utils/fileTreeBuilder'; import { displayMemberName } from '@renderer/utils/memberHelpers'; import { type TaskChangeRequestOptions } from '@renderer/utils/taskChangeRequest'; +import { normalizePathForComparison } from '@shared/utils/platformPath'; import { ChevronDown, Clock, X } from 'lucide-react'; import { ChangesLoadingAnimation } from './ChangesLoadingAnimation'; @@ -42,6 +43,8 @@ interface RecentHunkUndoAction { at: number; } +const REVIEW_LOCAL_WRITE_COOLDOWN_MS = 2000; + interface ChangeReviewDialogProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -100,6 +103,9 @@ export const ChangeReviewDialog = ({ updateEditedContent, discardFileEdits, saveEditedFile, + reviewExternalChangesByFile, + clearReviewFileExternalChange, + reloadReviewFileFromDisk, loadDecisionsFromDisk, persistDecisions, clearDecisionsFromDisk, @@ -165,11 +171,16 @@ export const ChangeReviewDialog = ({ { file: FileChangeSummary; index: number; restoreContent: string; removedAt: number }[] >([]); const lastNewFileRemoveAtRef = useRef(0); + const recentReviewWritesRef = useRef(new Map()); // Proxy ref for useDiffNavigation (points to active file's editor) const activeEditorViewRef = useRef(null); const activeFilePathRef = useRef(null); + const markRecentReviewWrite = useCallback((filePath: string): void => { + recentReviewWritesRef.current.set(normalizePathForComparison(filePath), Date.now()); + }, []); + const getEditorFilePathForTarget = useCallback((target: Element | null): string | null => { if (!target) return null; for (const [filePath, view] of editorViewMapRef.current.entries()) { @@ -324,6 +335,49 @@ export const ChangeReviewDialog = ({ setActiveFilePath(filePath); }, []); + useEffect(() => { + if (!open || !projectPath || !isElectronMode()) return; + + const unsubscribe = api.review.onExternalFileChange((event) => { + const normalizedPath = normalizePathForComparison(event.path); + const recentWriteAt = recentReviewWritesRef.current.get(normalizedPath); + if (recentWriteAt && Date.now() - recentWriteAt < REVIEW_LOCAL_WRITE_COOLDOWN_MS) { + return; + } + + const state = useStore.getState(); + const active = state.activeChangeSet; + if (!active) return; + + const file = active.files.find( + (entry) => normalizePathForComparison(entry.filePath) === normalizedPath + ); + if (!file) return; + + const changeType = + event.type === 'create' ? 'add' : event.type === 'delete' ? 'unlink' : 'change'; + + if (file.filePath in state.editedContents) { + state.markReviewFileExternallyChanged(file.filePath, changeType); + return; + } + + state.clearReviewFileExternalChange(file.filePath); + state.invalidateResolvedFileContent(file.filePath); + void state.fetchFileContent(teamName, memberName, file.filePath); + }); + + void api.review.watchFiles( + projectPath, + sortedFiles.map((file) => file.filePath) + ); + + return () => { + unsubscribe(); + void api.review.unwatchFiles(); + }; + }, [open, projectPath, sortedFiles, teamName, memberName]); + // Tree click → scroll to file const handleTreeFileClick = useCallback( (filePath: string) => { @@ -446,6 +500,7 @@ export const ChangeReviewDialog = ({ } else { const hasErrorForFile = !!result?.errors.some((e) => e.filePath === filePath); if (result && !hasErrorForFile) { + markRecentReviewWrite(filePath); // Disk state is now authoritative. Clear stale decisions/cache so reopening // doesn't try to re-apply and the diff can re-resolve from disk. clearReviewStateForFile(filePath); @@ -465,6 +520,7 @@ export const ChangeReviewDialog = ({ teamName, taskId, memberName, + markRecentReviewWrite, removeReviewFile, fileContents, clearReviewStateForFile, @@ -507,6 +563,7 @@ export const ChangeReviewDialog = ({ void applySingleFileDecision(teamName, filePath, taskId, memberName).then((result) => { const hasErrorForFile = !!result?.errors.some((e) => e.filePath === filePath); if (result && !hasErrorForFile) { + markRecentReviewWrite(filePath); clearReviewStateForFile(filePath); setDiscardCounters((prev) => ({ ...prev, [filePath]: (prev[filePath] ?? 0) + 1 })); void fetchFileContent(teamName, memberName, filePath); @@ -520,6 +577,7 @@ export const ChangeReviewDialog = ({ teamName, taskId, memberName, + markRecentReviewWrite, clearReviewStateForFile, fetchFileContent, ] @@ -542,19 +600,43 @@ export const ChangeReviewDialog = ({ ); const handleSaveFile = useCallback( - (filePath: string) => { - void saveEditedFile(filePath, projectPath); + async (filePath: string) => { + await saveEditedFile(filePath, projectPath); + if (!useStore.getState().applyError) { + markRecentReviewWrite(filePath); + } }, - [saveEditedFile, projectPath] + [saveEditedFile, projectPath, markRecentReviewWrite] ); const handleRestoreMissingFile = useCallback( (filePath: string, content: string) => { updateEditedContent(filePath, content); // Ensure editedContents is set before saveEditedFile reads it. - void Promise.resolve().then(() => saveEditedFile(filePath, projectPath)); + void Promise.resolve().then(async () => { + await saveEditedFile(filePath, projectPath); + if (!useStore.getState().applyError) { + markRecentReviewWrite(filePath); + } + }); }, - [updateEditedContent, saveEditedFile, projectPath] + [updateEditedContent, saveEditedFile, projectPath, markRecentReviewWrite] + ); + + const handleReloadFromDisk = useCallback( + (filePath: string) => { + reloadReviewFileFromDisk(filePath); + setDiscardCounters((prev) => ({ ...prev, [filePath]: (prev[filePath] ?? 0) + 1 })); + void fetchFileContent(teamName, memberName, filePath); + }, + [reloadReviewFileFromDisk, fetchFileContent, teamName, memberName] + ); + + const handleKeepDraft = useCallback( + (filePath: string) => { + clearReviewFileExternalChange(filePath); + }, + [clearReviewFileExternalChange] ); const handleDiscardFile = useCallback( @@ -644,8 +726,14 @@ export const ChangeReviewDialog = ({ // Save active file (for Cmd+S keyboard shortcut) const handleSaveActiveFile = useCallback(() => { - if (activeFilePath) void saveEditedFile(activeFilePath, projectPath); - }, [activeFilePath, saveEditedFile, projectPath]); + if (!activeFilePath) return; + void (async () => { + await saveEditedFile(activeFilePath, projectPath); + if (!useStore.getState().applyError) { + markRecentReviewWrite(activeFilePath); + } + })(); + }, [activeFilePath, saveEditedFile, projectPath, markRecentReviewWrite]); // Continuous navigation options for cross-file hunk navigation const continuousOptions = useMemo( @@ -910,7 +998,12 @@ export const ChangeReviewDialog = ({ scheduleScrollToFile(snap.file.filePath); updateEditedContent(snap.file.filePath, snap.restoreContent); // Ensure editedContents is set before saveEditedFile reads it. - void Promise.resolve().then(() => saveEditedFile(snap.file.filePath, projectPath)); + void Promise.resolve().then(async () => { + await saveEditedFile(snap.file.filePath, projectPath); + if (!useStore.getState().applyError) { + markRecentReviewWrite(snap.file.filePath); + } + }); return; } @@ -1240,6 +1333,7 @@ export const ChangeReviewDialog = ({ fileContents={fileContents} fileContentsLoading={fileContentsLoading} globalDiffLoadingState={globalDiffLoadingState} + reviewExternalChangesByFile={reviewExternalChangesByFile} viewedSet={viewedSet} editedContents={editedContents} hunkDecisions={hunkDecisions} @@ -1255,6 +1349,8 @@ export const ChangeReviewDialog = ({ onContentChanged={handleContentChanged} onDiscard={handleDiscardFile} onSave={handleSaveFile} + onReloadFromDisk={handleReloadFromDisk} + onKeepDraft={handleKeepDraft} onAcceptFile={handleAcceptFile} onRejectFile={handleRejectFile} onRestoreMissingFile={handleRestoreMissingFile} diff --git a/src/renderer/components/team/review/ContinuousScrollView.tsx b/src/renderer/components/team/review/ContinuousScrollView.tsx index e81ea249..d45b54ec 100644 --- a/src/renderer/components/team/review/ContinuousScrollView.tsx +++ b/src/renderer/components/team/review/ContinuousScrollView.tsx @@ -28,6 +28,7 @@ interface ContinuousScrollViewProps { snippetCount: number; activeFileName?: string; } | null; + reviewExternalChangesByFile: Record; viewedSet: Set; editedContents: Record; hunkDecisions: Record; @@ -43,6 +44,8 @@ interface ContinuousScrollViewProps { onContentChanged: (filePath: string, content: string) => void; onDiscard: (filePath: string) => void; onSave: (filePath: string) => void; + onReloadFromDisk: (filePath: string) => void; + onKeepDraft: (filePath: string) => void; onAcceptFile: (filePath: string) => void; onRejectFile: (filePath: string) => void; onRestoreMissingFile?: (filePath: string, content: string) => void; @@ -74,6 +77,7 @@ export const ContinuousScrollView = ({ fileContents, fileContentsLoading, globalDiffLoadingState, + reviewExternalChangesByFile, viewedSet, editedContents, hunkDecisions, @@ -89,6 +93,8 @@ export const ContinuousScrollView = ({ onContentChanged, onDiscard, onSave, + onReloadFromDisk, + onKeepDraft, onAcceptFile, onRejectFile, onRestoreMissingFile, @@ -257,6 +263,7 @@ export const ContinuousScrollView = ({ file={file} fileContent={content} fileDecision={decision} + externalChange={reviewExternalChangesByFile[filePath]} pathChangeLabel={pathChangeLabels?.[filePath]} hasEdits={hasEdits} applying={applying} @@ -264,6 +271,8 @@ export const ContinuousScrollView = ({ onToggleCollapse={handleToggleCollapse} onDiscard={onDiscard} onSave={onSave} + onReloadFromDisk={onReloadFromDisk} + onKeepDraft={onKeepDraft} onAcceptFile={onAcceptFile} onRejectFile={onRejectFile} onRestoreMissingFile={onRestoreMissingFile} diff --git a/src/renderer/components/team/review/FileSectionHeader.tsx b/src/renderer/components/team/review/FileSectionHeader.tsx index 4c99455c..acf3d121 100644 --- a/src/renderer/components/team/review/FileSectionHeader.tsx +++ b/src/renderer/components/team/review/FileSectionHeader.tsx @@ -1,5 +1,6 @@ import React from 'react'; +import { FileIcon } from '@renderer/components/team/editor/FileIcon'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { shortcutLabel } from '@renderer/utils/platformKeys'; import { ChevronDown, ChevronRight, FilePlus, Loader2, Save, Undo2 } from 'lucide-react'; @@ -19,6 +20,7 @@ interface FileSectionHeaderProps { file: FileChangeSummary; fileContent: FileChangeWithContent | null; fileDecision: HunkDecision | undefined; + externalChange?: { type: 'change' | 'add' | 'unlink' }; pathChangeLabel?: | { kind: 'deleted' } | { kind: 'moved' | 'renamed'; direction: 'from' | 'to'; otherPath: string }; @@ -28,6 +30,8 @@ interface FileSectionHeaderProps { onToggleCollapse: (filePath: string) => void; onDiscard: (filePath: string) => void; onSave: (filePath: string) => void; + onReloadFromDisk?: (filePath: string) => void; + onKeepDraft?: (filePath: string) => void; onRestoreMissingFile?: (filePath: string, content: string) => void; onAcceptFile?: (filePath: string) => void; onRejectFile?: (filePath: string) => void; @@ -37,6 +41,7 @@ export const FileSectionHeader = ({ file, fileContent, fileDecision, + externalChange, pathChangeLabel, hasEdits, applying, @@ -44,6 +49,8 @@ export const FileSectionHeader = ({ onToggleCollapse, onDiscard, onSave, + onReloadFromDisk, + onKeepDraft, onRestoreMissingFile, onAcceptFile, onRejectFile, @@ -60,6 +67,14 @@ export const FileSectionHeader = ({ return writeSnippets[writeSnippets.length - 1].newString; })(); const canRestore = !!onRestoreMissingFile && isPreviewOnly && !hasEdits && restoreContent != null; + const externalChangeLabel = + externalChange?.type === 'unlink' + ? 'Deleted on disk' + : externalChange?.type === 'add' + ? 'Recreated on disk' + : externalChange?.type === 'change' + ? 'Changed on disk' + : null; const handleHeaderClick = (e: React.MouseEvent): void => { // Don't collapse when clicking action buttons @@ -85,6 +100,7 @@ export const FileSectionHeader = ({ {isCollapsed ? : } + {file.relativePath} {file.isNewFile && ( @@ -167,7 +183,32 @@ export const FileSectionHeader = ({ )} + {externalChangeLabel && ( + + {externalChangeLabel} + + )} +
+ {externalChange && onReloadFromDisk && onKeepDraft && ( +
+ + +
+ )} + {(onAcceptFile || onRejectFile) && (
{onAcceptFile && ( diff --git a/src/renderer/store/slices/changeReviewSlice.ts b/src/renderer/store/slices/changeReviewSlice.ts index 4f9ebc42..c2c652f4 100644 --- a/src/renderer/store/slices/changeReviewSlice.ts +++ b/src/renderer/store/slices/changeReviewSlice.ts @@ -47,6 +47,10 @@ interface DecisionSnapshot { fileDecisions: Record; } +export interface ReviewExternalChange { + type: 'change' | 'add' | 'unlink'; +} + type ReviewChangeSet = AgentChangeSet | TaskChangeSet | TaskChangeSetV2; const MAX_REVIEW_UNDO_DEPTH = 10; @@ -95,6 +99,7 @@ export interface ChangeReviewSlice { fileContentsLoading: Record; changeSetEpoch: number; fileContentVersionByPath: Record; + reviewExternalChangesByFile: Record; collapseUnchanged: boolean; applyError: string | null; applying: boolean; @@ -170,6 +175,10 @@ export interface ChangeReviewSlice { * Prevents stale decisions from being re-applied later and forces fresh content resolve. */ clearReviewStateForFile: (filePath: string) => void; + invalidateResolvedFileContent: (filePath: string) => void; + markReviewFileExternallyChanged: (filePath: string, type: ReviewExternalChange['type']) => void; + clearReviewFileExternalChange: (filePath: string) => void; + reloadReviewFileFromDisk: (filePath: string) => void; invalidateChangeStats: (teamName: string) => void; // Editable diff actions @@ -312,6 +321,41 @@ export const createChangeReviewSlice: StateCreator { + const buildResolvedFileInvalidation = ( + s: ChangeReviewSlice, + filePath: string + ): Pick< + ChangeReviewSlice, + | 'fileChunkCounts' + | 'fileContents' + | 'fileContentsLoading' + | 'hunkContextHashesByFile' + | 'fileContentVersionByPath' + > => { + const nextFileChunkCounts = { ...s.fileChunkCounts }; + delete nextFileChunkCounts[filePath]; + + const nextFileContents = { ...s.fileContents }; + delete nextFileContents[filePath]; + + const nextFileContentsLoading = { ...s.fileContentsLoading }; + delete nextFileContentsLoading[filePath]; + + const nextHunkContextHashesByFile = { ...s.hunkContextHashesByFile }; + delete nextHunkContextHashesByFile[filePath]; + + return { + fileChunkCounts: nextFileChunkCounts, + fileContents: nextFileContents, + fileContentsLoading: nextFileContentsLoading, + hunkContextHashesByFile: nextHunkContextHashesByFile, + fileContentVersionByPath: { + ...s.fileContentVersionByPath, + [filePath]: (s.fileContentVersionByPath[filePath] ?? 0) + 1, + }, + }; + }; + const installActiveChangeSetForLoad = ( data: ReviewChangeSet, extraState?: Partial @@ -327,6 +371,7 @@ export const createChangeReviewSlice: StateCreator { + set((s) => buildResolvedFileInvalidation(s, filePath)); + }, + + markReviewFileExternallyChanged: (filePath: string, type: ReviewExternalChange['type']) => { + set((s) => ({ + reviewExternalChangesByFile: { + ...s.reviewExternalChangesByFile, + [filePath]: { type }, + }, + })); + }, + + clearReviewFileExternalChange: (filePath: string) => { + set((s) => { + if (!(filePath in s.reviewExternalChangesByFile)) return s; + const next = { ...s.reviewExternalChangesByFile }; + delete next[filePath]; + return { reviewExternalChangesByFile: next }; + }); + }, + + reloadReviewFileFromDisk: (filePath: string) => { + set((s) => { + const nextEditedContents = { ...s.editedContents }; + delete nextEditedContents[filePath]; + const nextReviewExternalChangesByFile = { ...s.reviewExternalChangesByFile }; + delete nextReviewExternalChangesByFile[filePath]; + return { + editedContents: nextEditedContents, + reviewExternalChangesByFile: nextReviewExternalChangesByFile, + ...buildResolvedFileInvalidation(s, filePath), }; }); }, @@ -1200,6 +1276,9 @@ export const createChangeReviewSlice: StateCreator Promise<{ success: boolean }>; + watchFiles: (projectPath: string, filePaths: string[]) => Promise; + unwatchFiles: () => Promise; + onExternalFileChange: (callback: (event: EditorFileChangeEvent) => void) => () => void; // Decision persistence loadDecisions: ( teamName: string, diff --git a/test/renderer/store/changeReviewSlice.test.ts b/test/renderer/store/changeReviewSlice.test.ts index 771a1cc1..69c320b9 100644 --- a/test/renderer/store/changeReviewSlice.test.ts +++ b/test/renderer/store/changeReviewSlice.test.ts @@ -643,6 +643,78 @@ describe('changeReviewSlice task changes', () => { expect(store.getState().fileContentVersionByPath['/repo/file.ts']).toBe(1); }); + it('invalidates resolved file content without clearing draft or review decisions', async () => { + const store = createSliceStore(); + + store.setState({ + activeChangeSet: makeAgentChangeSet('/repo/file.ts'), + hunkDecisions: { '/repo/file.ts:0': 'rejected' }, + fileDecisions: { '/repo/file.ts': 'rejected' }, + fileChunkCounts: { '/repo/file.ts': 2 }, + hunkContextHashesByFile: { '/repo/file.ts': { 0: 'ctx' } }, + fileContents: { + '/repo/file.ts': { + ...makeFile('/repo/file.ts'), + originalFullContent: 'before', + modifiedFullContent: 'after', + contentSource: 'snippet-reconstruction', + }, + }, + fileContentsLoading: { '/repo/file.ts': true }, + editedContents: { '/repo/file.ts': 'draft' }, + reviewExternalChangesByFile: { '/repo/file.ts': { type: 'change' } }, + fileContentVersionByPath: {}, + }); + + store.getState().invalidateResolvedFileContent('/repo/file.ts'); + + expect(store.getState().fileContents).toEqual({}); + expect(store.getState().fileContentsLoading).toEqual({}); + expect(store.getState().fileChunkCounts).toEqual({}); + expect(store.getState().hunkContextHashesByFile).toEqual({}); + expect(store.getState().editedContents).toEqual({ '/repo/file.ts': 'draft' }); + expect(store.getState().hunkDecisions).toEqual({ '/repo/file.ts:0': 'rejected' }); + expect(store.getState().fileDecisions).toEqual({ '/repo/file.ts': 'rejected' }); + expect(store.getState().reviewExternalChangesByFile).toEqual({ + '/repo/file.ts': { type: 'change' }, + }); + expect(store.getState().fileContentVersionByPath['/repo/file.ts']).toBe(1); + }); + + it('reloadReviewFileFromDisk clears the draft but preserves review decisions', async () => { + const store = createSliceStore(); + + store.setState({ + activeChangeSet: makeAgentChangeSet('/repo/file.ts'), + hunkDecisions: { '/repo/file.ts:0': 'rejected' }, + fileDecisions: { '/repo/file.ts': 'rejected' }, + fileChunkCounts: { '/repo/file.ts': 2 }, + hunkContextHashesByFile: { '/repo/file.ts': { 0: 'ctx' } }, + fileContents: { + '/repo/file.ts': { + ...makeFile('/repo/file.ts'), + originalFullContent: 'before', + modifiedFullContent: 'after', + contentSource: 'snippet-reconstruction', + }, + }, + editedContents: { '/repo/file.ts': 'draft' }, + reviewExternalChangesByFile: { '/repo/file.ts': { type: 'unlink' } }, + fileContentVersionByPath: {}, + }); + + store.getState().reloadReviewFileFromDisk('/repo/file.ts'); + + expect(store.getState().fileContents).toEqual({}); + expect(store.getState().fileChunkCounts).toEqual({}); + expect(store.getState().hunkContextHashesByFile).toEqual({}); + expect(store.getState().editedContents).toEqual({}); + expect(store.getState().reviewExternalChangesByFile).toEqual({}); + expect(store.getState().hunkDecisions).toEqual({ '/repo/file.ts:0': 'rejected' }); + expect(store.getState().fileDecisions).toEqual({ '/repo/file.ts': 'rejected' }); + expect(store.getState().fileContentVersionByPath['/repo/file.ts']).toBe(1); + }); + it('ignores stale fetchFileContent responses after removing a review file', async () => { const store = createSliceStore(); const pending = deferred();