import React from 'react'; import { RiArrowLeftSLine, RiArrowRightSLine, RiArrowDownSLine, RiClipboardLine, RiCloseLine, RiCodeLine, RiFileImageLine, RiFileTextLine, RiFileCopy2Line, RiCheckLine, RiFolder3Fill, RiFolderOpenFill, RiFullscreenExitLine, RiFullscreenLine, RiLoader4Line, RiRefreshLine, RiSearchLine, RiSave3Line, RiTextWrap, RiMore2Fill, RiFileAddLine, RiFolderAddLine, RiDeleteBinLine, RiEditLine, RiFileCopyLine, } from '@remixicon/react'; import { toast } from '@/components/ui'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { CodeMirrorEditor, type BlockWidgetDef } from '@/components/ui/CodeMirrorEditor'; import { PreviewToggleButton } from './PreviewToggleButton'; import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer'; import { languageByExtension } from '@/lib/codemirror/languageByExtension'; import { createFlexokiCodeMirrorTheme } from '@/lib/codemirror/flexokiTheme'; import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { useDebouncedValue } from '@/hooks/useDebouncedValue'; import { useFileSearchStore } from '@/stores/useFileSearchStore'; import { useDeviceInfo } from '@/lib/device'; import { useLongPress } from '@/hooks/useLongPress'; import { cn, getModifierLabel, hasModifier } from '@/lib/utils'; import { getLanguageFromExtension, getImageMimeType, isImageFile } from '@/lib/toolHelpers'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { EditorView } from '@codemirror/view'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useSessionStore } from '@/stores/useSessionStore'; import { useUIStore } from '@/stores/useUIStore'; import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore'; import { useGitStatus } from '@/stores/useGitStore'; import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore'; import { InlineCommentCard, InlineCommentInput } from '@/components/comments'; import { opencodeClient } from '@/lib/opencode/client'; import { useDirectoryShowHidden } from '@/lib/directoryShowHidden'; import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored'; import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; type FileNode = { name: string; path: string; type: 'file' | 'directory'; extension?: string; relativePath?: string; }; type SelectedLineRange = { start: number; end: number; }; const sortNodes = (items: FileNode[]) => items.slice().sort((a, b) => { if (a.type !== b.type) { return a.type === 'directory' ? -1 : 1; } return a.name.localeCompare(b.name); }); const normalizePath = (value: string): string => { if (!value) return ''; const raw = value.replace(/\\/g, '/'); const hadUncPrefix = raw.startsWith('//'); let normalized = raw.replace(/\/+/g, '/'); if (hadUncPrefix && !normalized.startsWith('//')) { normalized = `/${normalized}`; } const isUnixRoot = normalized === '/'; const isWindowsDriveRoot = /^[A-Za-z]:\/$/.test(normalized); if (!isUnixRoot && !isWindowsDriveRoot) { normalized = normalized.replace(/\/+$/, ''); } return normalized; }; const isAbsolutePath = (value: string): boolean => { return value.startsWith('/') || value.startsWith('//') || /^[A-Za-z]:\//.test(value); }; const toComparablePath = (value: string): string => { if (/^[A-Za-z]:\//.test(value)) { return value.toLowerCase(); } return value; }; const isPathWithinRoot = (path: string, root: string): boolean => { const normalizedRoot = normalizePath(root); const normalizedPath = normalizePath(path); if (!normalizedRoot || !normalizedPath) return false; const comparableRoot = toComparablePath(normalizedRoot); const comparablePath = toComparablePath(normalizedPath); return comparablePath === comparableRoot || comparablePath.startsWith(`${comparableRoot}/`); }; const getAncestorPaths = (filePath: string, root: string): string[] => { const normalizedRoot = normalizePath(root); const normalizedFile = normalizePath(filePath); // Ensure file is within root if (!isPathWithinRoot(normalizedFile, normalizedRoot)) return []; const relative = normalizedFile.slice(normalizedRoot.length).replace(/^\//, ''); const parts = relative.split('/'); const ancestors: string[] = []; let current = normalizedRoot; for (let i = 0; i < parts.length - 1; i++) { current = current ? `${current}/${parts[i]}` : parts[i]; ancestors.push(current); } return ancestors; }; type BreadcrumbSegment = { label: string; path: string }; const parseBreadcrumbs = (relativePath: string, root: string): BreadcrumbSegment[] => { const parts = relativePath.split('/'); const segments: BreadcrumbSegment[] = []; let currentPath = root; for (const part of parts) { if (!part) continue; currentPath = currentPath ? `${currentPath}/${part}` : part; segments.push({ label: part, path: currentPath }); } return segments; }; const FileBreadcrumbs: React.FC<{ path: string; root: string; onNavigate: (dirPath: string) => void; }> = ({ path, root, onNavigate }) => { const segments = React.useMemo(() => parseBreadcrumbs(path, root), [path, root]); return (
{segments.map((seg, i) => ( {i > 0 && } ))}
); }; const DEFAULT_IGNORED_DIR_NAMES = new Set(['node_modules']); type FileStatus = 'open' | 'modified' | 'git-modified' | 'git-added' | 'git-deleted'; const FileStatusDot: React.FC<{ status: FileStatus }> = ({ status }) => { const color = { open: 'var(--status-info)', modified: 'var(--status-warning)', 'git-modified': 'var(--status-warning)', 'git-added': 'var(--status-success)', 'git-deleted': 'var(--status-error)', }[status]; return ; }; const shouldIgnoreEntryName = (name: string): boolean => DEFAULT_IGNORED_DIR_NAMES.has(name); const shouldIgnorePath = (path: string): boolean => { const normalized = normalizePath(path); return normalized === 'node_modules' || normalized.endsWith('/node_modules') || normalized.includes('/node_modules/'); }; const isDirectoryReadError = (error: unknown): boolean => { const message = error instanceof Error ? error.message : String(error ?? ''); const normalized = message.toLowerCase(); return normalized.includes('is a directory') || normalized.includes('eisdir'); }; const MAX_VIEW_CHARS = 200_000; const CODE_EXTENSIONS = new Set([ // JavaScript/TypeScript 'js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs', 'mts', 'cts', // Web 'html', 'htm', 'xhtml', 'css', 'scss', 'sass', 'less', 'styl', 'stylus', 'vue', 'svelte', 'astro', // Shell/Scripts 'sh', 'bash', 'zsh', 'fish', 'ps1', 'psm1', 'bat', 'cmd', // Python 'py', 'pyw', 'pyx', 'pxd', 'pxi', // Ruby 'rb', 'erb', 'rake', 'gemspec', // PHP 'php', 'phtml', 'php3', 'php4', 'php5', 'phps', // Java/JVM 'java', 'kt', 'kts', 'scala', 'sc', 'groovy', 'gradle', // C/C++/Objective-C 'c', 'h', 'cpp', 'cc', 'cxx', 'hpp', 'hxx', 'hh', 'm', 'mm', // C#/F#/.NET 'cs', 'fs', 'fsx', 'fsi', // Go 'go', // Rust 'rs', // Swift 'swift', // Dart 'dart', // Lua 'lua', // Perl 'pl', 'pm', 'pod', // R 'r', 'R', 'rmd', // Julia 'jl', // Haskell 'hs', 'lhs', // Elixir/Erlang 'ex', 'exs', 'erl', 'hrl', // Clojure 'clj', 'cljs', 'cljc', 'edn', // Lisp/Scheme 'lisp', 'cl', 'el', 'scm', 'ss', 'rkt', // OCaml/ReasonML 'ml', 'mli', 're', 'rei', // Nim 'nim', // Zig 'zig', // V 'v', // Crystal 'cr', // Kotlin Script 'main.kts', // SQL 'sql', 'psql', 'plsql', // GraphQL 'graphql', 'gql', // Solidity 'sol', // Assembly 'asm', 's', 'S', // Makefile variants 'mk', // Nix 'nix', // Terraform 'tf', 'tfvars', // Puppet 'pp', // Ansible 'ansible', ]); const DATA_EXTENSIONS = new Set([ // JSON variants 'json', 'jsonc', 'json5', 'jsonl', 'ndjson', 'geojson', // YAML 'yaml', 'yml', // TOML 'toml', // XML variants 'xml', 'xsl', 'xslt', 'xsd', 'dtd', 'plist', // Config files 'ini', 'cfg', 'conf', 'config', 'env', 'properties', // CSV/TSV 'csv', 'tsv', // Lock files 'lock', ]); const IMAGE_EXTENSIONS = new Set([ 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'icns', 'bmp', 'tiff', 'tif', 'psd', 'ai', 'eps', 'raw', 'cr2', 'nef', 'heic', 'heif', 'avif', 'jxl', ]); const DOCUMENT_EXTENSIONS = new Set([ // Markdown 'md', 'mdx', 'markdown', 'mdown', 'mkd', // Text 'txt', 'text', 'rtf', // Docs 'doc', 'docx', 'odt', 'pdf', // ReStructuredText 'rst', // AsciiDoc 'adoc', 'asciidoc', // Org 'org', // LaTeX 'tex', 'latex', 'bib', ]); const getFileIcon = (extension?: string): React.ReactNode => { const ext = extension?.toLowerCase(); if (ext && CODE_EXTENSIONS.has(ext)) { return ; } if (ext && DATA_EXTENSIONS.has(ext)) { return ; } if (ext && IMAGE_EXTENSIONS.has(ext)) { return ; } if (ext && DOCUMENT_EXTENSIONS.has(ext)) { return ; } return ; }; const isMarkdownFile = (path: string): boolean => { if (!path) return false; const ext = path.toLowerCase().split('.').pop(); return ext === 'md' || ext === 'markdown'; }; interface FileRowProps { node: FileNode; isExpanded: boolean; isActive: boolean; isLoading: boolean; isMobile: boolean; status?: FileStatus | null; badge?: { modified: number; added: number } | null; permissions: { canRename: boolean; canCreateFile: boolean; canCreateFolder: boolean; canDelete: boolean; }; contextMenuPath: string | null; setContextMenuPath: (path: string | null) => void; onSelect: (node: FileNode) => void; onToggle: (path: string) => void; onOpenDialog: (type: 'createFile' | 'createFolder' | 'rename' | 'delete', data: { path: string; name?: string; type?: 'file' | 'directory' }) => void; } const FileRow: React.FC = ({ node, isExpanded, isActive, isLoading, isMobile, status, badge, permissions, contextMenuPath, setContextMenuPath, onSelect, onToggle, onOpenDialog, }) => { const isDir = node.type === 'directory'; const { canRename, canCreateFile, canCreateFolder, canDelete } = permissions; const handleContextMenu = React.useCallback((event?: React.MouseEvent) => { if (!canRename && !canCreateFile && !canCreateFolder && !canDelete) { return; } event?.preventDefault(); setContextMenuPath(node.path); }, [canRename, canCreateFile, canCreateFolder, canDelete, node.path, setContextMenuPath]); const handleInteraction = React.useCallback(() => { if (isDir) { onToggle(node.path); } else { onSelect(node); } }, [isDir, node, onSelect, onToggle]); const longPressHandlers = useLongPress({ onLongPress: handleContextMenu, onTap: handleInteraction, enableHaptic: true, }); const interactionProps = isMobile ? longPressHandlers : { onClick: handleInteraction, onContextMenu: handleContextMenu, }; return (
{(canRename || canCreateFile || canCreateFolder || canDelete) && (
setContextMenuPath(open ? node.path : null)} > {!isMobile && ( )} {isMobile && ( )} setContextMenuPath(null)}> {canRename && ( { e.stopPropagation(); onOpenDialog('rename', node); }}> Rename )} { e.stopPropagation(); void navigator.clipboard.writeText(node.path); toast.success('Path copied'); }}> Copy Path {isDir && (canCreateFile || canCreateFolder) && ( <> {canCreateFile && ( { e.stopPropagation(); onOpenDialog('createFile', node); }}> New File )} {canCreateFolder && ( { e.stopPropagation(); onOpenDialog('createFolder', node); }}> New Folder )} )} {canDelete && ( <> { e.stopPropagation(); onOpenDialog('delete', node); }} className="text-destructive focus:text-destructive" > Delete )}
)}
); }; export const FilesView: React.FC = () => { const { files, runtime } = useRuntimeAPIs(); const { currentTheme } = useThemeSystem(); React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]); const { isMobile, screenWidth } = useDeviceInfo(); const showHidden = useDirectoryShowHidden(); const showGitignored = useFilesViewShowGitignored(); const currentDirectory = useEffectiveDirectory() ?? ''; const root = normalizePath(currentDirectory.trim()); const searchFiles = useFileSearchStore((state) => state.searchFiles); const gitStatus = useGitStatus(currentDirectory); const [searchQuery, setSearchQuery] = React.useState(''); const debouncedSearchQuery = useDebouncedValue(searchQuery, 200); const searchInputRef = React.useRef(null); const [showMobilePageContent, setShowMobilePageContent] = React.useState(false); const [wrapLines, setWrapLines] = React.useState(isMobile); const [isFullscreen, setIsFullscreen] = React.useState(false); const [isSearchOpen, setIsSearchOpen] = React.useState(false); const EMPTY_PATHS: string[] = React.useMemo(() => [], []); const openPaths = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.openPaths ?? EMPTY_PATHS) : EMPTY_PATHS)); const selectedPath = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.selectedPath ?? null) : null)); const expandedPaths = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.expandedPaths ?? EMPTY_PATHS) : EMPTY_PATHS)); const addOpenPath = useFilesViewTabsStore((state) => state.addOpenPath); const removeOpenPath = useFilesViewTabsStore((state) => state.removeOpenPath); const removeOpenPathsByPrefix = useFilesViewTabsStore((state) => state.removeOpenPathsByPrefix); const setSelectedPath = useFilesViewTabsStore((state) => state.setSelectedPath); const toggleExpandedPath = useFilesViewTabsStore((state) => state.toggleExpandedPath); const expandPaths = useFilesViewTabsStore((state) => state.expandPaths); const toFileNode = React.useCallback((path: string): FileNode => { const normalized = normalizePath(path); const parts = normalized.split('/'); const name = parts[parts.length - 1] || normalized; const extension = name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined; return { name, path: normalized, type: 'file', extension, }; }, []); const openFiles = React.useMemo(() => openPaths.map(toFileNode), [openPaths, toFileNode]); const effectiveSelectedPath = React.useMemo(() => selectedPath ?? openPaths[0] ?? null, [openPaths, selectedPath]); const selectedFile = React.useMemo(() => (effectiveSelectedPath ? toFileNode(effectiveSelectedPath) : null), [effectiveSelectedPath, toFileNode]); const [childrenByDir, setChildrenByDir] = React.useState>({}); const loadedDirsRef = React.useRef>(new Set()); const inFlightDirsRef = React.useRef>(new Set()); const [searchResults, setSearchResults] = React.useState([]); const [searching, setSearching] = React.useState(false); const [fileContent, setFileContent] = React.useState(''); const [fileLoading, setFileLoading] = React.useState(false); const [fileError, setFileError] = React.useState(null); const [desktopImageSrc, setDesktopImageSrc] = React.useState(''); const [loadedFilePath, setLoadedFilePath] = React.useState(null); const [draftContent, setDraftContent] = React.useState(''); const [isSaving, setIsSaving] = React.useState(false); const [confirmDiscardOpen, setConfirmDiscardOpen] = React.useState(false); const pendingSelectFileRef = React.useRef(null); const pendingTabRef = React.useRef(null); const pendingClosePathRef = React.useRef(null); const skipDirtyOnceRef = React.useRef(false); const copiedContentTimeoutRef = React.useRef(null); const copiedPathTimeoutRef = React.useRef(null); const [activeDialog, setActiveDialog] = React.useState<'createFile' | 'createFolder' | 'rename' | 'delete' | null>(null); const [dialogData, setDialogData] = React.useState<{ path: string; name?: string; type?: 'file' | 'directory' } | null>(null); const [dialogInputValue, setDialogInputValue] = React.useState(''); const [isDialogSubmitting, setIsDialogSubmitting] = React.useState(false); const [contextMenuPath, setContextMenuPath] = React.useState(null); const [copiedContent, setCopiedContent] = React.useState(false); const [copiedPath, setCopiedPath] = React.useState(false); // Markdown view mode (global, not per-file) const [mdViewMode, setMdViewMode] = React.useState<'preview' | 'edit'>('edit'); const canCreateFile = Boolean(files.writeFile); const canCreateFolder = Boolean(files.createDirectory); const canRename = Boolean(files.rename); const canDelete = Boolean(files.delete); const handleOpenDialog = React.useCallback((type: 'createFile' | 'createFolder' | 'rename' | 'delete', data: { path: string; name?: string; type?: 'file' | 'directory' }) => { setActiveDialog(type); setDialogData(data); setDialogInputValue(type === 'rename' ? data.name || '' : ''); setIsDialogSubmitting(false); }, []); // Line selection state for commenting const [lineSelection, setLineSelection] = React.useState(null); const isSelectingRef = React.useRef(false); const selectionStartRef = React.useRef(null); const [isDragging, setIsDragging] = React.useState(false); // Session/config for sending comments const currentSessionId = useSessionStore((state) => state.currentSessionId); const setMainTabGuard = useUIStore((state) => state.setMainTabGuard); const addDraft = useInlineCommentDraftStore((s) => s.addDraft); const updateDraft = useInlineCommentDraftStore((s) => s.updateDraft); const removeDraft = useInlineCommentDraftStore((s) => s.removeDraft); const allDrafts = useInlineCommentDraftStore((s) => s.drafts); const [editingDraftId, setEditingDraftId] = React.useState(null); // Global mouseup to end drag selection React.useEffect(() => { const handleGlobalMouseUp = () => { isSelectingRef.current = false; selectionStartRef.current = null; setIsDragging(false); }; document.addEventListener('mouseup', handleGlobalMouseUp); return () => document.removeEventListener('mouseup', handleGlobalMouseUp); }, []); // Clear selection when file changes React.useEffect(() => { setLineSelection(null); setMainTabGuard(null); setDraftContent(''); setIsSaving(false); }, [selectedFile?.path, setMainTabGuard]); React.useEffect(() => { return () => { if (copiedContentTimeoutRef.current !== null) { window.clearTimeout(copiedContentTimeoutRef.current); } if (copiedPathTimeoutRef.current !== null) { window.clearTimeout(copiedPathTimeoutRef.current); } }; }, []); // Click outside to dismiss selection React.useEffect(() => { if (!lineSelection && !editingDraftId) return; const handleClickOutside = (e: MouseEvent) => { const target = e.target as HTMLElement; // Check if click is inside comment UI if (target.closest('[data-comment-input="true"]') || target.closest('[data-comment-card="true"]')) return; // Check if click is on CM gutter (only gutter should not dismiss) if (target.closest('.cm-gutterElement')) return; // Check if click is inside toast (sonner) if (target.closest('[data-sonner-toast]') || target.closest('[data-sonner-toaster]')) return; // Clicking anywhere else (including code content) dismisses selection setLineSelection(null); setEditingDraftId(null); }; const timeoutId = setTimeout(() => { document.addEventListener('click', handleClickOutside); }, 100); return () => { clearTimeout(timeoutId); document.removeEventListener('click', handleClickOutside); }; }, [lineSelection, editingDraftId]); // Extract selected code const extractSelectedCode = React.useCallback((content: string, range: SelectedLineRange): string => { const lines = content.split('\n'); const startLine = Math.max(1, range.start); const endLine = Math.min(lines.length, range.end); if (startLine > endLine) return ''; return lines.slice(startLine - 1, endLine).join('\n'); }, []); const handleSaveComment = React.useCallback((text: string, range?: { start: number; end: number }) => { if (!selectedFile) return; const sessionKey = currentSessionId ?? 'draft'; const finalRange = range || lineSelection; if (!finalRange) return; const code = extractSelectedCode(fileContent, { start: finalRange.start, end: finalRange.end }); if (editingDraftId) { updateDraft(sessionKey, editingDraftId, { text: text.trim(), code, startLine: finalRange.start, endLine: finalRange.end, }); } else { addDraft({ sessionKey, source: 'file', fileLabel: selectedFile.path, startLine: finalRange.start, endLine: finalRange.end, code, language: getLanguageFromExtension(selectedFile.path) || 'text', text: text.trim(), }); } setLineSelection(null); setEditingDraftId(null); }, [selectedFile, currentSessionId, lineSelection, fileContent, extractSelectedCode, editingDraftId, updateDraft, addDraft]); const mapDirectoryEntries = React.useCallback((dirPath: string, entries: Array<{ name: string; path: string; isDirectory: boolean }>): FileNode[] => { const nodes = entries .filter((entry) => entry && typeof entry.name === 'string' && entry.name.length > 0) .filter((entry) => showHidden || !entry.name.startsWith('.')) .filter((entry) => showGitignored || !shouldIgnoreEntryName(entry.name)) .map((entry) => { const name = entry.name; const normalizedEntryPath = normalizePath(entry.path || ''); const path = normalizedEntryPath ? (isAbsolutePath(normalizedEntryPath) ? normalizedEntryPath : normalizePath(`${dirPath}/${normalizedEntryPath}`)) : normalizePath(`${dirPath}/${name}`); const type = entry.isDirectory ? 'directory' : 'file'; const extension = type === 'file' && name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined; return { name, path, type, extension, }; }); return sortNodes(nodes); }, [showGitignored, showHidden]); const loadDirectory = React.useCallback(async (dirPath: string) => { const normalizedDir = normalizePath(dirPath.trim()); if (!normalizedDir) { return; } if (loadedDirsRef.current.has(normalizedDir) || inFlightDirsRef.current.has(normalizedDir)) { return; } inFlightDirsRef.current = new Set(inFlightDirsRef.current); inFlightDirsRef.current.add(normalizedDir); try { const respectGitignore = !showGitignored; let entries: Array<{ name: string; path: string; isDirectory: boolean }>; if (runtime.isDesktop) { const result = await files.listDirectory(normalizedDir, { respectGitignore }); entries = result.entries.map((entry) => ({ name: entry.name, path: entry.path, isDirectory: entry.isDirectory, })); } else { const result = await opencodeClient.listLocalDirectory(normalizedDir, { respectGitignore }); entries = result.map((entry) => ({ name: entry.name, path: entry.path, isDirectory: entry.isDirectory, })); } const mapped = mapDirectoryEntries(normalizedDir, entries); loadedDirsRef.current = new Set(loadedDirsRef.current); loadedDirsRef.current.add(normalizedDir); setChildrenByDir((prev) => ({ ...prev, [normalizedDir]: mapped })); } catch { setChildrenByDir((prev) => ({ ...prev, [normalizedDir]: prev[normalizedDir] ?? [], })); } finally { inFlightDirsRef.current = new Set(inFlightDirsRef.current); inFlightDirsRef.current.delete(normalizedDir); } }, [files, mapDirectoryEntries, runtime.isDesktop, showGitignored]); const refreshRoot = React.useCallback(async () => { if (!root) { return; } loadedDirsRef.current = new Set(); inFlightDirsRef.current = new Set(); setChildrenByDir((prev) => (Object.keys(prev).length === 0 ? prev : {})); await loadDirectory(root); }, [loadDirectory, root]); const lastFilesViewDirRef = React.useRef(''); const lastFilesViewTreeKeyRef = React.useRef(''); React.useEffect(() => { if (!root) { return; } const treeKey = `${root}|h${showHidden ? '1' : '0'}|g${showGitignored ? '1' : '0'}`; const dirChanged = lastFilesViewDirRef.current !== root; const treeKeyChanged = lastFilesViewTreeKeyRef.current !== treeKey; if (!dirChanged && !treeKeyChanged) { return; } if (dirChanged) { lastFilesViewDirRef.current = root; setFileContent(''); setFileError(null); setDesktopImageSrc(''); setLoadedFilePath(null); setShowMobilePageContent(false); } if (treeKeyChanged) { lastFilesViewTreeKeyRef.current = treeKey; loadedDirsRef.current = new Set(); inFlightDirsRef.current = new Set(); setChildrenByDir((prev) => (Object.keys(prev).length === 0 ? prev : {})); void loadDirectory(root); } }, [loadDirectory, root, showGitignored, showHidden]); const MD_VIEWER_MODE_KEY = 'openchamber:files:md-viewer-mode'; // Load markdown view mode preference from localStorage on mount React.useEffect(() => { try { const stored = localStorage.getItem(MD_VIEWER_MODE_KEY); if (stored) { const parsed = JSON.parse(stored); if (parsed === 'preview' || parsed === 'edit') { setMdViewMode(parsed); } } } catch { // Ignore localStorage errors } }, []); // Save markdown view mode preference to localStorage const saveMdViewMode = React.useCallback((mode: 'preview' | 'edit') => { setMdViewMode(mode); try { localStorage.setItem(MD_VIEWER_MODE_KEY, JSON.stringify(mode)); } catch { // Ignore localStorage errors } }, []); // Get the view mode for a markdown file (from state, default to 'edit') const getMdViewMode = React.useCallback((): 'preview' | 'edit' => { return mdViewMode; }, [mdViewMode]); const handleDialogSubmit = React.useCallback(async (e?: React.FormEvent) => { e?.preventDefault(); if (!dialogData || !activeDialog) return; setIsDialogSubmitting(true); try { if (activeDialog === 'createFile') { if (!dialogInputValue.trim()) throw new Error('Filename is required'); const parentPath = dialogData.path; // Handle root path or empty path const prefix = parentPath ? `${parentPath}/` : ''; const newPath = normalizePath(`${prefix}${dialogInputValue.trim()}`); if (!files.writeFile) throw new Error('Write not supported'); const result = await files.writeFile(newPath, ''); if (result.success) { toast.success('File created'); await refreshRoot(); } } else if (activeDialog === 'createFolder') { if (!dialogInputValue.trim()) throw new Error('Folder name is required'); const parentPath = dialogData.path; const prefix = parentPath ? `${parentPath}/` : ''; const newPath = normalizePath(`${prefix}${dialogInputValue.trim()}`); const result = await files.createDirectory(newPath); if (result.success) { toast.success('Folder created'); await refreshRoot(); } } else if (activeDialog === 'rename') { if (!dialogInputValue.trim()) throw new Error('Name is required'); const oldPath = dialogData.path; const parentDir = oldPath.split('/').slice(0, -1).join('/'); const prefix = parentDir ? `${parentDir}/` : ''; const newPath = normalizePath(`${prefix}${dialogInputValue.trim()}`); if (files.rename) { const result = await files.rename(oldPath, newPath); if (result.success) { toast.success('Renamed successfully'); await refreshRoot(); if (root) { removeOpenPathsByPrefix(root, oldPath); } if (selectedFile?.path === oldPath || selectedFile?.path.startsWith(`${oldPath}/`)) { if (root) { setSelectedPath(root, null); } setFileContent(''); setFileError(null); setDesktopImageSrc(''); setLoadedFilePath(null); if (isMobile) { setShowMobilePageContent(false); } } } } else { toast.error("Rename not supported"); } } else if (activeDialog === 'delete') { if (files.delete) { const result = await files.delete(dialogData.path); if (result.success) { toast.success('Deleted successfully'); await refreshRoot(); if (root) { removeOpenPathsByPrefix(root, dialogData.path); } if (selectedFile?.path === dialogData.path || selectedFile?.path.startsWith(dialogData.path + '/')) { if (root) { setSelectedPath(root, null); } setFileContent(''); setFileError(null); setDesktopImageSrc(''); setLoadedFilePath(null); if (isMobile) { setShowMobilePageContent(false); } } } } else { toast.error("Delete not supported"); } } setActiveDialog(null); } catch (error) { toast.error(error instanceof Error ? error.message : 'Operation failed'); } finally { setIsDialogSubmitting(false); } }, [activeDialog, dialogData, dialogInputValue, files, refreshRoot, isMobile, removeOpenPathsByPrefix, root, selectedFile?.path, setSelectedPath]); const fuzzyScore = React.useCallback((query: string, candidate: string): number | null => { const q = query.trim().toLowerCase(); if (!q) { return 0; } const c = candidate.toLowerCase(); let score = 0; let lastIndex = -1; let consecutive = 0; for (let i = 0; i < q.length; i += 1) { const ch = q[i]; if (!ch || ch === ' ') { continue; } const idx = c.indexOf(ch, lastIndex + 1); if (idx === -1) { return null; } const gap = idx - lastIndex - 1; if (gap === 0) { consecutive += 1; } else { consecutive = 0; } score += 10; score += Math.max(0, 18 - idx); score -= Math.max(0, gap); if (idx === 0) { score += 12; } else { const prev = c[idx - 1]; if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') { score += 10; } } score += consecutive > 0 ? 12 : 0; lastIndex = idx; } score += Math.max(0, 24 - Math.round(c.length / 3)); return score; }, []); React.useEffect(() => { if (!currentDirectory) { setSearchResults([]); setSearching(false); return; } const trimmedQuery = debouncedSearchQuery.trim(); if (!trimmedQuery) { setSearchResults([]); setSearching(false); return; } const normalizedQueryLower = trimmedQuery.toLowerCase(); let cancelled = false; setSearching(true); searchFiles(currentDirectory, trimmedQuery, 150, { includeHidden: showHidden, respectGitignore: !showGitignored, }) .then((hits) => { if (cancelled) { return; } const filtered = hits.filter((hit) => showGitignored || !shouldIgnorePath(hit.path)); // Apply fuzzy scoring and sort by score const ranked = filtered .map((hit) => { const label = hit.relativePath || hit.name || hit.path; const score = fuzzyScore(normalizedQueryLower, label); return score === null ? null : { hit, score, labelLength: label.length }; }) .filter(Boolean) as Array<{ hit: typeof hits[0]; score: number; labelLength: number }>; ranked.sort((a, b) => ( b.score - a.score || a.labelLength - b.labelLength || a.hit.path.localeCompare(b.hit.path) )); const mapped: FileNode[] = ranked.map(({ hit }) => ({ name: hit.name, path: normalizePath(hit.path), type: 'file', extension: hit.extension, relativePath: hit.relativePath, })); setSearchResults(mapped); }) .catch(() => { if (!cancelled) { setSearchResults([]); } }) .finally(() => { if (!cancelled) { setSearching(false); } }); return () => { cancelled = true; }; }, [currentDirectory, debouncedSearchQuery, fuzzyScore, searchFiles, showHidden, showGitignored]); const readFile = React.useCallback(async (path: string): Promise => { if (files.readFile) { const result = await files.readFile(path); return result.content ?? ''; } const response = await fetch(`/api/fs/read?path=${encodeURIComponent(path)}`); if (!response.ok) { const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error((error as { error?: string }).error || 'Failed to read file'); } return response.text(); }, [files]); const displayedContent = React.useMemo(() => { return fileContent.length > MAX_VIEW_CHARS ? `${fileContent.slice(0, MAX_VIEW_CHARS)}\n\n… truncated …` : fileContent; }, [fileContent]); const isDirty = React.useMemo(() => draftContent !== displayedContent, [draftContent, displayedContent]); const saveDraft = React.useCallback(async () => { if (!selectedFile || !files.writeFile) { toast.error('Saving not supported'); return; } if (!isDirty) { return; } setIsSaving(true); try { const result = await files.writeFile(selectedFile.path, draftContent); if (!result?.success) { throw new Error('Failed to write file'); } setFileContent(draftContent); } catch (error) { toast.error(error instanceof Error ? error.message : 'Save failed'); } finally { setIsSaving(false); } }, [draftContent, files, isDirty, selectedFile]); React.useEffect(() => { if (!isDirty) { setMainTabGuard(null); return; } const guard = (_nextTab: import('@/stores/useUIStore').MainTab) => { if (skipDirtyOnceRef.current) { skipDirtyOnceRef.current = false; return true; } setConfirmDiscardOpen(true); pendingTabRef.current = _nextTab; return false; }; setMainTabGuard(guard); return () => { const currentGuard = useUIStore.getState().mainTabGuard; if (currentGuard === guard) { setMainTabGuard(null); } }; }, [isDirty, setMainTabGuard]); React.useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (!hasModifier(e)) { return; } if (e.key.toLowerCase() === 's') { e.preventDefault(); if (!isSaving) { void saveDraft(); } } else if (e.key.toLowerCase() === 'f') { e.preventDefault(); setIsSearchOpen(true); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [isSaving, saveDraft]); const loadSelectedFile = React.useCallback(async (node: FileNode) => { setFileError(null); setDesktopImageSrc(''); setLoadedFilePath(node.path); const selectedIsImage = isImageFile(node.path); const isSvg = node.path.toLowerCase().endsWith('.svg'); if (isMobile) { setShowMobilePageContent(true); } // Desktop: binary images are loaded via readFileBinary (data URL). if (runtime.isDesktop && selectedIsImage && !isSvg) { setFileContent(''); setDraftContent(''); setFileLoading(true); return; } // Web: binary images should not be read as utf8. if (!runtime.isDesktop && selectedIsImage && !isSvg) { setFileContent(''); setDraftContent(''); setFileLoading(false); return; } setFileLoading(true); try { const content = await readFile(node.path); setFileContent(content); setDraftContent(content.length > MAX_VIEW_CHARS ? `${content.slice(0, MAX_VIEW_CHARS)}\n\n… truncated …` : content); } catch (error) { if (isDirectoryReadError(error)) { if (root) { setSelectedPath(root, null); } setFileError(null); setFileContent(''); setDraftContent(''); setLoadedFilePath(null); if (searchQuery.trim().length > 0) { setSearchQuery(''); } if (isMobile) { setShowMobilePageContent(false); } if (root) { const ancestors = getAncestorPaths(node.path, root); const pathsToExpand = [...ancestors, node.path]; if (pathsToExpand.length > 0) { expandPaths(root, pathsToExpand); } for (const path of pathsToExpand) { if (!loadedDirsRef.current.has(path)) { void loadDirectory(path); } } } return; } setFileContent(''); setDraftContent(''); setFileError(error instanceof Error ? error.message : 'Failed to read file'); } finally { setFileLoading(false); } }, [expandPaths, isMobile, loadDirectory, readFile, root, runtime.isDesktop, searchQuery, setSelectedPath]); const ensurePathVisible = React.useCallback(async (targetPath: string, includeTarget: boolean) => { if (!root) { return; } const ancestors = getAncestorPaths(targetPath, root); const pathsToExpand = includeTarget ? [...ancestors, targetPath] : ancestors; if (pathsToExpand.length > 0) { expandPaths(root, pathsToExpand); } for (const path of pathsToExpand) { if (!loadedDirsRef.current.has(path)) { await loadDirectory(path); } } }, [expandPaths, loadDirectory, root]); const getNextOpenFile = React.useCallback((path: string, filesList: FileNode[]) => { const index = filesList.findIndex((file) => file.path === path); if (index === -1 || filesList.length <= 1) { return null; } return filesList[index + 1] ?? filesList[index - 1] ?? null; }, []); const handleSelectFile = React.useCallback(async (node: FileNode) => { if (skipDirtyOnceRef.current) { skipDirtyOnceRef.current = false; } else if (isDirty) { setConfirmDiscardOpen(true); pendingSelectFileRef.current = node; return; } if (root) { setSelectedPath(root, node.path); addOpenPath(root, node.path); void ensurePathVisible(node.path, false); } setFileError(null); setDesktopImageSrc(''); setFileContent(''); setDraftContent(''); setLoadedFilePath(null); if (isMobile) { setShowMobilePageContent(true); } }, [addOpenPath, ensurePathVisible, isDirty, isMobile, root, setSelectedPath]); React.useEffect(() => { if (!selectedFile?.path) { return; } void ensurePathVisible(selectedFile.path, false); }, [ensurePathVisible, selectedFile?.path]); React.useEffect(() => { if (!selectedFile) { return; } if (loadedFilePath === selectedFile.path) { return; } // Selection changes are guarded; this effect is also what restores persisted tabs on mount. void loadSelectedFile(selectedFile); }, [loadSelectedFile, loadedFilePath, selectedFile]); const discardAndContinue = React.useCallback(() => { const nextFile = pendingSelectFileRef.current; const nextTab = pendingTabRef.current; const closePath = pendingClosePathRef.current; pendingSelectFileRef.current = null; pendingTabRef.current = null; pendingClosePathRef.current = null; // Allow one guarded navigation (tab/file) without re-opening dialog. skipDirtyOnceRef.current = true; setConfirmDiscardOpen(false); // Discard draft by reverting back to last loaded content setDraftContent(displayedContent); if (closePath) { if (root) { removeOpenPath(root, closePath); } if (selectedFile?.path === closePath) { if (nextFile) { void handleSelectFile(nextFile); } else { if (root) { setSelectedPath(root, null); } setFileContent(''); setFileError(null); setDesktopImageSrc(''); setLoadedFilePath(null); if (isMobile) { setShowMobilePageContent(false); } } } return; } if (nextFile) { void handleSelectFile(nextFile); return; } if (nextTab) { setMainTabGuard(null); useUIStore.getState().setActiveMainTab(nextTab); } }, [displayedContent, handleSelectFile, isMobile, removeOpenPath, root, selectedFile?.path, setMainTabGuard, setSelectedPath]); const saveAndContinue = React.useCallback(async () => { const nextFile = pendingSelectFileRef.current; const nextTab = pendingTabRef.current; const closePath = pendingClosePathRef.current; pendingSelectFileRef.current = null; pendingTabRef.current = null; pendingClosePathRef.current = null; // We'll proceed after saving; suppress guard reopening. skipDirtyOnceRef.current = true; setConfirmDiscardOpen(false); await saveDraft(); if (closePath) { if (root) { removeOpenPath(root, closePath); } if (selectedFile?.path === closePath) { if (nextFile) { await handleSelectFile(nextFile); } else { if (root) { setSelectedPath(root, null); } setFileContent(''); setFileError(null); setDesktopImageSrc(''); setLoadedFilePath(null); if (isMobile) { setShowMobilePageContent(false); } } } return; } if (nextFile) { await handleSelectFile(nextFile); return; } if (nextTab) { setMainTabGuard(null); useUIStore.getState().setActiveMainTab(nextTab); } }, [handleSelectFile, isMobile, removeOpenPath, root, saveDraft, selectedFile?.path, setMainTabGuard, setSelectedPath]); const handleCloseFile = React.useCallback((path: string) => { const isActive = selectedFile?.path === path; const nextFile = getNextOpenFile(path, openFiles); if (isActive && isDirty) { setConfirmDiscardOpen(true); pendingSelectFileRef.current = nextFile; pendingClosePathRef.current = path; return; } if (root) { removeOpenPath(root, path); } if (!isActive) { return; } if (nextFile) { void handleSelectFile(nextFile); return; } if (root) { setSelectedPath(root, null); } setFileContent(''); setFileError(null); setDesktopImageSrc(''); setLoadedFilePath(null); if (isMobile) { setShowMobilePageContent(false); } }, [getNextOpenFile, handleSelectFile, isDirty, isMobile, openFiles, removeOpenPath, root, selectedFile?.path, setSelectedPath]); const getFileStatus = React.useCallback((path: string): FileStatus | null => { // Check open status if (openPaths.includes(path)) return 'open'; // Check git status if (gitStatus?.files) { const relative = path.startsWith(root + '/') ? path.slice(root.length + 1) : path; const file = gitStatus.files.find(f => f.path === relative); if (file) { if (file.index === 'A' || file.working_dir === '?') return 'git-added'; if (file.index === 'D') return 'git-deleted'; if (file.index === 'M' || file.working_dir === 'M') return 'git-modified'; } } return null; }, [openPaths, gitStatus, root]); const getFolderBadge = React.useCallback((dirPath: string): { modified: number; added: number } | null => { if (!gitStatus?.files) return null; const relativeDir = dirPath.startsWith(root + '/') ? dirPath.slice(root.length + 1) : dirPath; const prefix = relativeDir ? `${relativeDir}/` : ''; let modified = 0, added = 0; for (const f of gitStatus.files) { if (f.path.startsWith(prefix)) { if (f.index === 'M' || f.working_dir === 'M') modified++; if (f.index === 'A' || f.working_dir === '?') added++; } } return modified + added > 0 ? { modified, added } : null; }, [gitStatus, root]); const toggleDirectory = React.useCallback(async (dirPath: string) => { const normalized = normalizePath(dirPath); if (!root) return; toggleExpandedPath(root, normalized); if (!loadedDirsRef.current.has(normalized)) { await loadDirectory(normalized); } }, [loadDirectory, root, toggleExpandedPath]); const handleBreadcrumbNavigate = React.useCallback((dirPath: string) => { if (!root) return; if (searchQuery.trim().length > 0) { setSearchQuery(''); } if (isMobile) { setShowMobilePageContent(false); } void ensurePathVisible(dirPath, true); }, [ensurePathVisible, isMobile, root, searchQuery]); const renderTree = React.useCallback((dirPath: string, depth: number): React.ReactNode => { const nodes = childrenByDir[dirPath] ?? []; return nodes.map((node, index) => { const isDir = node.type === 'directory'; const isExpanded = isDir && expandedPaths.includes(node.path); const isActive = selectedFile?.path === node.path; const isLoading = isDir && inFlightDirsRef.current.has(node.path); const isLast = index === nodes.length - 1; return (
  • {depth > 0 && ( <> {isLast && ( )} )} {isDir && isExpanded && (
      {renderTree(node.path, depth + 1)}
    )}
  • ); }); }, [childrenByDir, expandedPaths, handleSelectFile, selectedFile?.path, toggleDirectory, handleOpenDialog, canCreateFile, canCreateFolder, canRename, canDelete, contextMenuPath, setContextMenuPath, isMobile, getFileStatus, getFolderBadge]); const isSelectedImage = Boolean(selectedFile?.path && isImageFile(selectedFile.path)); const isSelectedSvg = Boolean(selectedFile?.path && selectedFile.path.toLowerCase().endsWith('.svg')); const getDisplayPath = React.useCallback((path: string): string => { if (!path) return ''; const normalizedFilePath = normalizePath(path); if (root && isPathWithinRoot(normalizedFilePath, root)) { const relative = normalizedFilePath.slice(root.length); return relative.startsWith('/') ? relative.slice(1) : relative; } return normalizedFilePath; }, [root]); const displaySelectedPath = React.useMemo(() => { if (!selectedFile?.path) return ''; return getDisplayPath(selectedFile.path); }, [getDisplayPath, selectedFile?.path]); const canCopy = Boolean(selectedFile && (!isSelectedImage || isSelectedSvg) && fileContent.length > 0); const canCopyPath = Boolean(selectedFile && displaySelectedPath.length > 0); const canEdit = Boolean(selectedFile && !isSelectedImage && files.writeFile && fileContent.length <= MAX_VIEW_CHARS); const editorExtensions = React.useMemo(() => { if (!selectedFile?.path) { return [createFlexokiCodeMirrorTheme(currentTheme)]; } const extensions = [createFlexokiCodeMirrorTheme(currentTheme)]; const language = languageByExtension(selectedFile.path); if (language) { extensions.push(language); } if (wrapLines) { extensions.push(EditorView.lineWrapping); } return extensions; }, [currentTheme, selectedFile?.path, wrapLines]); const imageSrc = selectedFile?.path && isSelectedImage ? (runtime.isDesktop ? (isSelectedSvg ? `data:${getImageMimeType(selectedFile.path)};utf8,${encodeURIComponent(fileContent)}` : desktopImageSrc) : (isSelectedSvg ? `data:${getImageMimeType(selectedFile.path)};utf8,${encodeURIComponent(fileContent)}` : `/api/fs/raw?path=${encodeURIComponent(selectedFile.path)}`)) : ''; React.useEffect(() => { let cancelled = false; const resolveDesktopImage = async () => { if (!runtime.isDesktop || !selectedFile?.path || !isSelectedImage || isSelectedSvg) { setDesktopImageSrc(''); return; } setFileError(null); try { if (files.readFileBinary) { const result = await files.readFileBinary(selectedFile.path); if (!cancelled) { setDesktopImageSrc(result.dataUrl); } return; } const core = await import('@tauri-apps/api/core'); const convertFileSrc = (core as { convertFileSrc?: (path: string, protocol?: string) => string }).convertFileSrc; if (!convertFileSrc) { return; } const src = convertFileSrc(selectedFile.path, 'asset'); if (!cancelled) { setDesktopImageSrc(src); } } catch (error) { if (!cancelled) { setDesktopImageSrc(''); setFileError(error instanceof Error ? error.message : 'Failed to read file'); } } finally { if (!cancelled) { setFileLoading(false); } } }; void resolveDesktopImage(); return () => { cancelled = true; }; }, [files, isSelectedImage, isSelectedSvg, runtime.isDesktop, selectedFile?.path]); const renderDialogs = () => ( !open && setActiveDialog(null)}> {activeDialog === 'createFile' && 'Create File'} {activeDialog === 'createFolder' && 'Create Folder'} {activeDialog === 'rename' && 'Rename'} {activeDialog === 'delete' && 'Delete'} {activeDialog === 'createFile' && `Create a new file in ${dialogData?.path ?? 'root'}`} {activeDialog === 'createFolder' && `Create a new folder in ${dialogData?.path ?? 'root'}`} {activeDialog === 'rename' && `Rename ${dialogData?.name}`} {activeDialog === 'delete' && `Are you sure you want to delete ${dialogData?.name}? This action cannot be undone.`} {activeDialog !== 'delete' && (
    setDialogInputValue(e.target.value)} placeholder={activeDialog === 'rename' ? 'New name' : 'Name'} onKeyDown={(e) => { if (e.key === 'Enter') { void handleDialogSubmit(); } }} autoFocus />
    )}
    ); const blockWidgets = React.useMemo(() => { if (!selectedFile) return []; const sessionKey = currentSessionId ?? 'draft'; const sessionDrafts = allDrafts[sessionKey] ?? []; // Filter drafts for current file const fileDrafts = sessionDrafts.filter( (d) => d.source === 'file' && d.fileLabel === selectedFile.path ); const widgets: BlockWidgetDef[] = []; // Add cards for existing drafts fileDrafts.forEach((draft) => { const isEditing = editingDraftId === draft.id; if (isEditing) { widgets.push({ afterLine: draft.endLine, id: `edit-${draft.id}`, content: ( handleSaveComment(text, { start: draft.startLine, end: draft.endLine })} onCancel={() => setEditingDraftId(null)} isEditing={true} /> ), }); } else { widgets.push({ afterLine: draft.endLine, id: `card-${draft.id}`, content: ( { setEditingDraftId(draft.id); setLineSelection(null); }} onDelete={() => removeDraft(sessionKey, draft.id)} /> ), }); } }); // Add input for new comment if (lineSelection && !editingDraftId && !isDragging) { widgets.push({ afterLine: lineSelection.end, id: 'files-new-comment-input', content: ( handleSaveComment(text)} onCancel={() => setLineSelection(null)} /> ), }); } return widgets; }, [selectedFile, currentSessionId, allDrafts, editingDraftId, lineSelection, handleSaveComment, removeDraft, isDragging]); const fileViewer = (
    { // Intentionally no "cancel" action. Keep dialog modal. if (!open) { setConfirmDiscardOpen(true); } }}> Unsaved changes Save your edits before continuing?
    {isMobile && showMobilePageContent && ( )}
    {isMobile ? ( selectedFile ? ( {openFiles.map((file) => { const isActive = selectedFile?.path === file.path; return ( { const target = event.target as HTMLElement; if (target.closest('[data-close-open-file]')) { event.preventDefault(); return; } if (!isActive) { void handleSelectFile(file); } }} className={cn( 'flex items-center justify-between gap-2', isActive && 'bg-[var(--interactive-selection)] text-[var(--interactive-selection-foreground)]' )} > {file.name} ); })} ) : (
    Select a file
    ) ) : ( openFiles.length > 0 ? (
    {openFiles.map((file) => { const isActive = selectedFile?.path === file.path; return (
    ); })}
    {selectedFile && ( )}
    ) : (
    Select a file
    ) )}
    {canEdit && ( )} {canEdit && selectedFile && !isSelectedImage && (
    {!selectedFile ? (
    Pick a file from the tree.
    ) : fileLoading ? (
    Loading…
    ) : fileError ? (
    {fileError}
    ) : isSelectedImage ? (
    {selectedFile?.name
    ) : selectedFile && isMarkdownFile(selectedFile.path) && getMdViewMode() === 'preview' ? (
    {fileContent.length > 500 * 1024 && (
    ⚠️ This file is large ({Math.round(fileContent.length / 1024)}KB). Preview may be limited.
    )}
    Preview unavailable
    Switch to edit mode to fix the issue.
    } >
    ) : (
    { if (!(event instanceof MouseEvent)) { return false; } if (event.button !== 0) { return false; } event.preventDefault(); const lineNumber = view.state.doc.lineAt(line.from).number; // Mobile: tap-to-extend selection if (isMobile && lineSelection && !event.shiftKey) { const start = Math.min(lineSelection.start, lineSelection.end, lineNumber); const end = Math.max(lineSelection.start, lineSelection.end, lineNumber); setLineSelection({ start, end }); isSelectingRef.current = false; selectionStartRef.current = null; setIsDragging(false); return true; } isSelectingRef.current = true; selectionStartRef.current = lineNumber; setIsDragging(true); if (lineSelection && event.shiftKey) { const start = Math.min(lineSelection.start, lineNumber); const end = Math.max(lineSelection.end, lineNumber); setLineSelection({ start, end }); } else { setLineSelection({ start: lineNumber, end: lineNumber }); } return true; }, mouseover: (view: EditorView, line: { from: number; to: number }, event: Event) => { if (!(event instanceof MouseEvent)) { return false; } if (event.buttons !== 1) { return false; } if (!isSelectingRef.current || selectionStartRef.current === null) { return false; } const lineNumber = view.state.doc.lineAt(line.from).number; const start = Math.min(selectionStartRef.current, lineNumber); const end = Math.max(selectionStartRef.current, lineNumber); setLineSelection({ start, end }); setIsDragging(true); return false; }, mouseup: () => { isSelectingRef.current = false; selectionStartRef.current = null; setIsDragging(false); return false; }, }, }} />
    )}
    ); const hasTree = Boolean(root && childrenByDir[root]); const treePanel = (
    setSearchQuery(e.target.value)} placeholder="Search files…" className="h-8 pl-8 pr-8 typography-meta" /> {searchQuery.trim().length > 0 && ( )}
      {searching ? (
    • Searching…
    • ) : searchResults.length > 0 ? ( searchResults.map((node) => { const isActive = selectedFile?.path === node.path; return (
    • ); }) ) : hasTree ? ( renderTree(root, 0) ) : (
    • Loading…
    • )}
    ); // Fullscreen file viewer overlay const fullscreenViewer = isFullscreen && selectedFile && (
    {/* Fullscreen header */}
    {selectedFile.name}
    {displaySelectedPath}
    {canEdit && ( )} {canEdit && !isSelectedImage && (
    {/* Fullscreen content */}
    {fileLoading ? (
    Loading…
    ) : fileError ? (
    {fileError}
    ) : isSelectedImage ? (
    {selectedFile.name}
    ) : isMarkdownFile(selectedFile.path) && getMdViewMode() === 'preview' ? (
    {fileContent.length > 500 * 1024 && (
    This file is large ({Math.round(fileContent.length / 1024)}KB). Preview may be limited.
    )}
    Preview unavailable
    Switch to edit mode to fix the issue.
    } >
    ) : (
    )}
    ); return (
    {renderDialogs()} {fullscreenViewer} {isMobile ? ( showMobilePageContent ? ( fileViewer ) : ( treePanel ) ) : (
    {screenWidth >= 700 && (
    {treePanel}
    )}
    {fileViewer}
    )}
    ); };