From 2b3c8745bcff236c3d04a061ac8058eee30d9cb4 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Mon, 2 Feb 2026 04:41:02 -0600 Subject: [PATCH] MAESTRO: Add file tab content refresh with mtime tracking - Add `lastModified: number` field to FilePreviewTab interface to track when content was loaded from disk - Add `fileTabAutoRefreshEnabled` setting (default: disabled) to control whether file tabs auto-refresh when switched to - Update handleOpenFileTab to accept and store lastModified - Update handleOpenFileTabAsync to fetch file stat and set lastModified from the file's actual modification time - Modify handleSelectFileTab to check if file changed on disk when auto-refresh is enabled: - Compares current file mtime with stored lastModified - Refreshes content if file was modified since last load - Skips refresh if tab has unsaved edits to prevent data loss - Update all FilePreviewTab test fixtures with lastModified field --- .../renderer/components/TabBar.test.tsx | 10 +++ src/renderer/App.tsx | 81 ++++++++++++++++--- src/renderer/hooks/settings/useSettings.ts | 23 ++++++ src/renderer/types/index.ts | 1 + 4 files changed, 102 insertions(+), 13 deletions(-) diff --git a/src/__tests__/renderer/components/TabBar.test.tsx b/src/__tests__/renderer/components/TabBar.test.tsx index ecac0e9d..f6cd3553 100644 --- a/src/__tests__/renderer/components/TabBar.test.tsx +++ b/src/__tests__/renderer/components/TabBar.test.tsx @@ -2665,6 +2665,7 @@ describe('FileTab overlay menu', () => { editMode: false, editContent: undefined, createdAt: Date.now(), + lastModified: Date.now(), }; const unifiedTabs = [ @@ -3109,6 +3110,7 @@ describe('Unified tabs drag and drop', () => { editMode: false, editContent: undefined, createdAt: Date.now(), + lastModified: Date.now(), }; const fileTab2: FilePreviewTab = { @@ -3122,6 +3124,7 @@ describe('Unified tabs drag and drop', () => { editMode: false, editContent: undefined, createdAt: Date.now() + 1, + lastModified: Date.now() + 1, }; // Unified tabs: AI, File, AI, File @@ -3655,6 +3658,7 @@ describe('Unified active tab styling consistency', () => { editMode: false, editContent: undefined, createdAt: Date.now(), + lastModified: Date.now(), }; const unifiedTabs = [ @@ -3723,6 +3727,7 @@ describe('Unified active tab styling consistency', () => { editMode: false, editContent: undefined, createdAt: Date.now(), + lastModified: Date.now(), }; const unifiedTabs = [ @@ -3768,6 +3773,7 @@ describe('Unified active tab styling consistency', () => { editMode: false, editContent: undefined, createdAt: Date.now(), + lastModified: Date.now(), }; const unifiedTabs = [ @@ -3823,6 +3829,7 @@ describe('File tab content and SSH support', () => { editMode: false, editContent: undefined, createdAt: Date.now(), + lastModified: Date.now(), }; const unifiedTabs = [ @@ -3864,6 +3871,7 @@ describe('File tab content and SSH support', () => { editMode: false, editContent: undefined, createdAt: Date.now(), + lastModified: Date.now(), sshRemoteId: 'ssh-remote-123', // SSH remote ID for re-fetching isLoading: false, }; @@ -3908,6 +3916,7 @@ describe('File tab content and SSH support', () => { editMode: false, editContent: undefined, createdAt: Date.now(), + lastModified: 0, // Not yet loaded sshRemoteId: 'ssh-remote-456', isLoading: true, // Currently loading content }; @@ -3954,6 +3963,7 @@ describe('File tab content and SSH support', () => { editMode: true, editContent: editedContent, // Has unsaved edits createdAt: Date.now(), + lastModified: Date.now(), }; const unifiedTabs = [ diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index b1d49fa4..63ca36d0 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -555,6 +555,9 @@ function MaestroConsoleInner() { // Tab naming settings automaticTabNamingEnabled, + + // File tab refresh settings + fileTabAutoRefreshEnabled, } = settings; // --- KEYBOARD SHORTCUT HELPERS --- @@ -3647,12 +3650,14 @@ function MaestroConsoleInner() { const speckitCommandsRef = useRef(speckitCommands); const openspecCommandsRef = useRef(openspecCommands); const automaticTabNamingEnabledRef = useRef(automaticTabNamingEnabled); + const fileTabAutoRefreshEnabledRef = useRef(fileTabAutoRefreshEnabled); addToastRef.current = addToast; updateGlobalStatsRef.current = updateGlobalStats; customAICommandsRef.current = customAICommands; speckitCommandsRef.current = speckitCommands; openspecCommandsRef.current = openspecCommands; automaticTabNamingEnabledRef.current = automaticTabNamingEnabled; + fileTabAutoRefreshEnabledRef.current = fileTabAutoRefreshEnabled; // Note: spawnBackgroundSynopsisRef and spawnAgentWithPromptRef are now provided by useAgentExecution hook // Note: addHistoryEntryRef is now provided by useAgentSessionManagement hook @@ -5161,7 +5166,7 @@ You are taking over this conversation. Based on the context above, provide a bri * For SSH remote files, pass sshRemoteId so content can be re-fetched if needed. */ const handleOpenFileTab = useCallback( - (file: { path: string; name: string; content: string; sshRemoteId?: string }) => { + (file: { path: string; name: string; content: string; sshRemoteId?: string; lastModified?: number }) => { setSessions((prev) => prev.map((s) => { if (s.id !== activeSessionIdRef.current) return s; @@ -5169,10 +5174,15 @@ You are taking over this conversation. Based on the context above, provide a bri // Check if a tab with this path already exists const existingTab = s.filePreviewTabs.find((tab) => tab.path === file.path); if (existingTab) { - // Tab exists - update content if provided (e.g., after re-fetch) and select it + // Tab exists - update content and lastModified if provided (e.g., after re-fetch) and select it const updatedTabs = s.filePreviewTabs.map((tab) => tab.id === existingTab.id - ? { ...tab, content: file.content, isLoading: false } + ? { + ...tab, + content: file.content, + lastModified: file.lastModified ?? tab.lastModified, + isLoading: false, + } : tab ); return { @@ -5203,6 +5213,7 @@ You are taking over this conversation. Based on the context above, provide a bri editMode: false, editContent: undefined, createdAt: Date.now(), + lastModified: file.lastModified ?? Date.now(), // Use file mtime or current time as fallback sshRemoteId: file.sshRemoteId, isLoading: false, // Content is already loaded when this is called }; @@ -5280,6 +5291,7 @@ You are taking over this conversation. Based on the context above, provide a bri editMode: false, editContent: undefined, createdAt: Date.now(), + lastModified: 0, // Will be populated after fetch sshRemoteId, isLoading: true, // Show loading state }; @@ -5299,10 +5311,15 @@ You are taking over this conversation. Based on the context above, provide a bri }) ); - // Fetch content asynchronously + // Fetch content and stat asynchronously try { - const content = await window.maestro.fs.readFile(file.path, sshRemoteId); - // Update the tab with loaded content + const [content, stat] = await Promise.all([ + window.maestro.fs.readFile(file.path, sshRemoteId), + window.maestro.fs.stat(file.path, sshRemoteId), + ]); + // Parse lastModified from stat (modifiedAt is an ISO string) + const lastModified = stat?.modifiedAt ? new Date(stat.modifiedAt).getTime() : Date.now(); + // Update the tab with loaded content and lastModified setSessions((prev) => prev.map((s) => { if (s.id !== currentSession.id) return s; @@ -5310,7 +5327,7 @@ You are taking over this conversation. Based on the context above, provide a bri ...s, filePreviewTabs: s.filePreviewTabs.map((tab) => tab.id === newTabId - ? { ...tab, content, isLoading: false } + ? { ...tab, content, lastModified, isLoading: false } : tab ), }; @@ -5434,16 +5451,22 @@ You are taking over this conversation. Based on the context above, provide a bri /** * Select a file preview tab. This sets the file tab as active. * activeTabId is preserved to track the last active AI tab for when the user switches back. + * If fileTabAutoRefreshEnabled setting is true, checks if file has changed on disk and refreshes content. */ - const handleSelectFileTab = useCallback((tabId: string) => { + const handleSelectFileTab = useCallback(async (tabId: string) => { + const currentSession = sessionsRef.current.find( + (s) => s.id === activeSessionIdRef.current + ); + if (!currentSession) return; + + // Verify the file tab exists + const fileTab = currentSession.filePreviewTabs.find((tab) => tab.id === tabId); + if (!fileTab) return; + + // Set the tab as active immediately setSessions((prev) => prev.map((s) => { if (s.id !== activeSessionIdRef.current) return s; - - // Verify the file tab exists - const fileTab = s.filePreviewTabs.find((tab) => tab.id === tabId); - if (!fileTab) return s; - return { ...s, activeFileTabId: tabId, @@ -5451,6 +5474,38 @@ You are taking over this conversation. Based on the context above, provide a bri }; }) ); + + // If auto-refresh is enabled and tab has pending edits, skip refresh to avoid losing changes + if (fileTabAutoRefreshEnabledRef.current && !fileTab.editContent) { + try { + // Get the current file stat to check if it has changed + const stat = await window.maestro.fs.stat(fileTab.path, fileTab.sshRemoteId); + if (!stat || !stat.modifiedAt) return; + + const currentMtime = new Date(stat.modifiedAt).getTime(); + + // If file has been modified since we last loaded it, refresh content + if (currentMtime > fileTab.lastModified) { + const content = await window.maestro.fs.readFile(fileTab.path, fileTab.sshRemoteId); + setSessions((prev) => + prev.map((s) => { + if (s.id !== activeSessionIdRef.current) return s; + return { + ...s, + filePreviewTabs: s.filePreviewTabs.map((tab) => + tab.id === tabId + ? { ...tab, content, lastModified: currentMtime } + : tab + ), + }; + }) + ); + } + } catch (error) { + // Silently ignore refresh errors - the tab still shows previous content + console.debug('[handleSelectFileTab] Auto-refresh failed:', error); + } + } }, []); /** diff --git a/src/renderer/hooks/settings/useSettings.ts b/src/renderer/hooks/settings/useSettings.ts index 1a8b840a..a78e68cd 100644 --- a/src/renderer/hooks/settings/useSettings.ts +++ b/src/renderer/hooks/settings/useSettings.ts @@ -346,6 +346,10 @@ export interface UseSettingsReturn { // Automatic tab naming settings automaticTabNamingEnabled: boolean; setAutomaticTabNamingEnabled: (value: boolean) => void; + + // File tab auto-refresh settings + fileTabAutoRefreshEnabled: boolean; + setFileTabAutoRefreshEnabled: (value: boolean) => void; } export function useSettings(): UseSettingsReturn { @@ -501,6 +505,9 @@ export function useSettings(): UseSettingsReturn { // Automatic tab naming settings const [automaticTabNamingEnabled, setAutomaticTabNamingEnabledState] = useState(true); // Default: enabled + // File tab auto-refresh settings + const [fileTabAutoRefreshEnabled, setFileTabAutoRefreshEnabledState] = useState(false); // Default: disabled + // Wrapper functions that persist to electron-store // PERF: All wrapped in useCallback to prevent re-renders const setLlmProvider = useCallback((value: LLMProvider) => { @@ -1309,6 +1316,12 @@ export function useSettings(): UseSettingsReturn { window.maestro.settings.set('automaticTabNamingEnabled', value); }, []); + // File tab auto-refresh toggle + const setFileTabAutoRefreshEnabled = useCallback((value: boolean) => { + setFileTabAutoRefreshEnabledState(value); + window.maestro.settings.set('fileTabAutoRefreshEnabled', value); + }, []); + // Load settings from electron-store // This function is called on mount and on system resume (after sleep/suspend) // PERF: Use batch loading to reduce IPC calls from ~60 to 3 @@ -1382,6 +1395,7 @@ export function useSettings(): UseSettingsReturn { const savedSshRemoteIgnorePatterns = allSettings['sshRemoteIgnorePatterns']; const savedSshRemoteHonorGitignore = allSettings['sshRemoteHonorGitignore']; const savedAutomaticTabNamingEnabled = allSettings['automaticTabNamingEnabled']; + const savedFileTabAutoRefreshEnabled = allSettings['fileTabAutoRefreshEnabled']; if (savedEnterToSendAI !== undefined) setEnterToSendAIState(savedEnterToSendAI as boolean); if (savedEnterToSendTerminal !== undefined) @@ -1741,6 +1755,11 @@ export function useSettings(): UseSettingsReturn { if (savedAutomaticTabNamingEnabled !== undefined) { setAutomaticTabNamingEnabledState(savedAutomaticTabNamingEnabled as boolean); } + + // File tab auto-refresh settings + if (savedFileTabAutoRefreshEnabled !== undefined) { + setFileTabAutoRefreshEnabledState(savedFileTabAutoRefreshEnabled as boolean); + } } catch (error) { console.error('[Settings] Failed to load settings:', error); } finally { @@ -1917,6 +1936,8 @@ export function useSettings(): UseSettingsReturn { setSshRemoteHonorGitignore, automaticTabNamingEnabled, setAutomaticTabNamingEnabled, + fileTabAutoRefreshEnabled, + setFileTabAutoRefreshEnabled, }), [ // State values @@ -2060,6 +2081,8 @@ export function useSettings(): UseSettingsReturn { setSshRemoteHonorGitignore, automaticTabNamingEnabled, setAutomaticTabNamingEnabled, + fileTabAutoRefreshEnabled, + setFileTabAutoRefreshEnabled, ] ); } diff --git a/src/renderer/types/index.ts b/src/renderer/types/index.ts index 1790d120..a72737a3 100644 --- a/src/renderer/types/index.ts +++ b/src/renderer/types/index.ts @@ -458,6 +458,7 @@ export interface FilePreviewTab { editMode: boolean; // Whether tab was in edit mode editContent: string | undefined; // Unsaved edit content (undefined if no pending changes) createdAt: number; // Timestamp for ordering + lastModified: number; // Timestamp (ms) when file was last modified on disk (for refresh detection) // SSH remote support sshRemoteId?: string; // SSH remote ID for re-fetching content if needed isLoading?: boolean; // True while content is being loaded (for SSH remote files)