/**
* 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 */}
{/* Save conflict dialog */}
{/* Unsaved changes confirmation dialog — single tab close */}
);
};