agent-ecosystem/src/renderer/store/slices/editorSlice.ts
iliya 075523dc42 fix: resolve 245 eslint errors across 12 files
- rendererLogs: inline IPC channel constants to fix boundary violation
- EditorFileWatcher/TaskBoundaryParser: rename UPPER_CASE class props to camelCase
- TeamConfigReader: remove unused readFileHead/extractQuotedString functions
- TeamFsWorkerClient: use crypto.randomUUID(), remove duplicated branches
- team-fs-worker: full type safety rewrite — 10 interfaces replacing ~150 any casts,
  extract readTasksDirForTeam diagnostics to avoid param reassign
- useDiffNavigation/editorSlice/buildSelectionAction: add missing return types
- useFileSuggestions: suppress intentional setState-in-effect
- store/index: suppress intentional deprecated navigator.platform fallback
- platformPath: replace vulnerable regexes with manual loops
2026-03-04 01:03:45 +02:00

1423 lines
51 KiB
TypeScript

/**
* 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<V>(record: Record<string, V>, key: string): Record<string, V> {
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<string, number>();
const SAVE_COOLDOWN_MS = 2000;
/**
* Throttle timers for watcher-driven updates.
* Keeping these module-level avoids store re-renders during bursts.
*/
let gitStatusThrottleTimer: ReturnType<typeof setTimeout> | null = null;
const GIT_STATUS_THROTTLE_MS = 1500;
const dirRefreshDebounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
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<typeof setTimeout> | null = null;
let watcherEventCounts: Record<EditorFileChangeEvent['type'], number> = {
change: 0,
create: 0,
delete: 0,
};
let watchedFilesSyncTimer: ReturnType<typeof setTimeout> | null = null;
let lastWatchedFilesKey = '';
let watchedDirsSyncTimer: ReturnType<typeof setTimeout> | 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<string, number>();
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<string, boolean>;
openEditor: (projectPath: string) => Promise<void>;
closeEditor: () => void;
loadFileTree: (dirPath: string) => Promise<void>;
expandDirectory: (dirPath: string) => Promise<void>;
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<tabId, EditorState> in useRef).
// Store only tracks dirty flags, loading, and save status.
// ═══════════════════════════════════════════════════════
editorFileLoading: Record<string, boolean>;
editorModifiedFiles: Record<string, boolean>;
editorSaving: Record<string, boolean>;
editorSaveError: Record<string, string>;
markFileModified: (filePath: string) => void;
markFileSaved: (filePath: string) => void;
saveFile: (filePath: string) => Promise<void>;
saveAllFiles: () => Promise<void>;
discardChanges: (filePath: string) => void;
hasUnsavedChanges: () => boolean;
// ═══════════════════════════════════════════════════════
// Group 4: File operations (iter-3)
// ═══════════════════════════════════════════════════════
editorCreating: boolean;
editorCreateError: string | null;
createFileInTree: (parentDir: string, fileName: string) => Promise<string | null>;
createDirInTree: (parentDir: string, dirName: string) => Promise<string | null>;
deleteFileFromTree: (filePath: string) => Promise<boolean>;
moveFileInTree: (sourcePath: string, destDir: string) => Promise<boolean>;
renameFileInTree: (sourcePath: string, newName: string) => Promise<boolean>;
// ═══════════════════════════════════════════════════════
// 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<string, EditorFileChangeEvent['type']>;
/** Baseline mtime per file (for conflict detection) */
editorFileMtimes: Record<string, number>;
/** 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<void>;
clearPendingRevealFile: () => void;
fetchGitStatus: () => Promise<void>;
toggleWatcher: (enable: boolean) => Promise<void>;
toggleLineWrap: () => void;
handleExternalFileChange: (event: EditorFileChangeEvent) => void;
clearExternalChange: (filePath: string) => void;
setFileMtime: (filePath: string, mtimeMs: number) => void;
forceOverwrite: (filePath: string) => Promise<void>;
resolveConflict: () => void;
}
// =============================================================================
// Slice Creator
// =============================================================================
export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (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<void>[] = [];
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<AppState>) => void,
dirPath: string
): Promise<void> {
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<string, FileTreeEntry>();
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<V>(
record: Record<string, V>,
oldPath: string,
newPath: string
): Record<string, V> {
const result: Record<string, V> = {};
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;
}