/* Copyright 2026 Marimo. All rights reserved. */ import { useAtom, useAtomValue } from "jotai"; import { atomWithStorage } from "jotai/utils"; import { ArrowLeftIcon, BetweenHorizontalStartIcon, BracesIcon, CopyMinusIcon, DownloadIcon, ExternalLinkIcon, FilePlus2Icon, FolderPlusIcon, FolderRootIcon, ListTreeIcon, PlaySquareIcon, UploadIcon, ViewIcon, XIcon, } from "lucide-react"; import React, { Suspense, use, useRef, useState } from "react"; import { type DragPreviewProps, type NodeApi, type NodeRendererProps, Tree, type TreeApi, } from "react-arborist"; import { createPortal } from "react-dom"; 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, VisibilityToggleButton, } 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 } from "@/components/ui/button"; import { DropdownMenuItem, DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; import { Tooltip } from "@/components/ui/tooltip"; import { SearchInput } from "@/components/ui/input"; 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 { isWasm } from "@/core/wasm/utils"; import { useAsyncData } from "@/hooks/useAsyncData"; import { useFileSearch } from "./use-file-search"; import { useHoverExpand } from "./use-hover-expand"; import { ErrorBanner } from "@/plugins/impl/common/error-banner"; import { cn } from "@/utils/cn"; import { copyToClipboard } from "@/utils/copy"; 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 { downloadFile } from "./download"; import { FileViewer } from "./file-viewer"; import { FileSearchResults } from "./file-search-results"; import { FileBrowserRow } from "./file-browser-row"; import type { FileTreeNode, RequestingTree } from "./requesting-tree"; import { openStateAtom, treeAtom } from "./state"; import { PYTHON_CODE_FOR_FILE_TYPE } from "./types"; import { FILE_EXPLORER_DIRECTORY_PATH_ATTRIBUTE, useFileExplorerUpload, } from "./upload"; const hiddenFilesState = atomWithStorage( "marimo:showHiddenFiles", true, jotaiJsonStorage, { getOnInit: true, }, ); interface FileExplorerContextValue { tree: RequestingTree; uploadFiles: (destinationPath: FilePath) => void; externalDropDestinationPath: FilePath | null; } const RequestingTreeContext = React.createContext(null); export const FileExplorer: React.FC<{ height: number; externalDropDestinationPath?: FilePath | null; }> = ({ height, externalDropDestinationPath = null }) => { const treeRef = useRef>(null); const searchTreeRef = useRef>(null); const searchRef = useRef(null); const [query, setQuery] = useState(""); const [revealId, setRevealId] = useState(null); const dndManager = useTreeDndManager(); const [tree] = useAtom(treeAtom); const [data, setData] = useState([]); const [openFile, setOpenFile] = useState(null); const [selectedFolder, setSelectedFolder] = useState<{ id: string; path: FilePath; } | null>(null); const [showHiddenFiles, setShowHiddenFiles] = useAtom(hiddenFilesState); const search = useFileSearch({ query: query.trim(), tree, showHiddenFiles }); // Keep external state to remember which folders are open when this // component is unmounted. const [openState, setOpenState] = useAtom(openStateAtom); const refreshUploadDestination = useEvent((destinationPath: FilePath) => { return tree.refreshPath(destinationPath); }); const uploadDestinationRef = useRef("" as FilePath); const { getInputProps: getUploadInputProps, open: openUploadPicker } = useFileExplorerUpload({ noClick: true, noDrag: true, noKeyboard: true, destinationPath: () => uploadDestinationRef.current, getDestinationLabel: (path) => getUploadDestinationLabel(tree, path), refreshDestination: refreshUploadDestination, }); const handleUploadFiles = useEvent((destinationPath: FilePath) => { uploadDestinationRef.current = destinationPath; openUploadPicker(); }); const { openPrompt, openConfirm } = useImperativeModal(); const { isPending, error } = useAsyncData(() => tree.initialize(setData), []); const handleRefresh = useEvent(async () => { await tree.refreshAll(Object.keys(openState).filter((id) => openState[id])); search.refetch(); }); const handleHiddenFilesToggle = useEvent(() => { const newValue = !showHiddenFiles; setShowHiddenFiles(newValue); }); const handleCreateFolder = useEvent(async () => { openPrompt({ title: "Folder name", onConfirm: async (name) => { tree.createFolder(name, selectedFolder?.id ?? null); }, }); }); const handleCreateFile = useEvent(async () => { openPrompt({ title: "File name", onConfirm: async (name) => { tree.createFile({ name, parentId: selectedFolder?.id ?? null }); }, }); }); const handleCreateNotebook = useEvent(async () => { openPrompt({ title: "Notebook name", onConfirm: async (name) => { tree.createFile({ name, parentId: selectedFolder?.id ?? null, type: "notebook", }); }, }); }); const handleCollapseAll = useEvent(() => { treeRef.current?.closeAll(); setOpenState({}); }); const visibleData = React.useMemo( () => filterHiddenTree(data, showHiddenFiles), [data, showHiddenFiles], ); React.useEffect(() => { if (selectedFolder && !treeContainsId(visibleData, selectedFolder.id)) { setSelectedFolder(null); } }, [selectedFolder, visibleData]); const contextValue = React.useMemo( () => ({ tree, uploadFiles: handleUploadFiles, externalDropDestinationPath, }), [tree, handleUploadFiles, externalDropDestinationPath], ); React.useEffect(() => { if (revealId && !openFile) { const activeTree = query.trim() ? searchTreeRef.current : treeRef.current; activeTree?.openParents(revealId); activeTree?.select(revealId); const node = activeTree?.get(revealId); if (node?.data.isDirectory) { node.open(); } setRevealId(null); } }, [revealId, query, openFile, data]); const handleReveal = async (node: FileTreeNode) => { if (!node.isDirectory) { setOpenFile(node); return; } if (await tree.reveal(node)) { setQuery(""); setRevealId(node.id); } }; const handleTreeKeyDown = (event: React.KeyboardEvent) => { if ((event.ctrlKey || event.metaKey) && event.key === "f") { event.preventDefault(); event.stopPropagation(); searchRef.current?.focus(); searchRef.current?.select(); return; } if (event.key === "Escape" && query) { event.preventDefault(); event.stopPropagation(); setQuery(""); searchRef.current?.focus(); return; } const role = event.target instanceof Element ? event.target.getAttribute("role") : null; if (query.trim() || (role !== "treeitem" && role !== "tree")) { return; } const node = treeRef.current?.focusedNode; if (!node || treeRef.current?.isEditing) { return; } if (event.key === "Enter") { event.preventDefault(); event.stopPropagation(); if (node.data.isDirectory) { node.toggle(); } else { node.activate(); } } else if (event.key === "F2" && !node.data.isRoot) { event.preventDefault(); event.stopPropagation(); node.edit(); } else if (event.key === "Delete" || event.key === "Backspace") { event.preventDefault(); event.stopPropagation(); if (!node.data.isRoot) { void node.tree.delete(node.id); } } }; if (isPending) { return ; } if (error) { return ; } const preview = openFile ? ( <>
{openFile.name}
{ const path = tree.getPrimaryRelativePath( openFile.path as FilePath, ); if (path !== null) { openMarimoNotebook(evt, path); } } : undefined } file={openFile} /> ) : null; return ( <> {preview} ); }; const INDENT_STEP = 15; interface ToolbarProps { onRefresh: () => void; onHidden: () => void; showHiddenFiles: boolean; onCreateFile: () => void; onCreateNotebook: () => void; onCreateFolder: () => void; onCollapseAll: () => void; uploadDestinationPath: FilePath; uploadDestinationLabel: string; onUpload: (destinationPath: FilePath) => void; } const Toolbar = ({ onRefresh, onHidden, showHiddenFiles, onCreateFile, onCreateNotebook, onCreateFolder, onCollapseAll, uploadDestinationPath, uploadDestinationLabel, onUpload, }: ToolbarProps) => { const uploadLabel = `Upload files to ${uploadDestinationLabel}`; return (
); }; const Show = ({ node, onOpenMarimoFile, }: { node: NodeApi; onOpenMarimoFile: ( evt: Pick, ) => void; }) => { const label = ( {node.data.name} {node.data.isMarimoFile && node.data.isPrimaryRoot && !isWasm() && ( open )} ); return node.data.isRoot ? ( {label} ) : ( label ); }; const Node = ({ node, style, dragHandle }: NodeRendererProps) => { const { openFile } = useRequestClient(); const disableFileDownloads = useAtomValue(disableFileDownloadsAtom); const fileType: FileIconType = node.data.isDirectory ? "directory" : guessFileIconType(node.data.name); const Icon = FILE_ICON[fileType]; const { openPrompt } = useImperativeModal(); const { createNewCell } = useCellActions(); const lastFocusedCellId = useLastFocusedCellId(); const handleInsertCode = (code: string) => { createNewCell({ code, before: false, cellId: lastFocusedCellId ?? "__end__", }); }; const fileExplorer = use(RequestingTreeContext); const tree = fileExplorer?.tree; const isExternalDropTarget = node.data.isDirectory && fileExplorer?.externalDropDestinationPath === node.data.path; useHoverExpand(node, isExternalDropTarget); const handleOpenMarimoFile = async ( evt: Pick, ) => { const path = tree?.getPrimaryRelativePath(node.data.path as FilePath); if (path !== null && path !== undefined) { openMarimoNotebook(evt, path); } }; const handleDeleteFile = async (evt: Event) => { evt.stopPropagation(); evt.preventDefault(); await node.tree.delete(node.id); }; 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 (
{ if (node.data.isDirectory) { node.toggle(); } }} onDoubleClick={(event) => { event.stopPropagation(); if (!node.data.isDirectory) { node.activate(); } }} > {Array.from({ length: node.level }, (_, level) => (
); }; const FolderArrow = ({ node }: { node: NodeApi }) => { if (!node.data.isDirectory) { return ; } if (node.data.loadState === "loading") { return ( ); } return ; }; function openMarimoNotebook( event: Pick, path: string, ) { event.stopPropagation(); event.preventDefault(); openNotebook(path); } export function getUploadDestinationLabel( tree: RequestingTree, destinationPath: FilePath, ): string { return tree.getDisplayPath(destinationPath); } export function filterHiddenTree( list: FileTreeNode[], showHidden: boolean, ): FileTreeNode[] { if (showHidden) { return list; } const out: FileTreeNode[] = []; for (const item of list) { if (!item.isRoot && 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; } function treeContainsId(list: FileTreeNode[], id: string): boolean { return list.some( (item) => item.id === id || (item.children ? treeContainsId(item.children, id) : false), ); } function FileDragPreview({ mouse, id, isDragging, tree, }: DragPreviewProps & { tree: TreeApi | null }) { const node = tree?.get(id); if (!isDragging || !mouse || !node) { return null; } const fileType = node.data.isDirectory ? "directory" : guessFileIconType(node.data.name); const Icon = FILE_ICON[fileType]; return createPortal(
{node.data.name}
, document.body, ); }