import { useState, useCallback, useEffect, useMemo } from "react"; import { X, Save, RotateCcw, Folder, FileType, ArrowLeft } from "lucide-react"; import { useWorkspaceFileBrowser } from "../hooks/useWorkspaceFileBrowser"; import { useWorkspaceFileEditor } from "../hooks/useWorkspaceFileEditor"; import { useWorkspaces } from "../hooks/useWorkspaces"; import { downloadFileUrl } from "../api"; import { FileBrowser } from "./FileBrowser"; import { FileEditor } from "./FileEditor"; import { WorkspaceSelector } from "./WorkspaceSelector"; const MOBILE_BREAKPOINT = 768; /** * 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); } interface FileBrowserModalProps { isOpen?: boolean; initialWorkspace?: string; 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", onClose, onWorkspaceChange, projectId, }: FileBrowserModalProps) { const { projectName, workspaces } = useWorkspaces(projectId); const [currentWorkspace, setCurrentWorkspace] = useState(initialWorkspace); const [selectedFile, setSelectedFile] = useState(null); const [isMobile, setIsMobile] = useState(false); const [mobileView, setMobileView] = useState<"list" | "editor">("list"); 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"); } }, [selectedFile]); 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 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 (
e.stopPropagation()}>
{modalTitle} {selectedFile && ( {selectedFile} )}
{selectedFile ? ( <>
{isMobile && mobileView === "editor" && ( )} {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

)}
); }