/** * Editor slice — manages project editor state. * * Group 1: File tree state + actions (iter-1) * Group 2: Tab management (iter-2) * Group 3: Dirty/save state (iter-2) * Group 4: File operations (iter-3) */ import { api } from '@renderer/api'; import { getLanguageFromFileName } from '@renderer/utils/codemirrorLanguages'; import { editorBridge } from '@renderer/utils/editorBridge'; import { invalidateQuickOpenCache } from '@renderer/utils/quickOpenCache'; import { computeDisambiguatedTabs } from '@renderer/utils/tabLabelDisambiguation'; import { createLogger } from '@shared/utils/logger'; import { getBasename, isPathPrefix, isWindowsishPath, joinPath, lastSeparatorIndex, splitPath, stripTrailingSeparators, } from '@shared/utils/platformPath'; import type { AppState } from '../types'; import type { EditorFileChangeEvent, EditorFileTab, FileTreeEntry, GitFileStatus, } from '@shared/types/editor'; import type { StateCreator } from 'zustand'; const log = createLogger('Store:editor'); /** Remove a key from a record. Returns the same reference if key doesn't exist. */ function omitKey(record: Record, key: string): Record { if (!(key in record)) return record; const result = { ...record }; delete result[key]; return result; } /** * Cooldown map: filePath → timestamp of last successful save. * * Used to suppress watcher events that arrive after editorSaving is cleared * (race condition: atomic write → IPC response → clear saving flag → watcher fires). * macOS FSEvents can delay up to ~1s; 2s cooldown covers all platforms safely. * * Module-level (not in store state) to avoid unnecessary re-renders. */ const recentSaveTimestamps = new Map(); const SAVE_COOLDOWN_MS = 2000; /** * Throttle timers for watcher-driven updates. * Keeping these module-level avoids store re-renders during bursts. */ let gitStatusThrottleTimer: ReturnType | null = null; const GIT_STATUS_THROTTLE_MS = 1500; const dirRefreshDebounceTimers = new Map>(); const DIR_REFRESH_DEBOUNCE_MS = 350; // Watcher event logging can be extremely expensive during bursts. // Keep a lightweight aggregate counter instead of logging per event. let watcherEventLogTimer: ReturnType | null = null; let watcherEventCounts: Record = { change: 0, create: 0, delete: 0, }; let watchedFilesSyncTimer: ReturnType | null = null; let lastWatchedFilesKey = ''; let watchedDirsSyncTimer: ReturnType | null = null; let lastWatchedDirsKey = ''; const WATCHED_DIRS_DEBOUNCE_MS = 250; const MAX_WATCHED_DIRS = 120; function scheduleSyncWatchedFiles(get: () => AppState): void { // Editor watcher is Electron-only. In browser mode, api.editor exists but throws. if (!window.electronAPI?.editor) return; const state = get(); if (!state.editorWatcherEnabled) return; const projectPath = state.editorProjectPath; if (!projectPath) return; const filePaths = state.editorOpenTabs.map((t) => t.filePath).filter(Boolean); filePaths.sort((a, b) => a.localeCompare(b)); const key = `${projectPath}\n${filePaths.join('\n')}`; if (key === lastWatchedFilesKey) return; lastWatchedFilesKey = key; if (watchedFilesSyncTimer) clearTimeout(watchedFilesSyncTimer); watchedFilesSyncTimer = setTimeout(() => { watchedFilesSyncTimer = null; void window.electronAPI.editor.setWatchedFiles(filePaths); }, 150); } function scheduleSyncWatchedDirs(get: () => AppState): void { if (!window.electronAPI?.editor) return; const state = get(); if (!state.editorWatcherEnabled) return; const projectPath = state.editorProjectPath; if (!projectPath) return; const expanded = Object.entries(state.editorExpandedDirs) .filter(([, v]) => v === true) .map(([k]) => k); // Always include root (depth=0), plus expanded folders (depth=0). // Cap to protect chokidar from too many watched paths if user expands a lot. const dirs = [projectPath, ...expanded].slice(0, MAX_WATCHED_DIRS); dirs.sort((a, b) => a.localeCompare(b)); const key = `${projectPath}\n${dirs.join('\n')}`; if (key === lastWatchedDirsKey) return; lastWatchedDirsKey = key; if (watchedDirsSyncTimer) clearTimeout(watchedDirsSyncTimer); watchedDirsSyncTimer = setTimeout(() => { watchedDirsSyncTimer = null; void window.electronAPI.editor.setWatchedDirs(dirs); }, WATCHED_DIRS_DEBOUNCE_MS); } /** * Open request sequence for editor initialization. * Cancels stale async work (notably React 18 StrictMode dev effect mount/unmount). */ let editorOpenSeq = 0; /** * Cooldown map: filePath → timestamp of last successful move. * Suppresses watcher events triggered by our own move operations. */ const recentMoveTimestamps = new Map(); const MOVE_COOLDOWN_MS = 2000; function scheduleIdleWork(cb: () => void): void { // Prefer requestIdleCallback when available; fall back to a short timeout. // This keeps editor open responsive for large repos. // timeout ensures the callback fires within 2s even if the event loop is busy // (without it, requestIdleCallback can be delayed indefinitely). try { const ric = ( window as unknown as { requestIdleCallback?: (fn: () => void, opts?: { timeout: number }) => number; } ).requestIdleCallback; if (typeof ric === 'function') { ric(cb, { timeout: 2000 }); return; } } catch { // ignore } setTimeout(cb, 150); } // ============================================================================= // Slice Interface // ============================================================================= export interface EditorSlice { // ═══════════════════════════════════════════════════════ // Group 1: File tree state + actions // ═══════════════════════════════════════════════════════ editorProjectPath: string | null; editorFileTree: FileTreeEntry[] | null; editorFileTreeLoading: boolean; editorFileTreeError: string | null; editorExpandedDirs: Record; openEditor: (projectPath: string) => Promise; closeEditor: () => void; loadFileTree: (dirPath: string) => Promise; expandDirectory: (dirPath: string) => Promise; collapseDirectory: (dirPath: string) => void; // ═══════════════════════════════════════════════════════ // Group 2: Tab management // ═══════════════════════════════════════════════════════ editorOpenTabs: EditorFileTab[]; editorActiveTabId: string | null; openFile: (filePath: string) => void; closeEditorTab: (tabId: string) => void; closeOtherEditorTabs: (keepTabId: string) => void; closeEditorTabsToLeft: (tabId: string) => void; closeEditorTabsToRight: (tabId: string) => void; closeAllEditorTabs: () => void; setActiveEditorTab: (tabId: string) => void; reorderEditorTabs: (activeId: string, overId: string) => void; // ═══════════════════════════════════════════════════════ // Group 3: Content + Save // Content lives in EditorState (Map in useRef). // Store only tracks dirty flags, loading, and save status. // ═══════════════════════════════════════════════════════ editorFileLoading: Record; editorModifiedFiles: Record; editorSaving: Record; editorSaveError: Record; markFileModified: (filePath: string) => void; markFileSaved: (filePath: string) => void; saveFile: (filePath: string) => Promise; saveAllFiles: () => Promise; discardChanges: (filePath: string) => void; hasUnsavedChanges: () => boolean; // ═══════════════════════════════════════════════════════ // Group 4: File operations (iter-3) // ═══════════════════════════════════════════════════════ editorCreating: boolean; editorCreateError: string | null; createFileInTree: (parentDir: string, fileName: string) => Promise; createDirInTree: (parentDir: string, dirName: string) => Promise; deleteFileFromTree: (filePath: string) => Promise; moveFileInTree: (sourcePath: string, destDir: string) => Promise; renameFileInTree: (sourcePath: string, newName: string) => Promise; // ═══════════════════════════════════════════════════════ // Group 5: Git status + file watcher + line wrap (iter-5) // ═══════════════════════════════════════════════════════ editorGitFiles: GitFileStatus[]; editorGitBranch: string | null; editorIsGitRepo: boolean; editorGitLoading: boolean; editorWatcherEnabled: boolean; editorLineWrap: boolean; /** Files changed on disk while open (absolute paths) */ editorExternalChanges: Record; /** Baseline mtime per file (for conflict detection) */ editorFileMtimes: Record; /** File path with active save conflict (null = no conflict) */ editorConflictFile: string | null; /** Pending line to scroll to after file loads (1-based). Set by search result click. */ editorPendingGoToLine: number | null; setPendingGoToLine: (line: number | null) => void; /** File path to reveal in editor (opens editor, expands dirs, opens tab, focuses in tree). */ editorPendingRevealFile: string | null; /** Request to reveal a file in the editor. Opens editor overlay if needed. */ revealFileInEditor: (filePath: string) => void; /** Process the pending reveal: expand parent dirs and open the file tab. */ revealAndOpenFile: (filePath: string) => Promise; clearPendingRevealFile: () => void; fetchGitStatus: () => Promise; toggleWatcher: (enable: boolean) => Promise; toggleLineWrap: () => void; handleExternalFileChange: (event: EditorFileChangeEvent) => void; clearExternalChange: (filePath: string) => void; setFileMtime: (filePath: string, mtimeMs: number) => void; forceOverwrite: (filePath: string) => Promise; resolveConflict: () => void; } // ============================================================================= // Slice Creator // ============================================================================= export const createEditorSlice: StateCreator = (set, get) => ({ // Group 1 initial state editorProjectPath: null, editorFileTree: null, editorFileTreeLoading: false, editorFileTreeError: null, editorExpandedDirs: {}, // Group 2 initial state editorOpenTabs: [], editorActiveTabId: null, // Group 3 initial state editorFileLoading: {}, editorModifiedFiles: {}, editorSaving: {}, editorSaveError: {}, // Group 4 initial state editorCreating: false, editorCreateError: null, // Group 5 initial state editorGitFiles: [], editorGitBranch: null, editorIsGitRepo: false, editorGitLoading: false, editorWatcherEnabled: false, editorLineWrap: (() => { try { return localStorage.getItem('editor-line-wrap') === 'true'; } catch { return false; } })(), editorExternalChanges: {}, editorFileMtimes: {}, editorConflictFile: null, editorPendingGoToLine: null, editorPendingRevealFile: null, setPendingGoToLine: (line: number | null) => set({ editorPendingGoToLine: line }), revealFileInEditor: (filePath: string) => { set({ editorPendingRevealFile: filePath }); }, clearPendingRevealFile: () => { set({ editorPendingRevealFile: null }); }, revealAndOpenFile: async (filePath: string) => { const { editorProjectPath, editorFileTree, expandDirectory, openFile } = get(); if (!editorProjectPath) return; // Guard: file tree must be loaded before we can reveal. // If it's still null, bail out WITHOUT clearing pendingRevealFile // so the caller effect can retry after the tree loads. if (!editorFileTree) { log.info('revealAndOpenFile: tree not loaded yet, deferring reveal'); return; } // Compute parent directories from projectRoot to the file. // Must handle both `/` and `\` because paths may arrive from any OS. const root = stripTrailingSeparators(editorProjectPath); const rootParts = splitPath(root); const fileParts = splitPath(filePath); const win = isWindowsishPath(root); const eq = (a: string, b: string): boolean => win ? a.toLowerCase() === b.toLowerCase() : a === b; const hasPrefix = fileParts.length >= rootParts.length && rootParts.every((seg, i) => eq(seg, fileParts[i])); if (hasPrefix) { const segments = fileParts.slice(rootParts.length); // Expand each parent directory sequentially (root → child → grandchild). // Skip the last segment (the file name itself). // Each expandDirectory call is awaited so that its children are merged // into the tree before the next level is expanded. let currentDir = root; for (let i = 0; i < segments.length - 1; i++) { currentDir = joinPath(currentDir, segments[i] ?? ''); await expandDirectory(currentDir); } } // Open the file as a tab openFile(filePath); // Clear reveal state set({ editorPendingRevealFile: null }); }, // ═══════════════════════════════════════════════════════ // Group 1: File tree actions // ═══════════════════════════════════════════════════════ openEditor: async (projectPath: string) => { const openSeq = ++editorOpenSeq; set({ editorProjectPath: projectPath, editorFileTree: null, editorFileTreeLoading: true, editorFileTreeError: null, editorExpandedDirs: {}, editorOpenTabs: [], editorActiveTabId: null, editorFileLoading: {}, editorModifiedFiles: {}, editorSaving: {}, editorSaveError: {}, editorCreating: false, editorCreateError: null, editorGitFiles: [], editorGitBranch: null, editorIsGitRepo: false, editorGitLoading: false, editorWatcherEnabled: false, editorExternalChanges: {}, editorFileMtimes: {}, editorConflictFile: null, editorPendingGoToLine: null, }); try { const tOpen = performance.now(); await api.editor.open(projectPath); const openMs = performance.now() - tOpen; // Cancel stale opens (e.g. StrictMode effect cleanup, or rapid project switching) if (editorOpenSeq !== openSeq || get().editorProjectPath !== projectPath) { return; } // Load file tree first so UI becomes interactive quickly. // Git status and file watching can be expensive on large projects, so they are NOT awaited here. const tReadDir = performance.now(); const result = await api.editor.readDir(projectPath); const readDirMs = performance.now() - tReadDir; if (editorOpenSeq !== openSeq || get().editorProjectPath !== projectPath) { return; } const tSet = performance.now(); set({ editorFileTree: result.entries, editorFileTreeLoading: false, }); const setMs = performance.now() - tSet; log.info( `[perf] openEditor: open=${openMs.toFixed(1)}ms, readDir=${readDirMs.toFixed(1)}ms, set=${setMs.toFixed(1)}ms, entries=${result.entries.length}` ); // Enable watcher by default (like most editors), but defer startup until idle so open stays fast. // Allow users to persistently disable it via localStorage toggle. const watcherDesired = (() => { try { return localStorage.getItem('editor-watcher-enabled') !== 'false'; } catch { return true; } })(); scheduleIdleWork(() => { if (editorOpenSeq !== openSeq || get().editorProjectPath !== projectPath) return; if (watcherDesired) void get().toggleWatcher(true); // Defer initial git status a bit more — it can be expensive on large repos. setTimeout(() => { if (editorOpenSeq !== openSeq || get().editorProjectPath !== projectPath) return; void get().fetchGitStatus(); }, 1200); }); } catch (error) { // Ignore errors from stale opens (e.g. StrictMode cleanup during dev) if (editorOpenSeq !== openSeq || get().editorProjectPath !== projectPath) { return; } const message = error instanceof Error ? error.message : String(error); log.error('Failed to open editor:', message); set({ editorFileTreeLoading: false, editorFileTreeError: message, }); } }, closeEditor: () => { // Cancel any in-flight openEditor async work editorOpenSeq++; // Cancel any pending watcher sync (avoid calling into main after close) if (watchedFilesSyncTimer) { clearTimeout(watchedFilesSyncTimer); watchedFilesSyncTimer = null; } if (watchedDirsSyncTimer) { clearTimeout(watchedDirsSyncTimer); watchedDirsSyncTimer = null; } lastWatchedFilesKey = ''; lastWatchedDirsKey = ''; // Clear cooldown timestamps (no stale entries across editor sessions) recentSaveTimestamps.clear(); recentMoveTimestamps.clear(); // Best-effort IPC cleanup api.editor.close().catch((e: unknown) => { log.error('editor:close failed:', e); }); // Cleanup bridge (destroys EditorView, clears caches) editorBridge.destroy(); set({ editorProjectPath: null, editorFileTree: null, editorFileTreeLoading: false, editorFileTreeError: null, editorExpandedDirs: {}, editorOpenTabs: [], editorActiveTabId: null, editorFileLoading: {}, editorModifiedFiles: {}, editorSaving: {}, editorSaveError: {}, editorCreating: false, editorCreateError: null, editorGitFiles: [], editorGitBranch: null, editorIsGitRepo: false, editorGitLoading: false, editorWatcherEnabled: false, editorExternalChanges: {}, editorFileMtimes: {}, editorConflictFile: null, editorPendingGoToLine: null, }); }, loadFileTree: async (dirPath: string) => { set({ editorFileTreeLoading: true, editorFileTreeError: null }); try { const t0 = performance.now(); const result = await api.editor.readDir(dirPath); const ipcMs = performance.now() - t0; const t1 = performance.now(); set({ editorFileTree: result.entries, editorFileTreeLoading: false, }); const setMs = performance.now() - t1; log.info( `[perf] loadFileTree: IPC=${ipcMs.toFixed(1)}ms, set=${setMs.toFixed(1)}ms, entries=${result.entries.length}` ); } catch (error) { const message = error instanceof Error ? error.message : String(error); log.error('Failed to load file tree:', message); set({ editorFileTreeLoading: false, editorFileTreeError: message, }); } }, expandDirectory: async (dirPath: string) => { const { editorExpandedDirs } = get(); // Skip set() if already expanded — prevents unnecessary re-render const wasExpanded = !!editorExpandedDirs[dirPath]; if (!wasExpanded) { set({ editorExpandedDirs: { ...editorExpandedDirs, [dirPath]: true }, }); scheduleSyncWatchedDirs(get); } try { const t0 = performance.now(); const result = await api.editor.readDir(dirPath); const ipcMs = performance.now() - t0; // Use fresh tree from store after await to avoid overwriting concurrent updates const currentTree = get().editorFileTree; const t1 = performance.now(); const updatedTree = mergeChildrenIntoTree(currentTree ?? [], dirPath, result.entries); const mergeMs = performance.now() - t1; const t2 = performance.now(); set({ editorFileTree: updatedTree }); const setMs = performance.now() - t2; log.info( `[perf] expandDirectory: IPC=${ipcMs.toFixed(1)}ms, merge=${mergeMs.toFixed(1)}ms, set=${setMs.toFixed(1)}ms, entries=${result.entries.length}, wasExpanded=${wasExpanded}` ); } catch (error) { const message = error instanceof Error ? error.message : String(error); log.error('Failed to expand directory:', message); const current = get().editorExpandedDirs; set({ editorExpandedDirs: omitKey(current, dirPath) }); } }, collapseDirectory: (dirPath: string) => { const { editorExpandedDirs } = get(); set({ editorExpandedDirs: omitKey(editorExpandedDirs, dirPath) }); scheduleSyncWatchedDirs(get); }, // ═══════════════════════════════════════════════════════ // Group 2: Tab management // ═══════════════════════════════════════════════════════ openFile: (filePath: string) => { const { editorOpenTabs } = get(); // Dedup: if file already open, just activate it const existing = editorOpenTabs.find((t) => t.filePath === filePath); if (existing) { set({ editorActiveTabId: existing.id }); return; } const fileName = getBasename(filePath) || 'file'; const language = getLanguageFromFileName(fileName); const tab: EditorFileTab = { id: filePath, filePath, fileName, language, }; const newTabs = computeDisambiguatedTabs([...editorOpenTabs, tab]); set({ editorOpenTabs: newTabs, editorActiveTabId: tab.id, }); scheduleSyncWatchedFiles(get); }, closeEditorTab: (tabId: string) => { const { editorOpenTabs, editorActiveTabId, editorModifiedFiles, editorSaveError } = get(); const filtered = editorOpenTabs.filter((t) => t.id !== tabId); // Clean up dirty/error state for closed tab const restModified = omitKey(editorModifiedFiles, tabId); const restErrors = omitKey(editorSaveError, tabId); // Clear cached EditorState from bridge editorBridge.deleteState(tabId); // Clear draft from localStorage try { localStorage.removeItem(`editor-draft:${tabId}`); } catch { // localStorage may not be available } let newActiveId = editorActiveTabId; if (editorActiveTabId === tabId) { // Activate adjacent tab const closedIndex = editorOpenTabs.findIndex((t) => t.id === tabId); if (filtered.length > 0) { newActiveId = filtered[Math.min(closedIndex, filtered.length - 1)].id; } else { newActiveId = null; } } // Recompute disambiguation after removing tab const disambiguated = computeDisambiguatedTabs(filtered); set({ editorOpenTabs: disambiguated, editorActiveTabId: newActiveId, editorModifiedFiles: restModified, editorSaveError: restErrors, }); scheduleSyncWatchedFiles(get); }, closeOtherEditorTabs: (keepTabId: string) => { const { editorOpenTabs } = get(); const toClose = editorOpenTabs.filter((t) => t.id !== keepTabId); for (const tab of toClose) get().closeEditorTab(tab.id); }, closeEditorTabsToLeft: (tabId: string) => { const { editorOpenTabs } = get(); const idx = editorOpenTabs.findIndex((t) => t.id === tabId); if (idx <= 0) return; const toClose = editorOpenTabs.slice(0, idx); for (const tab of toClose) get().closeEditorTab(tab.id); }, closeEditorTabsToRight: (tabId: string) => { const { editorOpenTabs } = get(); const idx = editorOpenTabs.findIndex((t) => t.id === tabId); if (idx < 0 || idx >= editorOpenTabs.length - 1) return; const toClose = editorOpenTabs.slice(idx + 1); for (const tab of toClose) get().closeEditorTab(tab.id); }, closeAllEditorTabs: () => { const { editorOpenTabs } = get(); for (const tab of [...editorOpenTabs]) get().closeEditorTab(tab.id); }, setActiveEditorTab: (tabId: string) => { set({ editorActiveTabId: tabId }); }, reorderEditorTabs: (activeId: string, overId: string) => { if (activeId === overId) return; const { editorOpenTabs } = get(); const oldIndex = editorOpenTabs.findIndex((t) => t.id === activeId); const newIndex = editorOpenTabs.findIndex((t) => t.id === overId); if (oldIndex === -1 || newIndex === -1) return; const updated = [...editorOpenTabs]; const [moved] = updated.splice(oldIndex, 1); updated.splice(newIndex, 0, moved); set({ editorOpenTabs: updated }); }, // ═══════════════════════════════════════════════════════ // Group 3: Content + Save // ═══════════════════════════════════════════════════════ markFileModified: (filePath: string) => { const { editorModifiedFiles } = get(); if (editorModifiedFiles[filePath]) return; // Already marked set({ editorModifiedFiles: { ...editorModifiedFiles, [filePath]: true } }); }, markFileSaved: (filePath: string) => { const { editorModifiedFiles } = get(); set({ editorModifiedFiles: omitKey(editorModifiedFiles, filePath) }); }, saveFile: async (filePath: string) => { const content = editorBridge.getContent(filePath); if (content === null) { log.error('saveFile: no content available for', filePath); return; } set((s) => ({ editorSaving: { ...s.editorSaving, [filePath]: true }, editorSaveError: omitKey(s.editorSaveError, filePath), })); try { // Pass baseline mtime for conflict detection (if available) const baselineMtime = get().editorFileMtimes[filePath]; const result = await api.editor.writeFile(filePath, content, baselineMtime); // Record save timestamp BEFORE clearing editorSaving (watcher race guard) recentSaveTimestamps.set(filePath, Date.now()); // Update baseline mtime with the new value after successful save set((s) => ({ editorModifiedFiles: omitKey(s.editorModifiedFiles, filePath), editorSaving: omitKey(s.editorSaving, filePath), editorFileMtimes: { ...s.editorFileMtimes, [filePath]: result.mtimeMs }, editorExternalChanges: omitKey(s.editorExternalChanges, filePath), })); try { localStorage.removeItem(`editor-draft:${filePath}`); } catch { // localStorage may not be available } } catch (error) { const message = error instanceof Error ? error.message : String(error); // Handle conflict errors specifically if (message.startsWith('CONFLICT')) { log.error('Save conflict detected:', filePath); set((s) => ({ editorSaving: omitKey(s.editorSaving, filePath), editorConflictFile: filePath, })); return; } log.error('Failed to save file:', message); set((s) => ({ editorSaving: omitKey(s.editorSaving, filePath), editorSaveError: { ...s.editorSaveError, [filePath]: message }, })); } }, saveAllFiles: async () => { const { editorModifiedFiles } = get(); const modifiedContent = editorBridge.getAllModifiedContent(editorModifiedFiles); const promises: Promise[] = []; for (const [filePath, content] of modifiedContent) { promises.push( (async () => { set((s) => ({ editorSaving: { ...s.editorSaving, [filePath]: true }, })); try { const baselineMtime = get().editorFileMtimes[filePath]; const result = await api.editor.writeFile(filePath, content, baselineMtime); // Record save timestamp BEFORE clearing editorSaving (watcher race guard) recentSaveTimestamps.set(filePath, Date.now()); set((s) => ({ editorModifiedFiles: omitKey(s.editorModifiedFiles, filePath), editorSaving: omitKey(s.editorSaving, filePath), editorFileMtimes: { ...s.editorFileMtimes, [filePath]: result.mtimeMs }, editorExternalChanges: omitKey(s.editorExternalChanges, filePath), })); try { localStorage.removeItem(`editor-draft:${filePath}`); } catch { // ignore } } catch (error) { const message = error instanceof Error ? error.message : String(error); if (message.startsWith('CONFLICT')) { log.error('Save conflict detected:', filePath); set((s) => ({ editorSaving: omitKey(s.editorSaving, filePath), editorConflictFile: filePath, })); return; } log.error('Failed to save file:', filePath, message); set((s) => ({ editorSaving: omitKey(s.editorSaving, filePath), editorSaveError: { ...s.editorSaveError, [filePath]: message }, })); } })() ); } await Promise.allSettled(promises); }, discardChanges: (filePath: string) => { const { editorModifiedFiles, editorSaveError } = get(); set({ editorModifiedFiles: omitKey(editorModifiedFiles, filePath), editorSaveError: omitKey(editorSaveError, filePath), }); try { localStorage.removeItem(`editor-draft:${filePath}`); } catch { // localStorage may not be available } }, hasUnsavedChanges: () => { return Object.keys(get().editorModifiedFiles).length > 0; }, // ═══════════════════════════════════════════════════════ // Group 4: File operations // ═══════════════════════════════════════════════════════ createFileInTree: async (parentDir: string, fileName: string) => { set({ editorCreating: true, editorCreateError: null }); try { const result = await api.editor.createFile(parentDir, fileName); // Refresh parent directory in the tree await refreshDirectory(get, set, parentDir); set({ editorCreating: false }); return result.filePath; } catch (error) { const message = error instanceof Error ? error.message : String(error); log.error('Failed to create file:', message); set({ editorCreating: false, editorCreateError: message }); return null; } }, createDirInTree: async (parentDir: string, dirName: string) => { set({ editorCreating: true, editorCreateError: null }); try { const result = await api.editor.createDir(parentDir, dirName); // Refresh parent directory in the tree await refreshDirectory(get, set, parentDir); set({ editorCreating: false }); return result.dirPath; } catch (error) { const message = error instanceof Error ? error.message : String(error); log.error('Failed to create directory:', message); set({ editorCreating: false, editorCreateError: message }); return null; } }, deleteFileFromTree: async (filePath: string) => { try { await api.editor.deleteFile(filePath); // Close tab if the deleted file is open const { editorOpenTabs } = get(); const tabsToClose = editorOpenTabs.filter((t) => isPathPrefix(filePath, t.filePath)); for (const tab of tabsToClose) { get().closeEditorTab(tab.id); } // Refresh parent directory const parentDir = filePath.substring(0, lastSeparatorIndex(filePath)); if (parentDir) { await refreshDirectory(get, set, parentDir); } return true; } catch (error) { const message = error instanceof Error ? error.message : String(error); log.error('Failed to delete file:', message); return false; } }, moveFileInTree: async (sourcePath: string, destDir: string) => { const { editorSaving } = get(); // Guard: don't move during save if (editorSaving[sourcePath]) { log.error('moveFileInTree: blocked — file is being saved:', sourcePath); return false; } try { const result = await api.editor.moveFile(sourcePath, destDir); const { newPath, isDirectory } = result; const oldParent = sourcePath.substring(0, lastSeparatorIndex(sourcePath)); // Record move timestamps for watcher cooldown recentMoveTimestamps.set(sourcePath, Date.now()); recentMoveTimestamps.set(newPath, Date.now()); // Atomic remap of all path-keyed state set((s) => { const tabs = s.editorOpenTabs.map((tab) => { const remapped = remapPath(tab.filePath, sourcePath, newPath); if (remapped === tab.filePath) return tab; const fileName = getBasename(remapped) || 'file'; return { ...tab, id: remapped, filePath: remapped, fileName, language: getLanguageFromFileName(fileName), }; }); return { editorOpenTabs: computeDisambiguatedTabs(tabs), editorActiveTabId: remapPath(s.editorActiveTabId ?? '', sourcePath, newPath) || s.editorActiveTabId, editorModifiedFiles: remapRecord(s.editorModifiedFiles, sourcePath, newPath), editorSaving: remapRecord(s.editorSaving, sourcePath, newPath), editorSaveError: remapRecord(s.editorSaveError, sourcePath, newPath), editorFileLoading: remapRecord(s.editorFileLoading, sourcePath, newPath), editorExternalChanges: remapRecord(s.editorExternalChanges, sourcePath, newPath), editorFileMtimes: remapRecord(s.editorFileMtimes, sourcePath, newPath), editorExpandedDirs: remapRecord(s.editorExpandedDirs, sourcePath, newPath), }; }); // Keep open-files-only watcher in sync with remapped tab paths scheduleSyncWatchedFiles(get); // Remap bridge state for each affected tab const { editorOpenTabs } = get(); for (const tab of editorOpenTabs) { // Check if this tab was affected by the move const originalPath = reverseRemapPath(tab.filePath, sourcePath, newPath); if (originalPath !== tab.filePath) { editorBridge.remapState(originalPath, tab.filePath); } } // Also remap for single file case (directories have no direct bridge state) if (!isDirectory) { editorBridge.remapState(sourcePath, newPath); } // Remap localStorage drafts try { for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (key?.startsWith('editor-draft:')) { const draftPath = key.slice('editor-draft:'.length); const remapped = remapPath(draftPath, sourcePath, newPath); if (remapped !== draftPath) { const value = localStorage.getItem(key); localStorage.removeItem(key); if (value !== null) localStorage.setItem(`editor-draft:${remapped}`, value); } } } } catch { // localStorage may not be available } // Remap recentSaveTimestamps for (const [key, ts] of [...recentSaveTimestamps.entries()]) { const remapped = remapPath(key, sourcePath, newPath); if (remapped !== key) { recentSaveTimestamps.delete(key); recentSaveTimestamps.set(remapped, ts); } } // Refresh directories and git status in background void refreshDirectory(get, set, oldParent); void refreshDirectory(get, set, destDir); void get().fetchGitStatus(); return true; } catch (error) { const message = error instanceof Error ? error.message : String(error); log.error('moveFileInTree failed:', message); return false; } }, renameFileInTree: async (sourcePath: string, newName: string) => { const { editorSaving } = get(); if (editorSaving[sourcePath]) { log.error('renameFileInTree: blocked — file is being saved:', sourcePath); return false; } try { const result = await api.editor.renameFile(sourcePath, newName); const { newPath, isDirectory } = result; const parentDir = sourcePath.substring(0, lastSeparatorIndex(sourcePath)); recentMoveTimestamps.set(sourcePath, Date.now()); recentMoveTimestamps.set(newPath, Date.now()); set((s) => { const tabs = s.editorOpenTabs.map((tab) => { const remapped = remapPath(tab.filePath, sourcePath, newPath); if (remapped === tab.filePath) return tab; const fileName = getBasename(remapped) || 'file'; return { ...tab, id: remapped, filePath: remapped, fileName, language: getLanguageFromFileName(fileName), }; }); return { editorOpenTabs: computeDisambiguatedTabs(tabs), editorActiveTabId: remapPath(s.editorActiveTabId ?? '', sourcePath, newPath) || s.editorActiveTabId, editorModifiedFiles: remapRecord(s.editorModifiedFiles, sourcePath, newPath), editorSaving: remapRecord(s.editorSaving, sourcePath, newPath), editorSaveError: remapRecord(s.editorSaveError, sourcePath, newPath), editorFileLoading: remapRecord(s.editorFileLoading, sourcePath, newPath), editorExternalChanges: remapRecord(s.editorExternalChanges, sourcePath, newPath), editorFileMtimes: remapRecord(s.editorFileMtimes, sourcePath, newPath), editorExpandedDirs: remapRecord(s.editorExpandedDirs, sourcePath, newPath), }; }); // Keep open-files-only watcher in sync with remapped tab paths scheduleSyncWatchedFiles(get); // Remap bridge state const { editorOpenTabs } = get(); for (const tab of editorOpenTabs) { const originalPath = reverseRemapPath(tab.filePath, sourcePath, newPath); if (originalPath !== tab.filePath) { editorBridge.remapState(originalPath, tab.filePath); } } if (!isDirectory) { editorBridge.remapState(sourcePath, newPath); } // Remap localStorage drafts try { for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (key?.startsWith('editor-draft:')) { const draftPath = key.slice('editor-draft:'.length); const remapped = remapPath(draftPath, sourcePath, newPath); if (remapped !== draftPath) { const value = localStorage.getItem(key); localStorage.removeItem(key); if (value !== null) localStorage.setItem(`editor-draft:${remapped}`, value); } } } } catch { // localStorage may not be available } for (const [key, ts] of [...recentSaveTimestamps.entries()]) { const remapped = remapPath(key, sourcePath, newPath); if (remapped !== key) { recentSaveTimestamps.delete(key); recentSaveTimestamps.set(remapped, ts); } } void refreshDirectory(get, set, parentDir); void get().fetchGitStatus(); return true; } catch (error) { const message = error instanceof Error ? error.message : String(error); log.error('renameFileInTree failed:', message); return false; } }, // ═══════════════════════════════════════════════════════ // Group 5: Git status + file watcher + line wrap // ═══════════════════════════════════════════════════════ fetchGitStatus: async () => { set({ editorGitLoading: true }); try { const t0 = performance.now(); const result = await api.editor.gitStatus(); const ipcMs = performance.now() - t0; const t1 = performance.now(); set({ editorGitFiles: result.files, editorGitBranch: result.branch, editorIsGitRepo: result.isGitRepo, editorGitLoading: false, }); const setMs = performance.now() - t1; log.info( `[perf] fetchGitStatus: IPC=${ipcMs.toFixed(1)}ms, set=${setMs.toFixed(1)}ms, files=${result.files.length}` ); } catch (error) { log.error('Failed to fetch git status:', error); set({ editorGitLoading: false }); } }, toggleWatcher: async (enable: boolean) => { try { await api.editor.watchDir(enable); set({ editorWatcherEnabled: enable }); try { localStorage.setItem('editor-watcher-enabled', String(enable)); } catch { // localStorage may not be available } if (enable) { scheduleSyncWatchedFiles(get); scheduleSyncWatchedDirs(get); } else { // Ensure main process stops watching files promptly. lastWatchedFilesKey = ''; lastWatchedDirsKey = ''; void api.editor.setWatchedFiles([]); void api.editor.setWatchedDirs([]); } } catch (error) { log.error('Failed to toggle watcher:', error); } }, toggleLineWrap: () => { set((s) => { const next = !s.editorLineWrap; try { localStorage.setItem('editor-line-wrap', String(next)); } catch { // localStorage may not be available } return { editorLineWrap: next }; }); }, handleExternalFileChange: (event: EditorFileChangeEvent) => { // Avoid per-event logging (can freeze renderer during bursts on large repos). watcherEventCounts[event.type] = (watcherEventCounts[event.type] ?? 0) + 1; if (!watcherEventLogTimer) { watcherEventLogTimer = setTimeout(() => { watcherEventLogTimer = null; const counts = watcherEventCounts; watcherEventCounts = { change: 0, create: 0, delete: 0 }; // Keep a single lightweight summary line. log.info( `[perf] editor watcher events (2s): change=${counts.change}, create=${counts.create}, delete=${counts.delete}` ); }, 2000); } const { editorOpenTabs, editorProjectPath, editorSaving } = get(); // Ignore watcher events for files we are currently saving (our own write) if (editorSaving[event.path]) return; // Ignore watcher events within cooldown after save // (covers race: save completes → editorSaving cleared → watcher fires late) const lastSaveTime = recentSaveTimestamps.get(event.path); if (lastSaveTime && Date.now() - lastSaveTime < SAVE_COOLDOWN_MS) return; // Ignore watcher events within cooldown after move const lastMoveTime = recentMoveTimestamps.get(event.path); if (lastMoveTime && Date.now() - lastMoveTime < MOVE_COOLDOWN_MS) return; // Track changes for open files const isOpenFile = editorOpenTabs.some((t) => t.filePath === event.path); if (isOpenFile || event.type === 'delete') { set((s) => ({ editorExternalChanges: { ...s.editorExternalChanges, [event.path]: event.type, }, })); } // Refresh git status on change — throttled to avoid expensive work during bursts. // Main process already caches git status for 5s, but IPC + store updates still cost. if (!gitStatusThrottleTimer) { gitStatusThrottleTimer = setTimeout(() => { gitStatusThrottleTimer = null; void get().fetchGitStatus(); }, GIT_STATUS_THROTTLE_MS); } // Refresh parent directory in tree for create/delete if (event.type === 'create' || event.type === 'delete') { invalidateQuickOpenCache(); const parentDir = event.path.substring(0, lastSeparatorIndex(event.path)); if (parentDir && editorProjectPath) { const existing = dirRefreshDebounceTimers.get(parentDir); if (existing) clearTimeout(existing); const timer = setTimeout(() => { dirRefreshDebounceTimers.delete(parentDir); void refreshDirectory(get, set, parentDir); }, DIR_REFRESH_DEBOUNCE_MS); dirRefreshDebounceTimers.set(parentDir, timer); } } }, clearExternalChange: (filePath: string) => { set((s) => ({ editorExternalChanges: omitKey(s.editorExternalChanges, filePath), })); }, setFileMtime: (filePath: string, mtimeMs: number) => { set((s) => ({ editorFileMtimes: { ...s.editorFileMtimes, [filePath]: mtimeMs }, })); }, forceOverwrite: async (filePath: string) => { const content = editorBridge.getContent(filePath); if (content === null) { log.error('forceOverwrite: no content available for', filePath); return; } set((s) => ({ editorSaving: { ...s.editorSaving, [filePath]: true }, editorConflictFile: null, })); try { // No baselineMtimeMs → skip conflict check on backend const result = await api.editor.writeFile(filePath, content); // Record save timestamp BEFORE clearing editorSaving (watcher race guard) recentSaveTimestamps.set(filePath, Date.now()); set((s) => ({ editorModifiedFiles: omitKey(s.editorModifiedFiles, filePath), editorSaving: omitKey(s.editorSaving, filePath), editorFileMtimes: { ...s.editorFileMtimes, [filePath]: result.mtimeMs }, editorExternalChanges: omitKey(s.editorExternalChanges, filePath), })); try { localStorage.removeItem(`editor-draft:${filePath}`); } catch { // localStorage may not be available } } catch (error) { const message = error instanceof Error ? error.message : String(error); log.error('Failed to force overwrite:', message); set((s) => ({ editorSaving: omitKey(s.editorSaving, filePath), editorSaveError: { ...s.editorSaveError, [filePath]: message }, })); } }, resolveConflict: () => { set({ editorConflictFile: null }); }, }); // ============================================================================= // Helpers // ============================================================================= /** * Refresh a directory's children in the file tree via IPC readDir + merge. */ async function refreshDirectory( get: () => AppState, set: (partial: Partial) => void, dirPath: string ): Promise { try { const t0 = performance.now(); const result = await api.editor.readDir(dirPath); log.info( `[perf] refreshDirectory: IPC=${(performance.now() - t0).toFixed(1)}ms, entries=${result.entries.length}, dir=${getBasename(dirPath)}` ); const currentTree = get().editorFileTree; if (!currentTree) return; const projectPath = get().editorProjectPath; if (dirPath === projectPath) { // Root refresh — tree IS the root's children, so preserve expanded subtrees // by merging new entries with existing children data const existingByPath = new Map(); for (const entry of currentTree) { existingByPath.set(entry.path, entry); } const merged = result.entries.map((entry) => { const existing = existingByPath.get(entry.path); // Preserve expanded subtree children for directories that still exist if (existing?.children && entry.type === 'directory') { return { ...entry, children: existing.children }; } return entry; }); set({ editorFileTree: merged }); } else { const updatedTree = mergeChildrenIntoTree(currentTree, dirPath, result.entries); set({ editorFileTree: updatedTree }); } } catch (error) { log.error('Failed to refresh directory:', error); } } /** * Remap a single path: if it matches oldPath exactly or is a child of oldPath, * replace the prefix with newPath. */ function remapPath(p: string, oldPath: string, newPath: string): string { const oldParts = splitPath(oldPath); const pParts = splitPath(p); if (oldParts.length === 0) return p; const win = isWindowsishPath(oldPath) || isWindowsishPath(p) || isWindowsishPath(newPath); const eq = (a: string, b: string): boolean => win ? a.toLowerCase() === b.toLowerCase() : a === b; const matchesPrefix = pParts.length >= oldParts.length && oldParts.every((seg, i) => eq(seg, pParts[i])); if (!matchesPrefix) return p; const suffix = pParts.slice(oldParts.length); return suffix.length > 0 ? joinPath(newPath, ...suffix) : newPath; } /** * Reverse remap: given a potentially-remapped path, recover the original path. * Used to identify which bridge caches to remap. */ function reverseRemapPath(p: string, oldPath: string, newPath: string): string { const newParts = splitPath(newPath); const pParts = splitPath(p); if (newParts.length === 0) return p; const win = isWindowsishPath(oldPath) || isWindowsishPath(p) || isWindowsishPath(newPath); const eq = (a: string, b: string): boolean => win ? a.toLowerCase() === b.toLowerCase() : a === b; const matchesPrefix = pParts.length >= newParts.length && newParts.every((seg, i) => eq(seg, pParts[i])); if (!matchesPrefix) return p; const suffix = pParts.slice(newParts.length); return suffix.length > 0 ? joinPath(oldPath, ...suffix) : oldPath; } /** * Remap all keys in a Record that match or are children of oldPath. */ function remapRecord( record: Record, oldPath: string, newPath: string ): Record { const result: Record = {}; let changed = false; for (const [key, value] of Object.entries(record)) { const remapped = remapPath(key, oldPath, newPath); if (remapped !== key) changed = true; result[remapped] = value; } return changed ? result : record; } /** * Recursively merge children into the tree at the matching directory path. * Returns the same array reference if nothing changed — preserves React.memo equality. */ function mergeChildrenIntoTree( tree: FileTreeEntry[], targetPath: string, children: FileTreeEntry[] ): FileTreeEntry[] { let changed = false; const result = tree.map((entry) => { if (entry.path === targetPath && entry.type === 'directory') { changed = true; return { ...entry, children }; } if (entry.children) { const updated = mergeChildrenIntoTree(entry.children, targetPath, children); if (updated !== entry.children) { changed = true; return { ...entry, children: updated }; } } return entry; }); return changed ? result : tree; }