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();