import { useEffect, useCallback, useRef, useState, useMemo } from 'react'; import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import { FileCode2, Save, FileWarning, Binary, Circle, PanelLeftOpen, Search, Undo2, Redo2, Settings, } from 'lucide-react'; import { createLogger } from '@automaker/utils/logger'; import { useAppStore } from '@/store/app-store'; import { getElectronAPI } from '@/lib/electron'; import { useIsMobile } from '@/hooks/use-media-query'; import { useVirtualKeyboardResize } from '@/hooks/use-virtual-keyboard-resize'; import { Button } from '@/components/ui/button'; import { HeaderActionsPanel, HeaderActionsPanelTrigger, } from '@/components/ui/header-actions-panel'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { cn } from '@/lib/utils'; import { toast } from 'sonner'; import { useFileEditorStore, type FileTreeNode, type EnhancedGitFileStatus, } from './use-file-editor-store'; import { FileTree } from './components/file-tree'; import { CodeEditor, getLanguageName, type CodeEditorHandle } from './components/code-editor'; import { EditorTabs } from './components/editor-tabs'; import { EditorSettingsForm } from './components/editor-settings-form'; import { MarkdownPreviewPanel, MarkdownViewToolbar, isMarkdownFile, } from './components/markdown-preview'; import { WorktreeDirectoryDropdown } from './components/worktree-directory-dropdown'; import { GitDetailPanel } from './components/git-detail-panel'; const logger = createLogger('FileEditorView'); // Files with these extensions are considered binary const BINARY_EXTENSIONS = new Set([ 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'svg', 'webp', 'avif', 'mp3', 'mp4', 'wav', 'ogg', 'webm', 'avi', 'mov', 'flac', 'zip', 'tar', 'gz', 'bz2', 'xz', '7z', 'rar', 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'exe', 'dll', 'so', 'dylib', 'bin', 'dat', 'woff', 'woff2', 'ttf', 'otf', 'eot', 'sqlite', 'db', ]); function isBinaryFile(filePath: string): boolean { // Extract the filename from the full path first, then get the extension. // Using split('/').pop() ensures we don't confuse dots in directory names // with the file extension. Files without an extension (no dot after the // last slash) correctly return '' here. const fileName = filePath.split('/').pop() || ''; const dotIndex = fileName.lastIndexOf('.'); // No dot found, or dot is at index 0 (dotfile like ".gitignore") → no extension if (dotIndex <= 0) return false; const ext = fileName.slice(dotIndex + 1).toLowerCase(); return BINARY_EXTENSIONS.has(ext); } interface FileEditorViewProps { initialPath?: string; } export function FileEditorView({ initialPath }: FileEditorViewProps) { const { currentProject } = useAppStore(); const currentWorktree = useAppStore((s) => currentProject?.path ? (s.currentWorktreeByProject[currentProject.path] ?? null) : null ); // Read persisted editor font settings from app store const editorFontSize = useAppStore((s) => s.editorFontSize); const editorFontFamily = useAppStore((s) => s.editorFontFamily); const setEditorFontSize = useAppStore((s) => s.setEditorFontSize); const setEditorFontFamily = useAppStore((s) => s.setEditorFontFamily); // Auto-save settings const editorAutoSave = useAppStore((s) => s.editorAutoSave); const editorAutoSaveDelay = useAppStore((s) => s.editorAutoSaveDelay); const setEditorAutoSave = useAppStore((s) => s.setEditorAutoSave); const store = useFileEditorStore(); const isMobile = useIsMobile(); const loadedProjectRef = useRef(null); const refreshTimerRef = useRef | null>(null); const editorRef = useRef(null); const autoSaveTimerRef = useRef | null>(null); const [showActionsPanel, setShowActionsPanel] = useState(false); // Derive the effective working path from the current worktree selection. // When a worktree is selected (path is non-null), use the worktree path; // otherwise fall back to the main project path. const effectivePath = useMemo(() => { if (!currentProject?.path) return null; return currentWorktree?.path ?? currentProject.path; }, [currentProject?.path, currentWorktree?.path]); // Track virtual keyboard height on mobile to prevent content from being hidden const { keyboardHeight, isKeyboardOpen } = useVirtualKeyboardResize(); const { tabs, activeTabId, markdownViewMode, mobileBrowserVisible, tabSize, wordWrap, maxFileSize, setFileTree, openTab, closeTab, closeAllTabs, setActiveTab, markTabSaved, setMarkdownViewMode, setMobileBrowserVisible, setGitStatusMap, setExpandedFolders, setEnhancedGitStatusMap, setGitBranch, setActiveFileGitDetails, activeFileGitDetails, gitBranch, enhancedGitStatusMap, } = store; const activeTab = tabs.find((t) => t.id === activeTabId) || null; // ─── Load File Tree ────────────────────────────────────────── const loadTree = useCallback( async (basePath?: string, options?: { preserveExpanded?: boolean }) => { const treePath = basePath || effectivePath; if (!treePath) return; // Snapshot expanded folders before loading so we can restore them after // (loadTree resets expandedFolders by default on initial load, but // refreshes triggered by file/folder operations should preserve state) const expandedSnapshot = options?.preserveExpanded ? new Set(useFileEditorStore.getState().expandedFolders) : null; try { const api = getElectronAPI(); // Recursive tree builder const buildTree = async (dirPath: string, depth: number = 0): Promise => { const result = await api.readdir(dirPath); if (!result.success || !result.entries) return []; const nodes: FileTreeNode[] = result.entries .sort((a, b) => { if (a.isDirectory && !b.isDirectory) return -1; if (!a.isDirectory && b.isDirectory) return 1; return a.name.localeCompare(b.name); }) .map((entry) => ({ name: entry.name, path: `${dirPath}/${entry.name}`, isDirectory: entry.isDirectory, })); // Load first level of children for directories (lazy after that) if (depth < 1) { for (const node of nodes) { if (node.isDirectory) { node.children = await buildTree(node.path, depth + 1); } } } return nodes; }; const tree = await buildTree(treePath); setFileTree(tree); if (expandedSnapshot !== null) { // Restore previously expanded folders after refresh setExpandedFolders(expandedSnapshot); } else { // Folders are collapsed by default — do not auto-expand any directories setExpandedFolders(new Set()); } } catch (error) { logger.error('Failed to load file tree:', error); } }, [effectivePath, setFileTree, setExpandedFolders] ); // ─── Load Git Status ───────────────────────────────────────── const loadGitStatus = useCallback(async () => { if (!effectivePath) return; try { const api = getElectronAPI(); if (!api.git) return; // Load basic diffs (backwards-compatible) const result = await api.git.getDiffs(effectivePath); if (result.success && result.files) { const statusMap = new Map(); for (const file of result.files) { const fullPath = `${effectivePath}/${file.path}`; // Determine status - prefer workTree, fallback to index let status = file.workTreeStatus || file.indexStatus || file.status; if (status === ' ') status = file.indexStatus || ''; if (status) { statusMap.set(fullPath, status); } } setGitStatusMap(statusMap); } // Also load enhanced status (with diff stats and staged/unstaged info) try { const enhancedResult = await api.git.getEnhancedStatus(effectivePath); if (enhancedResult.success) { if (enhancedResult.branch) { setGitBranch(enhancedResult.branch); } if (enhancedResult.files) { const enhancedMap = new Map(); for (const file of enhancedResult.files) { const fullPath = `${effectivePath}/${file.path}`; enhancedMap.set(fullPath, { indexStatus: file.indexStatus, workTreeStatus: file.workTreeStatus, isConflicted: file.isConflicted, isStaged: file.isStaged, isUnstaged: file.isUnstaged, linesAdded: file.linesAdded, linesRemoved: file.linesRemoved, statusLabel: file.statusLabel, }); } setEnhancedGitStatusMap(enhancedMap); } } } catch { // Enhanced status not available - that's okay } } catch (error) { // Git might not be available - that's okay logger.debug('Git status not available:', error); } }, [effectivePath, setGitStatusMap, setEnhancedGitStatusMap, setGitBranch]); // ─── Load subdirectory children lazily ─────────────────────── const loadSubdirectory = useCallback(async (dirPath: string): Promise => { try { const api = getElectronAPI(); const result = await api.readdir(dirPath); if (!result.success || !result.entries) return []; const nodes: FileTreeNode[] = result.entries .sort((a, b) => { if (a.isDirectory && !b.isDirectory) return -1; if (!a.isDirectory && b.isDirectory) return 1; return a.name.localeCompare(b.name); }) .map((entry) => ({ name: entry.name, path: `${dirPath}/${entry.name}`, isDirectory: entry.isDirectory, })); // Pre-load first level of children for subdirectories so they can be expanded next for (const node of nodes) { if (node.isDirectory) { try { const subResult = await api.readdir(node.path); if (subResult.success && subResult.entries) { node.children = subResult.entries .sort((a, b) => { if (a.isDirectory && !b.isDirectory) return -1; if (!a.isDirectory && b.isDirectory) return 1; return a.name.localeCompare(b.name); }) .map((entry) => ({ name: entry.name, path: `${node.path}/${entry.name}`, isDirectory: entry.isDirectory, })); } } catch { // Failed to pre-load children, they'll be loaded on expand } } } return nodes; } catch (error) { logger.error('Failed to load subdirectory:', error); return []; } }, []); // ─── Handle File Select ────────────────────────────────────── const handleFileSelect = useCallback( async (filePath: string) => { // Check if already open const existing = tabs.find((t) => t.filePath === filePath); if (existing) { setActiveTab(existing.id); return; } const fileName = filePath.split('/').pop() || 'untitled'; // Check if binary if (isBinaryFile(filePath)) { openTab({ filePath, fileName, content: '', originalContent: '', isDirty: false, scrollTop: 0, cursorLine: 1, cursorCol: 1, isBinary: true, isTooLarge: false, fileSize: 0, }); return; } try { const api = getElectronAPI(); // Check file size first const statResult = await api.stat(filePath); const fileSize = statResult.success && statResult.stats ? statResult.stats.size : 0; if (fileSize > maxFileSize) { openTab({ filePath, fileName, content: '', originalContent: '', isDirty: false, scrollTop: 0, cursorLine: 1, cursorCol: 1, isBinary: false, isTooLarge: true, fileSize, }); return; } // Read file content const result = await api.readFile(filePath); if (result.success && result.content !== undefined) { // Check if content looks binary (contains null bytes) if (result.content.includes('\0')) { openTab({ filePath, fileName, content: '', originalContent: '', isDirty: false, scrollTop: 0, cursorLine: 1, cursorCol: 1, isBinary: true, isTooLarge: false, fileSize, }); return; } openTab({ filePath, fileName, content: result.content, originalContent: result.content, isDirty: false, scrollTop: 0, cursorLine: 1, cursorCol: 1, isBinary: false, isTooLarge: false, fileSize, }); } } catch (error) { logger.error('Failed to open file:', error); } }, [tabs, setActiveTab, openTab, maxFileSize] ); // ─── Mobile-aware file select ──────────────────────────────── const handleMobileFileSelect = useCallback( async (filePath: string) => { await handleFileSelect(filePath); if (isMobile) { setMobileBrowserVisible(false); } }, [handleFileSelect, isMobile, setMobileBrowserVisible] ); // ─── Handle Save ───────────────────────────────────────────── const handleSave = useCallback(async () => { if (!activeTab || !activeTab.isDirty) return; try { const api = getElectronAPI(); const result = await api.writeFile(activeTab.filePath, activeTab.content); if (result.success) { markTabSaved(activeTab.id, activeTab.content); // Refresh git status after save loadGitStatus(); } else { logger.error('Failed to save file:', result.error); } } catch (error) { logger.error('Failed to save file:', error); } }, [activeTab, markTabSaved, loadGitStatus]); // ─── Auto Save: save a specific tab by ID ─────────────────── const saveTabById = useCallback( async (tabId: string) => { const { tabs: currentTabs } = useFileEditorStore.getState(); const tab = currentTabs.find((t) => t.id === tabId); if (!tab || !tab.isDirty) return; try { const api = getElectronAPI(); const result = await api.writeFile(tab.filePath, tab.content); if (result.success) { markTabSaved(tab.id, tab.content); loadGitStatus(); } else { logger.error('Auto-save failed:', result.error); } } catch (error) { logger.error('Auto-save failed:', error); } }, [markTabSaved, loadGitStatus] ); // ─── Auto Save: on tab switch ────────────────────────────── const prevActiveTabIdRef = useRef(null); useEffect(() => { if (!editorAutoSave) { prevActiveTabIdRef.current = activeTabId; return; } const prevTabId = prevActiveTabIdRef.current; prevActiveTabIdRef.current = activeTabId; // When switching away from a dirty tab, auto-save it if (prevTabId && prevTabId !== activeTabId) { saveTabById(prevTabId); } }, [activeTabId, editorAutoSave, saveTabById]); // ─── Auto Save: after timeout on content change ──────────── useEffect(() => { if (!editorAutoSave || !activeTab || !activeTab.isDirty) { // Clear any pending auto-save timer if (autoSaveTimerRef.current) { clearTimeout(autoSaveTimerRef.current); autoSaveTimerRef.current = null; } return; } // Debounce: set a timer to save after the configured delay if (autoSaveTimerRef.current) { clearTimeout(autoSaveTimerRef.current); } autoSaveTimerRef.current = setTimeout(() => { handleSave(); autoSaveTimerRef.current = null; }, editorAutoSaveDelay); return () => { if (autoSaveTimerRef.current) { clearTimeout(autoSaveTimerRef.current); autoSaveTimerRef.current = null; } }; }, [editorAutoSave, editorAutoSaveDelay, activeTab?.isDirty, activeTab?.content, handleSave]); // ─── Handle Search ────────────────────────────────────────── const handleSearch = useCallback(() => { if (editorRef.current) { editorRef.current.openSearch(); } }, []); // ─── Handle Undo ─────────────────────────────────────────── const handleUndo = useCallback(() => { if (editorRef.current) { editorRef.current.undo(); } }, []); // ─── Handle Redo ─────────────────────────────────────────── const handleRedo = useCallback(() => { if (editorRef.current) { editorRef.current.redo(); } }, []); // ─── File Operations ───────────────────────────────────────── const handleCreateFile = useCallback( async (parentPath: string, name: string) => { if (!effectivePath) return; const fullPath = parentPath ? `${parentPath}/${name}` : `${effectivePath}/${name}`; try { const api = getElectronAPI(); await api.writeFile(fullPath, ''); // If the new file starts with a dot, auto-enable hidden files visibility // so the created file doesn't "disappear" from the tree if (name.startsWith('.')) { const { showHiddenFiles } = useFileEditorStore.getState(); if (!showHiddenFiles) { store.setShowHiddenFiles(true); } } // Preserve expanded folders so the parent directory stays open after refresh await loadTree(undefined, { preserveExpanded: true }); // Open the newly created file (use mobile-aware select on mobile) if (isMobile) { handleMobileFileSelect(fullPath); } else { handleFileSelect(fullPath); } } catch (error) { logger.error('Failed to create file:', error); } }, [effectivePath, loadTree, handleFileSelect, handleMobileFileSelect, isMobile, store] ); const handleCreateFolder = useCallback( async (parentPath: string, name: string) => { if (!effectivePath) return; const fullPath = parentPath ? `${parentPath}/${name}` : `${effectivePath}/${name}`; try { const api = getElectronAPI(); await api.mkdir(fullPath); // If the new folder starts with a dot, auto-enable hidden files visibility // so the created folder doesn't "disappear" from the tree if (name.startsWith('.')) { const { showHiddenFiles } = useFileEditorStore.getState(); if (!showHiddenFiles) { store.setShowHiddenFiles(true); } } // Preserve expanded folders so the parent directory stays open after refresh await loadTree(undefined, { preserveExpanded: true }); } catch (error) { logger.error('Failed to create folder:', error); } }, [effectivePath, loadTree, store] ); const handleDeleteItem = useCallback( async (path: string, _isDirectory: boolean) => { try { const api = getElectronAPI(); // Use trashItem if available (safer), fallback to deleteFile if (api.trashItem) { await api.trashItem(path); } else { await api.deleteFile(path); } // Close tab if the deleted file is open const tab = tabs.find((t) => t.filePath === path); if (tab) { closeTab(tab.id); } // Preserve expanded folders so siblings of the deleted item remain visible await loadTree(undefined, { preserveExpanded: true }); loadGitStatus(); } catch (error) { logger.error('Failed to delete item:', error); } }, [tabs, closeTab, loadTree, loadGitStatus] ); const handleRenameItem = useCallback( async (oldPath: string, newName: string) => { // Extract the current file/folder name from the old path const oldName = oldPath.split('/').pop() || ''; // If the name hasn't changed, skip the rename entirely (no-op) if (newName === oldName) return; const parentPath = oldPath.substring(0, oldPath.lastIndexOf('/')); const newPath = `${parentPath}/${newName}`; try { const api = getElectronAPI(); // Use the moveItem API for an atomic rename (works for both files and directories) const result = await api.moveItem?.(oldPath, newPath); if (result?.success) { // Update the open tab if it was renamed const tab = tabs.find((t) => t.filePath === oldPath); if (tab) { closeTab(tab.id); if (isMobile) { handleMobileFileSelect(newPath); } else { handleFileSelect(newPath); } } // If the new name starts with a dot, auto-enable hidden files visibility // so the renamed file doesn't "disappear" from the tree if (newName.startsWith('.')) { const { showHiddenFiles } = useFileEditorStore.getState(); if (!showHiddenFiles) { store.setShowHiddenFiles(true); } } await loadTree(undefined, { preserveExpanded: true }); loadGitStatus(); } else { toast.error('Rename failed', { description: result?.error }); } } catch (error) { logger.error('Failed to rename item:', error); } }, [ tabs, closeTab, handleFileSelect, handleMobileFileSelect, isMobile, loadTree, loadGitStatus, store, ] ); // ─── Handle Copy Item ──────────────────────────────────────── const handleCopyItem = useCallback( async (sourcePath: string, destinationPath: string) => { try { const api = getElectronAPI(); if (!api.copyItem) { toast.error('Copy not supported'); return; } // First try without overwrite const result = await api.copyItem(sourcePath, destinationPath); if (!result.success && result.exists) { // Ask for confirmation to overwrite const confirmed = window.confirm( `"${destinationPath.split('/').pop()}" already exists at the destination. Do you want to replace it?` ); if (confirmed) { const retryResult = await api.copyItem(sourcePath, destinationPath, true); if (retryResult.success) { toast.success('Copied successfully'); await loadTree(undefined, { preserveExpanded: true }); loadGitStatus(); } else { toast.error('Copy failed', { description: retryResult.error }); } } } else if (result.success) { toast.success('Copied successfully'); await loadTree(undefined, { preserveExpanded: true }); loadGitStatus(); } else { toast.error('Copy failed', { description: result.error }); } } catch (error) { logger.error('Failed to copy item:', error); toast.error('Copy failed', { description: error instanceof Error ? error.message : 'Unknown error', }); } }, [loadTree, loadGitStatus] ); // ─── Handle Move Item ────────────────────────────────────── const handleMoveItem = useCallback( async (sourcePath: string, destinationPath: string) => { try { const api = getElectronAPI(); if (!api.moveItem) { toast.error('Move not supported'); return; } // First try without overwrite const result = await api.moveItem(sourcePath, destinationPath); if (!result.success && result.exists) { // Ask for confirmation to overwrite const confirmed = window.confirm( `"${destinationPath.split('/').pop()}" already exists at the destination. Do you want to replace it?` ); if (confirmed) { const retryResult = await api.moveItem(sourcePath, destinationPath, true); if (retryResult.success) { toast.success('Moved successfully'); // Update open tabs that point to moved files const tab = tabs.find((t) => t.filePath === sourcePath); if (tab) { closeTab(tab.id); handleFileSelect(destinationPath); } await loadTree(undefined, { preserveExpanded: true }); loadGitStatus(); } else { toast.error('Move failed', { description: retryResult.error }); } } } else if (result.success) { toast.success('Moved successfully'); // Update open tabs that point to moved files const tab = tabs.find((t) => t.filePath === sourcePath); if (tab) { closeTab(tab.id); handleFileSelect(destinationPath); } await loadTree(undefined, { preserveExpanded: true }); loadGitStatus(); } else { toast.error('Move failed', { description: result.error }); } } catch (error) { logger.error('Failed to move item:', error); toast.error('Move failed', { description: error instanceof Error ? error.message : 'Unknown error', }); } }, [tabs, closeTab, handleFileSelect, loadTree, loadGitStatus] ); // ─── Handle Download Item ────────────────────────────────── const handleDownloadItem = useCallback(async (filePath: string) => { try { const api = getElectronAPI(); if (!api.downloadItem) { toast.error('Download not supported'); return; } toast.info('Starting download...'); await api.downloadItem(filePath); toast.success('Download complete'); } catch (error) { logger.error('Failed to download item:', error); toast.error('Download failed', { description: error instanceof Error ? error.message : 'Unknown error', }); } }, []); // ─── Handle Drag and Drop Move ───────────────────────────── const handleDragDropMove = useCallback( async (sourcePaths: string[], targetFolderPath: string) => { for (const sourcePath of sourcePaths) { const fileName = sourcePath.split('/').pop() || ''; const destinationPath = `${targetFolderPath}/${fileName}`; // Prevent moving to the same location const sourceDir = sourcePath.substring(0, sourcePath.lastIndexOf('/')); if (sourceDir === targetFolderPath) continue; await handleMoveItem(sourcePath, destinationPath); } }, [handleMoveItem] ); // ─── Load git details for active file ────────────────────── const loadFileGitDetails = useCallback( async (filePath: string) => { if (!effectivePath) return; try { const api = getElectronAPI(); if (!api.git?.getDetails) return; // Get relative path const relativePath = filePath.startsWith(effectivePath) ? filePath.substring(effectivePath.length + 1) : filePath; const result = await api.git.getDetails(effectivePath, relativePath); if (result.success && result.details) { setActiveFileGitDetails(result.details); } else { setActiveFileGitDetails(null); } } catch { setActiveFileGitDetails(null); } }, [effectivePath, setActiveFileGitDetails] ); // Load git details when active tab changes useEffect(() => { if (activeTab && !activeTab.isBinary) { loadFileGitDetails(activeTab.filePath); } else { setActiveFileGitDetails(null); } }, [activeTab?.filePath, activeTab?.isBinary, loadFileGitDetails, setActiveFileGitDetails]); // ─── Handle Cursor Change ──────────────────────────────────── // Stable callback to avoid recreating CodeMirror extensions on every render. // Accessing activeTabId from the store directly prevents this callback from // changing every time the active tab switches (which would trigger an infinite // update loop: cursor change → extension rebuild → view update → cursor change). const handleCursorChange = useCallback((line: number, col: number) => { const { activeTabId: currentActiveTabId } = useFileEditorStore.getState(); if (currentActiveTabId) { useFileEditorStore.getState().updateTabCursor(currentActiveTabId, line, col); } }, []); // ─── Handle Editor Content Change ──────────────────────────── // Stable callback to avoid recreating CodeMirror extensions on every render. // Reading activeTabId from getState() keeps the reference identity stable. const handleEditorChange = useCallback((val: string) => { const { activeTabId: currentActiveTabId } = useFileEditorStore.getState(); if (currentActiveTabId) { useFileEditorStore.getState().updateTabContent(currentActiveTabId, val); } }, []); // ─── Handle Copy Path ──────────────────────────────────────── const handleCopyPath = useCallback(async (path: string) => { try { await navigator.clipboard.writeText(path); } catch (error) { logger.error('Failed to copy path to clipboard:', error); } }, []); // ─── Handle folder expand (lazy load children) ─────────────── const handleToggleFolder = useCallback( async (path: string) => { const { expandedFolders, fileTree } = useFileEditorStore.getState(); if (!expandedFolders.has(path)) { // Loading children for newly expanded folder const findNode = (nodes: FileTreeNode[]): FileTreeNode | null => { for (const n of nodes) { if (n.path === path) return n; if (n.children) { const found = findNode(n.children); if (found) return found; } } return null; }; const node = findNode(fileTree); if (node && !node.children) { const children = await loadSubdirectory(path); // Update the tree with loaded children const updateChildren = (nodes: FileTreeNode[]): FileTreeNode[] => { return nodes.map((n) => { if (n.path === path) return { ...n, children }; if (n.children) return { ...n, children: updateChildren(n.children) }; return n; }); }; setFileTree(updateChildren(fileTree)); } } // Access toggleFolder via getState() to avoid capturing a new store reference // on every render, which would make this useCallback's dependency unstable. useFileEditorStore.getState().toggleFolder(path); }, [loadSubdirectory, setFileTree] ); // ─── Initial Load ──────────────────────────────────────────── // Reload the file tree and git status when the effective working directory changes // (either from switching projects or switching worktrees) useEffect(() => { if (!effectivePath) return; if (loadedProjectRef.current === effectivePath) return; loadedProjectRef.current = effectivePath; loadTree(); loadGitStatus(); // Set up periodic refresh for git status (every 10 seconds) refreshTimerRef.current = setInterval(() => { loadGitStatus(); }, 10000); return () => { if (refreshTimerRef.current) { clearInterval(refreshTimerRef.current); } }; }, [effectivePath, loadTree, loadGitStatus]); // Open initial path if provided useEffect(() => { if (initialPath) { if (isMobile) { handleMobileFileSelect(initialPath); } else { handleFileSelect(initialPath); } } }, [initialPath, handleFileSelect, handleMobileFileSelect, isMobile]); // ─── Handle Tab Close with Dirty Check ─────────────────────── const handleTabClose = useCallback( (tabId: string) => { const tab = tabs.find((t) => t.id === tabId); if (tab?.isDirty) { const shouldClose = window.confirm( `"${tab.fileName}" has unsaved changes. Are you sure you want to close it?` ); if (!shouldClose) return; } closeTab(tabId); }, [tabs, closeTab] ); // ─── Handle Close All Tabs with Dirty Check ────────────────── const handleCloseAll = useCallback(() => { const dirtyTabs = tabs.filter((t) => t.isDirty); if (dirtyTabs.length > 0) { const fileList = dirtyTabs.map((t) => ` • ${t.fileName}`).join('\n'); const shouldClose = window.confirm( `${dirtyTabs.length} file${dirtyTabs.length > 1 ? 's have' : ' has'} unsaved changes:\n${fileList}\n\nAre you sure you want to close all tabs?` ); if (!shouldClose) return; } closeAllTabs(); }, [tabs, closeAllTabs]); // ─── Rendering ─────────────────────────────────────────────── if (!currentProject) { return (

