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
This commit is contained in:
Pedram Amini
2026-02-02 04:41:02 -06:00
parent fd9f419251
commit 2b3c8745bc
4 changed files with 102 additions and 13 deletions

View File

@@ -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 = [

View File

@@ -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);
}
}
}, []);
/**

View File

@@ -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,
]
);
}

View File

@@ -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)