/** * Full-screen project editor overlay. * * Pattern: follows ChangeReviewDialog.tsx — raw
with fixed inset-0, not Radix Dialog. * macOS traffic light padding, inert on background, Escape to close. */ import { useCallback, useEffect, useRef, useState } from 'react'; import { Button } from '@renderer/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@renderer/components/ui/dialog'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useEditorKeyboardShortcuts } from '@renderer/hooks/useEditorKeyboardShortcuts'; import { useStore } from '@renderer/store'; import { buildFileAction, buildSelectionAction } from '@renderer/utils/buildSelectionAction'; import { shortcutLabel } from '@renderer/utils/platformKeys'; import { getBasename, getDirname } from '@shared/utils/platformPath'; import { AlertTriangle, HelpCircle, Loader2, PanelLeftClose, PanelLeftOpen, RefreshCw, RotateCcw, X, } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { CodeMirrorEditor } from './CodeMirrorEditor'; import { EditorBinaryState } from './EditorBinaryState'; import { EditorEmptyState } from './EditorEmptyState'; import { EditorErrorBoundary } from './EditorErrorBoundary'; import { EditorErrorState } from './EditorErrorState'; import { EditorFileTree } from './EditorFileTree'; import { EditorSelectionMenu } from './EditorSelectionMenu'; import { EditorShortcutsHelp } from './EditorShortcutsHelp'; import { EditorStatusBar } from './EditorStatusBar'; import { EditorTabBar } from './EditorTabBar'; import { EditorToolbar } from './EditorToolbar'; import { GoToLineDialog } from './GoToLineDialog'; import { MarkdownSplitView } from './MarkdownSplitView'; import { QuickOpenDialog } from './QuickOpenDialog'; import { SearchInFilesPanel } from './SearchInFilesPanel'; import type { MdPreviewMode } from './EditorToolbar'; import type { EditorSelectionAction, EditorSelectionInfo, ReadFileResult, } from '@shared/types/editor'; // ============================================================================= // Types // ============================================================================= interface ProjectEditorOverlayProps { projectPath: string; onClose: () => void; /** Called when user triggers an action from the selection menu */ onEditorAction?: (action: EditorSelectionAction) => void; } // ============================================================================= // Component // ============================================================================= export const ProjectEditorOverlay = ({ projectPath, onClose, onEditorAction, }: ProjectEditorOverlayProps): React.ReactElement => { // Data selectors — grouped with useShallow to prevent unnecessary re-renders const { activeTabId, openTabs, modifiedFiles, saveErrors, externalChanges, conflictFile } = useStore( useShallow((s) => ({ activeTabId: s.editorActiveTabId, openTabs: s.editorOpenTabs, modifiedFiles: s.editorModifiedFiles, saveErrors: s.editorSaveError, externalChanges: s.editorExternalChanges, conflictFile: s.editorConflictFile, })) ); // Actions — stable references in Zustand, no grouping needed const openEditor = useStore((s) => s.openEditor); const closeEditor = useStore((s) => s.closeEditor); const openFile = useStore((s) => s.openFile); const closeEditorTab = useStore((s) => s.closeEditorTab); const saveFile = useStore((s) => s.saveFile); const hasUnsavedChanges = useStore((s) => s.hasUnsavedChanges); const saveAllFiles = useStore((s) => s.saveAllFiles); const discardChanges = useStore((s) => s.discardChanges); const clearExternalChange = useStore((s) => s.clearExternalChange); const forceOverwrite = useStore((s) => s.forceOverwrite); const resolveConflict = useStore((s) => s.resolveConflict); const setFileMtime = useStore((s) => s.setFileMtime); const fetchGitStatus = useStore((s) => s.fetchGitStatus); const [fileContent, setFileContent] = useState(null); const [fileLoading, setFileLoading] = useState(false); const [fileError, setFileError] = useState(null); const [cursorLine, setCursorLine] = useState(1); const [cursorCol, setCursorCol] = useState(1); // Unsaved changes confirmation (overlay close) const [showConfirmClose, setShowConfirmClose] = useState(false); // Unsaved changes confirmation (single tab close) const [confirmCloseTabId, setConfirmCloseTabId] = useState(null); // Draft recovery banner const [draftRecoveredFile, setDraftRecoveredFile] = useState(null); // Bumped on draft discard to force CodeMirrorEditor remount (fresh state cache) const [editorResetKey, setEditorResetKey] = useState(0); // Selection action menu const [selectionInfo, setSelectionInfo] = useState(null); const editorContentRef = useRef(null); const [containerRect, setContainerRect] = useState(() => new DOMRect()); // Markdown preview state const [mdPreviewMode, setMdPreviewMode] = useState('off'); const [liveContent, setLiveContent] = useState(''); const [splitRatio, setSplitRatio] = useState(() => { try { const stored = localStorage.getItem('editor:mdSplitRatio'); return stored ? Math.max(0.2, Math.min(0.8, Number(stored))) : 0.5; } catch { return 0.5; } }); // Iter-4: New state const [quickOpenVisible, setQuickOpenVisible] = useState(false); const [searchPanelVisible, setSearchPanelVisible] = useState(false); const [goToLineVisible, setGoToLineVisible] = useState(false); const [shortcutsHelpVisible, setShortcutsHelpVisible] = useState(false); const [sidebarVisible, setSidebarVisibleRaw] = useState(() => { try { return localStorage.getItem('editor-sidebar-visible') !== 'false'; } catch { return true; } }); const overlayRef = useRef(null); // IPC deduplication: reuse in-flight readFile promise for same path const pendingReads = useRef(new Map>()); // Active tab metadata const activeTab = openTabs.find((t) => t.id === activeTabId) ?? null; const isMarkdown = activeTab?.language === 'Markdown'; // Auto-enable split preview for markdown tabs, reset for non-markdown useEffect(() => { if (isMarkdown) { setMdPreviewMode((m) => (m === 'off' ? 'split' : m)); } else { setMdPreviewMode('off'); } }, [isMarkdown, activeTabId]); // Persist split ratio const handleSplitRatioChange = useCallback((ratio: number) => { setSplitRatio(ratio); try { localStorage.setItem('editor:mdSplitRatio', String(ratio)); } catch { // localStorage unavailable } }, []); const handleLiveContent = useCallback((content: string) => { setLiveContent(content); }, []); const toggleMdSplit = useCallback(() => { setMdPreviewMode((m) => (m === 'split' ? 'off' : 'split')); }, []); const toggleMdPreview = useCallback(() => { setMdPreviewMode((m) => (m === 'preview' ? 'off' : 'preview')); }, []); // Initialize live content when entering preview mode or switching files useEffect(() => { if (mdPreviewMode !== 'off' && fileContent?.content) { setLiveContent(fileContent.content); } }, [mdPreviewMode, fileContent?.content]); // Content for preview: use live content when available, fallback to file content const previewContent = liveContent || fileContent?.content || ''; const loadFileContent = useCallback( async (filePath: string) => { setFileLoading(true); setFileError(null); setFileContent(null); try { const t0 = performance.now(); let promise = pendingReads.current.get(filePath); const wasCached = !!promise; if (!promise) { promise = window.electronAPI.editor.readFile(filePath); pendingReads.current.set(filePath, promise); void promise.finally(() => pendingReads.current.delete(filePath)); } const result = await promise; const ipcMs = performance.now() - t0; console.debug( `[perf] loadFileContent: IPC=${ipcMs.toFixed(1)}ms, size=${result.size}, truncated=${result.truncated}, cached=${wasCached}, file=${getBasename(filePath)}` ); setFileContent(result); // Track baseline mtime for conflict detection if (result.mtimeMs) { setFileMtime(filePath, result.mtimeMs); } } catch (error) { const message = error instanceof Error ? error.message : String(error); setFileError(message); } finally { setFileLoading(false); } }, [setFileMtime] ); // Active tab save error const activeSaveError = activeTabId ? (saveErrors[activeTabId] ?? null) : null; const pendingRevealFile = useStore((s) => s.editorPendingRevealFile); const revealAndOpenFile = useStore((s) => s.revealAndOpenFile); // Initialize editor on mount useEffect(() => { void openEditor(projectPath); return () => { closeEditor(); }; }, [projectPath, openEditor, closeEditor]); // Process pending file reveal after editor initializes. // Guard: wait until the file tree is actually loaded (not null) and not loading. // Without the fileTree check, the effect fires on mount when fileTreeLoading is // still at its initial `false` value — before openEditor sets it to `true`. const fileTreeLoading = useStore((s) => s.editorFileTreeLoading); const fileTreeLoaded = useStore((s) => s.editorFileTree !== null); useEffect(() => { if (pendingRevealFile && !fileTreeLoading && fileTreeLoaded) { void revealAndOpenFile(pendingRevealFile); } }, [pendingRevealFile, fileTreeLoading, fileTreeLoaded, revealAndOpenFile]); // Keep container rect fresh for selection menu positioning (resize, sidebar toggle) useEffect(() => { const el = editorContentRef.current; if (!el) return; const updateRect = (): void => setContainerRect(el.getBoundingClientRect()); updateRect(); const observer = new ResizeObserver(updateRect); observer.observe(el); return () => observer.disconnect(); }, []); // Escape to close + F5 to refresh (with dialog guard) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { // Skip if another handler already consumed this Escape // (e.g. CodeMirror search panel close, or React search input onKeyDown) if (e.defaultPrevented) return; // Don't close overlay if a dialog is open — dialog handles its own Escape if (quickOpenVisible || searchPanelVisible || shortcutsHelpVisible) return; if (showConfirmClose || confirmCloseTabId) return; if (conflictFile) return; e.preventDefault(); handleCloseRequest(); } // F5: Manual refresh (git status + file tree) if (e.key === 'F5') { e.preventDefault(); handleManualRefresh(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); // eslint-disable-next-line react-hooks/exhaustive-deps -- handleCloseRequest and handleManualRefresh are stable callbacks; listing dialog visibility guards as deps is sufficient }, [ quickOpenVisible, searchPanelVisible, shortcutsHelpVisible, showConfirmClose, confirmCloseTabId, conflictFile, ]); // Focus trap — focus overlay on mount useEffect(() => { overlayRef.current?.focus(); }, []); // Load file content when active tab changes useEffect(() => { // Clear selection menu from previous tab setSelectionInfo(null); if (!activeTabId) { setFileContent(null); setFileLoading(false); setFileError(null); return; } void loadFileContent(activeTabId); }, [activeTabId, loadFileContent]); // Clear draft recovery banner when switching tabs useEffect(() => { if (activeTabId !== draftRecoveredFile) { setDraftRecoveredFile(null); } }, [activeTabId, draftRecoveredFile]); const handleFileSelect = useCallback( (filePath: string) => { openFile(filePath); }, [openFile] ); const handleRetry = useCallback(() => { if (activeTabId) { void loadFileContent(activeTabId); } }, [activeTabId, loadFileContent]); const handleCursorChange = useCallback((line: number, col: number) => { setCursorLine(line); setCursorCol(col); }, []); // --- Overlay close handlers --- const handleCloseRequest = useCallback(() => { if (hasUnsavedChanges()) { setShowConfirmClose(true); } else { onClose(); } }, [onClose, hasUnsavedChanges]); const handleSaveAndClose = useCallback(async () => { await saveAllFiles(); setShowConfirmClose(false); onClose(); }, [saveAllFiles, onClose]); const handleDiscardAndClose = useCallback(() => { setShowConfirmClose(false); onClose(); }, [onClose]); const handleCancelClose = useCallback(() => { setShowConfirmClose(false); }, []); // --- Tab close handlers (with dirty check) --- const handleRequestCloseTab = useCallback( (tabId: string) => { if (modifiedFiles[tabId]) { setConfirmCloseTabId(tabId); } else { closeEditorTab(tabId); } }, [modifiedFiles, closeEditorTab] ); // Listen for editor-close-tab custom events from keyboard shortcut hook useEffect(() => { const handler = (e: Event) => { const tabId = (e as CustomEvent).detail as string; handleRequestCloseTab(tabId); }; window.addEventListener('editor-close-tab', handler); return () => window.removeEventListener('editor-close-tab', handler); }, [handleRequestCloseTab]); const handleSaveAndCloseTab = useCallback(async () => { if (!confirmCloseTabId) return; await saveFile(confirmCloseTabId); closeEditorTab(confirmCloseTabId); setConfirmCloseTabId(null); }, [confirmCloseTabId, saveFile, closeEditorTab]); const handleDiscardAndCloseTab = useCallback(() => { if (!confirmCloseTabId) return; closeEditorTab(confirmCloseTabId); setConfirmCloseTabId(null); }, [confirmCloseTabId, closeEditorTab]); const handleCancelCloseTab = useCallback(() => { setConfirmCloseTabId(null); }, []); // --- Draft recovery handlers --- const handleDraftRecovered = useCallback((filePath: string) => { setDraftRecoveredFile(filePath); }, []); const handleDiscardDraft = useCallback(() => { if (!draftRecoveredFile || !activeTabId) return; discardChanges(draftRecoveredFile); setDraftRecoveredFile(null); setFileContent(null); setEditorResetKey((k) => k + 1); void loadFileContent(activeTabId); }, [draftRecoveredFile, activeTabId, discardChanges, loadFileContent]); const handleDismissDraftBanner = useCallback(() => { setDraftRecoveredFile(null); }, []); // --- Iter-5: Conflict handlers --- const handleForceOverwrite = useCallback(() => { if (!conflictFile) return; void forceOverwrite(conflictFile); }, [conflictFile, forceOverwrite]); const handleCancelConflict = useCallback(() => { resolveConflict(); }, [resolveConflict]); // --- Iter-5: External change handlers --- const handleReloadExternalChange = useCallback(() => { if (!activeTabId) return; clearExternalChange(activeTabId); discardChanges(activeTabId); setFileContent(null); setEditorResetKey((k) => k + 1); void loadFileContent(activeTabId); }, [activeTabId, clearExternalChange, discardChanges, loadFileContent]); const handleKeepMine = useCallback(() => { if (!activeTabId) return; clearExternalChange(activeTabId); }, [activeTabId, clearExternalChange]); // --- Iter-5: Watcher toggle --- // --- Iter-5: Manual refresh (F5) --- const handleManualRefresh = useCallback(() => { void fetchGitStatus(); }, [fetchGitStatus]); // --- Iter-4: Toggle handlers --- const toggleQuickOpen = useCallback(() => { setQuickOpenVisible((v) => !v); }, []); const toggleSearchPanel = useCallback(() => { setSearchPanelVisible((v) => !v); }, []); const toggleGoToLine = useCallback(() => { setGoToLineVisible((v) => !v); }, []); const toggleSidebar = useCallback(() => { setSidebarVisibleRaw((v) => { const next = !v; try { localStorage.setItem('editor-sidebar-visible', String(next)); } catch { // localStorage unavailable } return next; }); }, []); // --- Iter-4: Search result selection --- const setPendingGoToLine = useStore((s) => s.setPendingGoToLine); const handleSearchSelectMatch = useCallback( (filePath: string, line: number) => { setPendingGoToLine(line); openFile(filePath); }, [openFile, setPendingGoToLine] ); // --- Keyboard shortcuts --- useEditorKeyboardShortcuts({ onToggleQuickOpen: toggleQuickOpen, onToggleSearchPanel: toggleSearchPanel, onToggleGoToLine: toggleGoToLine, onToggleSidebar: toggleSidebar, onClose: handleCloseRequest, onToggleMdSplit: isMarkdown ? toggleMdSplit : undefined, onToggleMdPreview: isMarkdown ? toggleMdPreview : undefined, }); const projectName = getBasename(projectPath) || projectPath; return (
{/* Header */}
{projectName} {projectPath}
Refresh git status (F5) Keyboard shortcuts Close editor (Esc)
{/* Main content */}
{/* Search in files panel (replaces sidebar when visible) */} {searchPanelVisible && (
setSearchPanelVisible(false)} onSelectMatch={handleSearchSelectMatch} />
)} {/* File tree sidebar */} {sidebarVisible && !searchPanelVisible && (
Explorer Hide sidebar ({shortcutLabel('⌘ B', 'Ctrl+B')})
onEditorAction(buildFileAction('createTask', filePath, projectPath)) : undefined } onSendMessage={ onEditorAction ? (filePath: string) => onEditorAction(buildFileAction('sendMessage', filePath, projectPath)) : undefined } />
)} {/* Sidebar toggle (when hidden) */} {!sidebarVisible && !searchPanelVisible && ( Show sidebar ({shortcutLabel('⌘ B', 'Ctrl+B')}) )} {/* Editor area */}
{/* Tab bar */} {/* Toolbar */} {/* Draft recovery banner */} {draftRecoveredFile && activeTabId === draftRecoveredFile && (
Recovered unsaved changes from a previous session.
)} {/* Save error banner */} {activeSaveError && (
Save failed: {activeSaveError}
)} {/* External change banner */} {activeTabId && externalChanges[activeTabId] && (
{externalChanges[activeTabId] === 'delete' ? 'File no longer exists on disk.' : 'File changed on disk.'} {externalChanges[activeTabId] === 'delete' ? ( ) : ( <> )}
)} {/* Editor content */}
{fileLoading && (
)} {fileError && } {fileContent?.isBinary && activeTabId && ( )} {fileContent && !fileContent.isBinary && activeTabId && (
{/* Code editor — always mounted, hidden via display:none in preview mode */}
{/* Resize handle + Preview pane */} {mdPreviewMode !== 'off' && ( )}
)} {!fileLoading && !fileError && !fileContent && !activeTabId && } {/* Selection action menu */} {selectionInfo && onEditorAction && ( { onEditorAction(buildSelectionAction('sendMessage', selectionInfo)); setSelectionInfo(null); }} onCreateTask={() => { onEditorAction(buildSelectionAction('createTask', selectionInfo)); setSelectionInfo(null); }} /> )}
{/* Status bar */} {activeTab && ( )}
{/* Quick Open dialog */} {quickOpenVisible && ( setQuickOpenVisible(false)} onSelectFile={handleFileSelect} /> )} {/* Go to Line dialog */} {goToLineVisible && setGoToLineVisible(false)} />} {/* Shortcuts help modal */} {shortcutsHelpVisible && ( setShortcutsHelpVisible(false)} /> )} {/* Unsaved changes confirmation dialog — overlay close */} !open && handleCancelClose()}> Unsaved Changes You have unsaved changes. What would you like to do? {/* Save conflict dialog */} !open && handleCancelConflict()}> Save Conflict The file has been modified externally since you opened it. Overwrite with your changes? {/* Unsaved changes confirmation dialog — single tab close */} !open && handleCancelCloseTab()}> Unsaved Changes This file has unsaved changes. What would you like to do?
); };