No project selected

); } const isMarkdown = activeTab ? isMarkdownFile(activeTab.filePath) : false; const showPreview = isMarkdown && markdownViewMode !== 'editor'; const showEditor = !isMarkdown || markdownViewMode !== 'preview'; // ─── Editor Panel Content (shared between mobile and desktop) ── const renderEditorPanel = () => (
{/* Tab bar */} {/* Editor content */} {activeTab ? (
{/* Binary file notice */} {activeTab.isBinary && (

Binary File

This file cannot be displayed as text.

)} {/* Too large file notice */} {activeTab.isTooLarge && (

File Too Large

This file is {(activeTab.fileSize / (1024 * 1024)).toFixed(1)}MB, which exceeds the {(maxFileSize / (1024 * 1024)).toFixed(0)}MB limit.

)} {/* Normal editable file */} {!activeTab.isBinary && !activeTab.isTooLarge && ( <> {isMarkdown && showEditor && showPreview ? ( // Markdown split view (stacks vertically on mobile) ) : isMarkdown && !showEditor ? ( // Markdown preview only ) : ( // Regular editor (or markdown editor-only mode) )} )}
) : ( // No file open

{isMobile ? 'Tap a file from the explorer to start editing' : 'Select a file from the explorer to start editing'}

{!isMobile && (

Ctrl+S to save · Ctrl+F to search

)}
)} {/* Git detail panel (shown below editor for active file) */} {activeTab && !activeTab.isBinary && !activeTab.isTooLarge && activeFileGitDetails && ( )} {/* Status bar */} {activeTab && !activeTab.isBinary && !activeTab.isTooLarge && (
{getLanguageName(activeTab.filePath)} Ln {activeTab.cursorLine}, Col {activeTab.cursorCol} {activeTab.isDirty && ( Modified )}
{gitBranch && ( {gitBranch} )} Spaces: {tabSize} {!isMobile && {wordWrap ? 'Wrap' : 'No Wrap'}} UTF-8
)}
); // ─── File Tree Panel (shared between mobile and desktop) ── const renderFileTree = () => ( { loadTree(); loadGitStatus(); }} onToggleFolder={handleToggleFolder} activeFilePath={activeTab?.filePath || null} onCopyItem={handleCopyItem} onMoveItem={handleMoveItem} onDownloadItem={handleDownloadItem} onDragDropMove={handleDragDropMove} effectivePath={effectivePath || ''} /> ); return (
{/* Header */}
{/* Mobile: show browser toggle button when viewing editor */} {isMobile && !mobileBrowserVisible && ( )}

File Editor

{currentProject.name}

{/* Worktree directory selector */} {currentProject?.path && } {/* Desktop: Markdown view mode toggle */} {isMarkdown && !(isMobile && mobileBrowserVisible) && (
)} {/* Desktop: Search button */} {activeTab && !activeTab.isBinary && !activeTab.isTooLarge && !(isMobile && mobileBrowserVisible) && ( )} {/* Desktop: Undo / Redo buttons */} {activeTab && !activeTab.isBinary && !activeTab.isTooLarge && !(isMobile && mobileBrowserVisible) && (
)} {/* Desktop: Save button */} {activeTab && !activeTab.isBinary && !activeTab.isTooLarge && !(isMobile && mobileBrowserVisible) && ( )} {/* Editor Settings popover */}

Editor Settings

{/* Tablet/Mobile: actions panel trigger */} setShowActionsPanel(!showActionsPanel)} />
{/* Actions Panel (tablet/mobile) */} setShowActionsPanel(false)} title="Editor Actions" > {/* Markdown view mode toggle */} {isMarkdown && (
View Mode
)} {/* Search button */} {activeTab && !activeTab.isBinary && !activeTab.isTooLarge && ( )} {/* Undo / Redo buttons */} {activeTab && !activeTab.isBinary && !activeTab.isTooLarge && (
)} {/* Save button */} {activeTab && !activeTab.isBinary && !activeTab.isTooLarge && ( )} {/* File info */} {activeTab && !activeTab.isBinary && !activeTab.isTooLarge && (
File Info
{getLanguageName(activeTab.filePath)}
Ln {activeTab.cursorLine}, Col {activeTab.cursorCol}
)} {/* Editor Settings */}
Editor Settings
{/* Main content area */} {isMobile ? ( // ─── Mobile Layout: full-screen browser or editor ───────── // When the virtual keyboard is open, reduce container height so the // editor content scrolls up and the cursor stays visible above the keyboard.
{mobileBrowserVisible ? ( // Full-screen file browser on mobile
{renderFileTree()}
) : ( // Full-screen editor on mobile renderEditorPanel() )}
) : ( // ─── Desktop Layout: resizable split panels ────────────── {/* File Browser Panel */} {renderFileTree()} {/* Resize handle */} {/* Editor Panel */} {renderEditorPanel()} )}
); }