/* Copyright 2026 Marimo. All rights reserved. */ import { useAtom, useAtomValue } from "jotai"; import { atomWithStorage } from "jotai/utils"; import { ArrowLeftIcon, BetweenHorizontalStartIcon, BracesIcon, CopyMinusIcon, DownloadIcon, ExternalLinkIcon, EyeOffIcon, FilePlus2Icon, FolderPlusIcon, ListTreeIcon, PlaySquareIcon, UploadIcon, ViewIcon, } from "lucide-react"; import React, { Suspense, use, useRef, useState } from "react"; import { type NodeApi, type NodeRendererProps, Tree, type TreeApi, } from "react-arborist"; import useEvent from "react-use-event-hook"; import { FILE_ICON, FILE_ICON_COLOR, type FileIconType, guessFileIconType, } from "@/components/editor/file-tree/file-icons"; import { DeleteMenuItem, DuplicateMenuItem, FileActionsDropdown, RenameMenuItem, } from "@/components/editor/file-tree/file-operations"; import { FileNameInput } from "@/components/editor/file-tree/file-name-input"; import { MENU_ITEM_ICON_CLASS, RefreshIconButton, TreeChevron, } from "@/components/editor/file-tree/tree-actions"; import { MarimoIcon, MarimoPlusIcon } from "@/components/icons/marimo-icons"; import { Spinner } from "@/components/icons/spinner"; import { useImperativeModal } from "@/components/modal/ImperativeModal"; import { AlertDialogDestructiveAction } from "@/components/ui/alert-dialog"; import { Button, buttonVariants } from "@/components/ui/button"; import { DropdownMenuItem, DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; import { Tooltip } from "@/components/ui/tooltip"; import { toast } from "@/components/ui/use-toast"; import { useCellActions } from "@/core/cells/cells"; import { useLastFocusedCellId } from "@/core/cells/focus"; import { disableFileDownloadsAtom } from "@/core/config/config"; import { useRequestClient } from "@/core/network/requests"; import type { FileInfo } from "@/core/network/types"; import { isWasm } from "@/core/wasm/utils"; import { useAsyncData } from "@/hooks/useAsyncData"; import { ErrorBanner } from "@/plugins/impl/common/error-banner"; import { deserializeBlob } from "@/utils/blob"; import { cn } from "@/utils/cn"; import { copyToClipboard } from "@/utils/copy"; import { downloadBlob } from "@/utils/download"; import { type Base64String, base64ToDataURL } from "@/utils/json/base64"; import { openNotebook } from "@/utils/links"; import type { FilePath } from "@/utils/paths"; import { makeDuplicateName } from "@/utils/pathUtils"; import { jotaiJsonStorage } from "@/utils/storage/jotai"; import { useTreeDndManager } from "./dnd-wrapper"; import { FileViewer } from "./file-viewer"; import type { RequestingTree } from "./requesting-tree"; import { openStateAtom, treeAtom } from "./state"; import { PYTHON_CODE_FOR_FILE_TYPE } from "./types"; import { useFileExplorerUpload } from "./upload"; const hiddenFilesState = atomWithStorage( "marimo:showHiddenFiles", true, jotaiJsonStorage, { getOnInit: true, }, ); const RequestingTreeContext = React.createContext(null); export const FileExplorer: React.FC<{ height: number; }> = ({ height }) => { const treeRef = useRef>(null); const dndManager = useTreeDndManager(); const [tree] = useAtom(treeAtom); const [data, setData] = useState([]); const [openFile, setOpenFile] = useState(null); const [showHiddenFiles, setShowHiddenFiles] = useAtom(hiddenFilesState); const { openPrompt } = useImperativeModal(); // Keep external state to remember which folders are open // when this component is unmounted const [openState, setOpenState] = useAtom(openStateAtom); const { isPending, error } = useAsyncData(() => tree.initialize(setData), []); const handleRefresh = useEvent(() => { // Return the promise so callers can await refresh completion return tree.refreshAll( Object.keys(openState).filter((id) => openState[id]), ); }); const handleHiddenFilesToggle = useEvent(() => { const newValue = !showHiddenFiles; setShowHiddenFiles(newValue); }); const handleCreateFolder = useEvent(async () => { openPrompt({ title: "Folder name", onConfirm: async (name) => { tree.createFolder(name, null); }, }); }); const handleCreateFile = useEvent(async () => { openPrompt({ title: "File name", onConfirm: async (name) => { tree.createFile({ name, parentId: null }); }, }); }); const handleCreateNotebook = useEvent(async () => { openPrompt({ title: "Notebook name", onConfirm: async (name) => { tree.createFile({ name, parentId: null, type: "notebook" }); }, }); }); const handleCollapseAll = useEvent(() => { treeRef.current?.closeAll(); setOpenState({}); }); const visibleData = React.useMemo( () => filterHiddenTree(data, showHiddenFiles), [data, showHiddenFiles], ); if (isPending) { return ; } if (error) { return ; } if (openFile) { return ( <>
{openFile.name}
openMarimoNotebook( evt, tree.relativeFromRoot(openFile.path as FilePath), ) } file={openFile} /> ); } return ( <> width="100%" ref={treeRef} height={height - 33} className="h-full" data={visibleData} initialOpenState={openState} openByDefault={false} // Use shared DnD manager to prevent "Cannot have two HTML5 backends" error dndManager={dndManager} // Hide the drop cursor renderCursor={() => null} // Disable dropping files into files disableDrop={({ parentNode }) => !parentNode.data.isDirectory} onDelete={async ({ ids }) => { for (const id of ids) { await tree.delete(id); } }} onRename={async ({ id, name }) => { await tree.rename(id, name); }} onMove={async ({ dragIds, parentId }) => { await tree.move(dragIds, parentId); }} onSelect={(nodes) => { const first = nodes[0]; if (!first) { return; } if (!first.data.isDirectory) { setOpenFile(first.data); } }} onToggle={async (id) => { const result = await tree.expand(id); if (result) { const prevOpen = openState[id] ?? false; setOpenState({ ...openState, [id]: !prevOpen }); } }} padding={15} rowHeight={30} indent={INDENT_STEP} overscanCount={1000} // Disable multi-selection disableMultiSelection={true} > {Node} ); }; const INDENT_STEP = 15; interface ToolbarProps { onRefresh: () => void; onHidden: () => void; onCreateFile: () => void; onCreateNotebook: () => void; onCreateFolder: () => void; onCollapseAll: () => void; tree: RequestingTree; } const Toolbar = ({ onRefresh, onHidden, onCreateFile, onCreateNotebook, onCreateFolder, onCollapseAll, }: ToolbarProps) => { const { getRootProps, getInputProps } = useFileExplorerUpload({ noDrag: true, noDragEventsBubbling: true, }); return (
); }; const Show = ({ node, onOpenMarimoFile, }: { node: NodeApi; onOpenMarimoFile: ( evt: Pick, ) => void; }) => { return ( { if (node.data.isDirectory) { return; } e.stopPropagation(); node.select(); }} > {node.data.name} {node.data.isMarimoFile && !isWasm() && ( open )} ); }; const Node = ({ node, style, dragHandle }: NodeRendererProps) => { const { openFile, sendFileDetails } = useRequestClient(); const disableFileDownloads = useAtomValue(disableFileDownloadsAtom); const fileType: FileIconType = node.data.isDirectory ? "directory" : guessFileIconType(node.data.name); const Icon = FILE_ICON[fileType]; const { openConfirm, openPrompt } = useImperativeModal(); const { createNewCell } = useCellActions(); const lastFocusedCellId = useLastFocusedCellId(); const handleInsertCode = (code: string) => { createNewCell({ code, before: false, cellId: lastFocusedCellId ?? "__end__", }); }; const tree = use(RequestingTreeContext); const handleOpenMarimoFile = async ( evt: Pick, ) => { const path = tree ? tree.relativeFromRoot(node.data.path as FilePath) : node.data.path; openMarimoNotebook(evt, path); }; const handleDeleteFile = async (evt: Event) => { evt.stopPropagation(); evt.preventDefault(); openConfirm({ title: "Delete file", description: `Are you sure you want to delete ${node.data.name}?`, confirmAction: ( { await node.tree.delete(node.id); }} aria-label="Confirm" > Delete ), }); }; const handleCreateFolder = useEvent(async () => { // If not expanded, then expand node.open(); openPrompt({ title: "Folder name", onConfirm: async (name) => { tree?.createFolder(name, node.id); }, }); }); const handleCreateFile = useEvent(async () => { node.open(); openPrompt({ title: "File name", onConfirm: async (name) => { tree?.createFile({ name, parentId: node.id }); }, }); }); const handleCreateNotebook = useEvent(async () => { node.open(); openPrompt({ title: "Notebook name", onConfirm: async (name) => { tree?.createFile({ name, parentId: node.id, type: "notebook" }); }, }); }); const handleDuplicate = useEvent(async () => { if (!tree) { return; } await tree.copy(node.id, makeDuplicateName(node.data.name)); }); return (
{ evt.stopPropagation(); if (node.data.isDirectory) { node.toggle(); } }} > {node.data.isMarimoFile ? ( ) : ( )} {node.isEditing ? ( ) : ( )} {!node.data.isDirectory && ( node.select()}> Open file )} {!node.data.isDirectory && !isWasm() && ( { openFile({ path: node.data.path }); }} > Open file in external editor )} {node.data.isDirectory && ( <> handleCreateNotebook()}> Create notebook handleCreateFile()}> Create file handleCreateFolder()}> Create folder )} node.edit()} /> { await copyToClipboard(node.data.path); toast({ title: "Copied to clipboard" }); }} > Copy path {tree && ( { await copyToClipboard( tree.relativeFromRoot(node.data.path as FilePath), ); toast({ title: "Copied to clipboard" }); }} > Copy relative path )} { const { path } = node.data; const pythonCode = PYTHON_CODE_FOR_FILE_TYPE[fileType](path); handleInsertCode(pythonCode); }} > Insert snippet for reading file { toast({ title: "Copied to clipboard", description: "Code to open the file has been copied to your clipboard. You can also drag and drop this file into the editor", }); const { path } = node.data; const pythonCode = PYTHON_CODE_FOR_FILE_TYPE[fileType](path); await copyToClipboard(pythonCode); }} > Copy snippet for reading file {node.data.isMarimoFile && !isWasm() && ( <> Open notebook )} {!node.data.isDirectory && !disableFileDownloads && ( <> { const details = await sendFileDetails({ path: node.data.path, }); if (details.isBase64 && details.contents) { const blob = deserializeBlob( base64ToDataURL( details.contents as Base64String, details.mimeType || "application/octet-stream", ), ); downloadBlob(blob, node.data.name); } else { downloadBlob( new Blob([details.contents || ""]), node.data.name, ); } }} > Download )}
); }; const FolderArrow = ({ node }: { node: NodeApi }) => { if (!node.data.isDirectory) { return ; } return ; }; function openMarimoNotebook( event: Pick, path: string, ) { event.stopPropagation(); event.preventDefault(); openNotebook(path); } export function filterHiddenTree( list: FileInfo[], showHidden: boolean, ): FileInfo[] { if (showHidden) { return list; } const out: FileInfo[] = []; for (const item of list) { if (isDirectoryOrFileHidden(item.name)) { continue; } let next = item; if (item.children) { const kids = filterHiddenTree(item.children, showHidden); if (kids !== item.children) { next = { ...item, children: kids }; } } out.push(next); } return out; } export function isDirectoryOrFileHidden(filename: string): boolean { if (filename.startsWith(".")) { return true; } return false; }