import "./FileBrowser.css"; import { useState, useCallback, useEffect, useMemo, useRef, useId } from "react"; import { X, Save, RotateCcw, Folder, FileType, ArrowLeft, ChevronDown, ChevronUp } from "lucide-react"; import { useWorkspaceFileBrowser } from "../hooks/useWorkspaceFileBrowser"; import { useWorkspaceFileEditor } from "../hooks/useWorkspaceFileEditor"; import { useWorkspaces } from "../hooks/useWorkspaces"; import { useModalResizePersist } from "../hooks/useModalResizePersist"; import { useOverlayDismiss } from "../hooks/useOverlayDismiss"; import { downloadFileUrl } from "../api"; import { FileBrowser } from "./FileBrowser"; import { FileEditor } from "./FileEditor"; import { WorkspaceSelector } from "./WorkspaceSelector"; import { getScopedItem, setScopedItem } from "../utils/projectStorage"; const MOBILE_BREAKPOINT = 768; const SIDEBAR_DEFAULT_WIDTH = 280; const SIDEBAR_MIN_WIDTH = 180; const SIDEBAR_MAX_WIDTH = 500; const SIDEBAR_STORAGE_KEY = "fusion:file-browser-sidebar-width"; const FILES_LINE_NUMBERS_STORAGE_KEY = "kb-files-line-numbers"; /** * Image file extensions that should be rendered as image previews. */ const IMAGE_EXTENSIONS = new Set([ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".bmp", ".svgz", ]); /** * Binary file extensions that should be displayed as read-only. */ const BINARY_EXTENSIONS = new Set([ ...IMAGE_EXTENSIONS, ".exe", ".dll", ".so", ".dylib", ".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar", ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".mp3", ".mp4", ".avi", ".mov", ".webm", ".mkv", ".flv", ".woff", ".woff2", ".ttf", ".otf", ".eot", ".wasm", ".bin", ]); function isBinaryFile(filename: string): boolean { const ext = filename.slice(filename.lastIndexOf(".")).toLowerCase(); return BINARY_EXTENSIONS.has(ext); } function isImageFile(filename: string): boolean { const ext = filename.slice(filename.lastIndexOf(".")).toLowerCase(); return IMAGE_EXTENSIONS.has(ext); } function getParentDirectory(path: string): string { const normalized = path.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/$/, ""); const lastSlash = normalized.lastIndexOf("/"); return lastSlash > 0 ? normalized.slice(0, lastSlash) : "."; } interface FileBrowserModalProps { isOpen?: boolean; initialWorkspace?: string; initialFile?: string | null; onClose: () => void; onWorkspaceChange?: (workspace: string) => void; projectId?: string; } /** * Workspace-aware file browser modal used by the top-level dashboard Files button. * Supports browsing the project root or any active task worktree from one shared UI. */ export function FileBrowserModal({ initialWorkspace = "project", initialFile = null, onClose, onWorkspaceChange, projectId, }: FileBrowserModalProps) { const { projectName, workspaces } = useWorkspaces(projectId); const modalRef = useRef(null); useModalResizePersist(modalRef, true, "fusion:files-modal-size"); const overlayDismissProps = useOverlayDismiss(onClose); const [currentWorkspace, setCurrentWorkspace] = useState(initialWorkspace); const [selectedFile, setSelectedFile] = useState(null); const [isMobile, setIsMobile] = useState(false); const [mobileView, setMobileView] = useState<"list" | "editor">("list"); const [sidebarWidth, setSidebarWidth] = useState(SIDEBAR_DEFAULT_WIDTH); const [showLineNumbers, setShowLineNumbers] = useState(false); const [toolbarActionsExpanded, setToolbarActionsExpanded] = useState(false); const toolbarActionsId = useId(); const { entries, currentPath, setPath, loading: browserLoading, error: browserError, refresh, } = useWorkspaceFileBrowser(currentWorkspace, true, projectId); const { content, setContent, originalContent, loading: editorLoading, saving, error: editorError, save, hasChanges, mtime, } = useWorkspaceFileEditor(currentWorkspace, selectedFile, true, projectId); useEffect(() => { setCurrentWorkspace(initialWorkspace); }, [initialWorkspace]); useEffect(() => { const checkMobile = () => { setIsMobile(window.innerWidth <= MOBILE_BREAKPOINT); }; checkMobile(); window.addEventListener("resize", checkMobile); return () => window.removeEventListener("resize", checkMobile); }, []); useEffect(() => { if (!selectedFile) { setMobileView("list"); } setToolbarActionsExpanded(false); }, [selectedFile]); useEffect(() => { if (!initialFile) { setSelectedFile(null); return; } setSelectedFile(initialFile); setPath(getParentDirectory(initialFile)); if (isMobile) { setMobileView("editor"); } }, [initialFile, isMobile, setPath]); useEffect(() => { try { const rawWidth = localStorage.getItem(SIDEBAR_STORAGE_KEY); if (!rawWidth) return; const parsedWidth = Number.parseInt(rawWidth, 10); if (!Number.isNaN(parsedWidth)) { const clampedWidth = Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, parsedWidth)); setSidebarWidth(clampedWidth); } } catch { // Ignore storage errors. } }, []); useEffect(() => { const savedPreference = getScopedItem(FILES_LINE_NUMBERS_STORAGE_KEY, projectId); setShowLineNumbers(savedPreference === "true"); }, [projectId]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { onClose(); } if ((e.metaKey || e.ctrlKey) && e.key === "s") { e.preventDefault(); if (hasChanges && !saving) { void save(); } } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [onClose, hasChanges, saving, save]); const handleSelectFile = useCallback((path: string) => { setSelectedFile(path); if (isMobile) { setMobileView("editor"); } }, [isMobile]); const handleBackToList = useCallback(() => { setMobileView("list"); }, []); const handleDiscard = useCallback(() => { setContent(originalContent); }, [originalContent, setContent]); const handleWorkspaceSelect = useCallback((workspace: string) => { setCurrentWorkspace(workspace); setSelectedFile(null); setMobileView("list"); onWorkspaceChange?.(workspace); }, [onWorkspaceChange]); const persistSidebarWidth = useCallback((width: number) => { try { localStorage.setItem(SIDEBAR_STORAGE_KEY, String(width)); } catch { // Ignore storage errors. } }, []); const handleResizeStart = useCallback((event: React.PointerEvent) => { if (isMobile) { return; } event.preventDefault(); event.stopPropagation(); const resizeHandle = event.currentTarget; if (typeof resizeHandle.setPointerCapture === "function") { resizeHandle.setPointerCapture(event.pointerId); } const startX = event.clientX; const startWidth = sidebarWidth; let latestWidth = startWidth; document.body.style.userSelect = "none"; const onPointerMove = (moveEvent: PointerEvent) => { const deltaX = moveEvent.clientX - startX; const nextWidth = Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, startWidth + deltaX)); latestWidth = nextWidth; setSidebarWidth(nextWidth); }; const onPointerUp = (upEvent: PointerEvent) => { if (typeof resizeHandle.releasePointerCapture === "function") { resizeHandle.releasePointerCapture(upEvent.pointerId); } document.body.style.userSelect = ""; document.removeEventListener("pointermove", onPointerMove); document.removeEventListener("pointerup", onPointerUp); persistSidebarWidth(latestWidth); }; document.addEventListener("pointermove", onPointerMove); document.addEventListener("pointerup", onPointerUp); }, [isMobile, persistSidebarWidth, sidebarWidth]); const handleResizeKeyDown = useCallback((event: React.KeyboardEvent) => { if (isMobile) { return; } const step = 20; if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") { return; } event.preventDefault(); const delta = event.key === "ArrowLeft" ? -step : step; const nextWidth = Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, sidebarWidth + delta)); setSidebarWidth(nextWidth); persistSidebarWidth(nextWidth); }, [isMobile, persistSidebarWidth, sidebarWidth]); const handleToggleLineNumbers = useCallback(() => { setShowLineNumbers((previousValue) => { const nextValue = !previousValue; setScopedItem(FILES_LINE_NUMBERS_STORAGE_KEY, String(nextValue), projectId); return nextValue; }); }, [projectId]); const workspaceLabel = useMemo(() => { if (currentWorkspace === "project") { return "Project"; } return workspaces.find((workspace) => workspace.id === currentWorkspace)?.id ?? currentWorkspace; }, [currentWorkspace, workspaces]); const modalTitle = `Files — ${workspaceLabel}`; // Compute image source URL when an image file is selected const imageSrc = useMemo(() => { if (!selectedFile || !isImageFile(selectedFile)) return null; return downloadFileUrl(currentWorkspace, selectedFile, projectId); }, [selectedFile, currentWorkspace, projectId]); const formatFileSize = (value: string): string => { const bytes = new Blob([value]).size; if (bytes < 1024) return `${bytes} B`; return `${(bytes / 1024).toFixed(1)} KB`; }; return (
{modalTitle} {selectedFile && ( {selectedFile} )}
{!isMobile && (
)}
{selectedFile ? ( <>
{isMobile && mobileView === "editor" && ( )} {!isBinaryFile(selectedFile) && ( )} {selectedFile} {isBinaryFile(selectedFile) && ( Binary file — read only )} {mtime && ( Modified: {new Date(mtime).toLocaleString()} )} {editorLoading && ( Loading... )}
{!imageSrc && hasChanges && ( <> )}
{editorError && !imageSrc && (
{editorError}
)} {imageSrc ? (
{selectedFile
) : (
)} {!imageSrc && (
{formatFileSize(content)} {hasChanges && Unsaved changes}
)} ) : (

Select a file to edit

)}
); }