/* Copyright 2026 Marimo. All rights reserved. */ import { CommandList } from "cmdk"; import { CopyIcon, DownloadIcon, FolderIcon, HardDriveIcon, HelpCircleIcon, LoaderCircle, PlusIcon, ViewIcon, XIcon, } from "lucide-react"; import React, { useCallback, useState } from "react"; import { useLocale } from "react-aria"; import { EngineVariable } from "@/components/databases/engine-variable"; import { useAddCodeToNewCell } from "@/components/editor/cell/useAddCell"; import { PanelEmptyState } from "@/components/editor/chrome/panels/empty-state"; import { AddConnectionDialog } from "@/components/editor/connections/add-connection-dialog"; import { FILE_ICON_COLOR, renderFileIcon, } from "@/components/editor/file-tree/file-icons"; import { MENU_ITEM_ICON_CLASS, MoreActionsButton, RefreshIconButton, TreeChevron, } from "@/components/editor/file-tree/tree-actions"; import { Command, CommandInput, CommandItem } from "@/components/ui/command"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Tooltip } from "@/components/ui/tooltip"; import { toast } from "@/components/ui/use-toast"; import { DownloadStorage } from "@/core/storage/request-registry"; import { useStorage, useStorageActions, useStorageEntries, } from "@/core/storage/state"; import type { StorageEntry, StorageNamespace, StoragePathKey, } from "@/core/storage/types"; import { storagePathKey } from "@/core/storage/types"; import { cn } from "@/utils/cn"; import { copyToClipboard } from "@/utils/copy"; import { downloadByURL } from "@/utils/download"; import { formatBytes } from "@/utils/formatting"; import { Logger } from "@/utils/Logger"; import { ErrorState } from "../datasources/components"; import { Button } from "../ui/button"; import { ProtocolIcon } from "./components"; import { StorageFileViewer } from "./storage-file-viewer"; import { STORAGE_SNIPPETS } from "./storage-snippets"; interface OpenFileInfo { entry: StorageEntry; namespace: string; protocol: string; backendType: StorageNamespace["backendType"]; } // Pixels per depth level. Applied as paddingLeft on each full-width item // so the selection highlight still spans the entire panel. const INDENT_PX = 16; function indentStyle(depth: number): React.CSSProperties { return { paddingLeft: depth * INDENT_PX }; } function formatDate(timestamp: number, locale: string): string { const date = new Date(timestamp * 1000); return date.toLocaleDateString(locale, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }); } /** Extract display name from a full path (e.g., "folder/subfolder/" -> "subfolder") */ function displayName(path: string): string { // Remove trailing slash const trimmed = path.endsWith("/") ? path.slice(0, -1) : path; const parts = trimmed.split("/"); return parts[parts.length - 1] || trimmed; } /** * Recursively check whether an entry (or any of its loaded descendants) * matches the search query. */ function entryMatchesSearch( entry: StorageEntry, namespace: string, searchValue: string, entriesByPath: ReadonlyMap, ): boolean { const query = searchValue.toLowerCase(); if (displayName(entry.path).toLowerCase().includes(query)) { return true; } // For directories, check loaded children recursively if (entry.kind === "directory") { const children = entriesByPath.get(storagePathKey(namespace, entry.path)); if (children) { return children.some((child) => entryMatchesSearch(child, namespace, searchValue, entriesByPath), ); } } return false; } /** * Filter entries to those matching the search (or having loaded descendants * that match). Returns all entries when there is no active search. */ function filterEntries( entries: StorageEntry[], namespace: string, searchValue: string, entriesByPath: ReadonlyMap, ): StorageEntry[] { if (!searchValue.trim()) { return entries; } return entries.filter((entry) => entryMatchesSearch(entry, namespace, searchValue, entriesByPath), ); } /** * Lazily loaded children of a directory entry. * Caches fetched entries in the Jotai store so re-expanding doesn't re-fetch. */ const StorageEntryChildren: React.FC<{ namespace: string; protocol: string; rootPath: string; backendType: StorageNamespace["backendType"]; prefix: string; depth: number; locale: string; searchValue: string; onOpenFile: (info: OpenFileInfo) => void; }> = ({ namespace, protocol, rootPath, backendType, prefix, depth, locale, searchValue, onOpenFile, }) => { const { entriesByPath } = useStorage(); const { entries: children, isPending, error, } = useStorageEntries(namespace, prefix); if (isPending) { return (
Loading...
); } if (error) { return (
Failed to load: {error.message}
); } if (children.length === 0) { return (
Empty
); } const filtered = filterEntries( children, namespace, searchValue, entriesByPath, ); return ( <> {filtered.map((child) => ( ))} ); }; const StorageEntryRow: React.FC<{ entry: StorageEntry; namespace: string; protocol: string; rootPath: string; backendType: StorageNamespace["backendType"]; depth: number; locale: string; searchValue: string; onOpenFile: (info: OpenFileInfo) => void; }> = ({ entry, namespace, protocol, rootPath, backendType, depth, locale, searchValue, onOpenFile, }) => { const [isExpanded, setIsExpanded] = useState(false); const { entriesByPath } = useStorage(); const addCodeToNewCell = useAddCodeToNewCell(); const isDir = entry.kind === "directory"; const name = displayName(entry.path); const hasSearch = !!searchValue.trim(); const selfMatches = isDir && hasSearch && name.toLowerCase().includes(searchValue.trim().toLowerCase()); // During a search, auto-expand directories whose loaded descendants match const hasMatchingDescendants = isDir && hasSearch && !!entriesByPath .get(storagePathKey(namespace, entry.path)) ?.some((child) => entryMatchesSearch(child, namespace, searchValue, entriesByPath), ); // Folder is shown expanded by manual toggle OR by search auto-expand const effectiveExpanded = isExpanded || hasMatchingDescendants; const handleDownload = useCallback(async () => { try { const result = await DownloadStorage.request({ namespace, path: entry.path, }); if (result.error) { toast({ title: "Download failed", description: result.error, variant: "danger", }); return; } if (result.url) { downloadByURL(result.url, result.filename ?? "download"); } } catch (error) { Logger.error("Failed to download storage entry", error); toast({ title: "Download failed", description: String(error), variant: "danger", }); } }, [namespace, entry.path]); return ( <> { if (isDir) { setIsExpanded(!effectiveExpanded); } else { onOpenFile({ entry, namespace, protocol, backendType }); } }} > {isDir ? ( ) : ( )} {isDir ? ( ) : ( renderFileIcon(name) )} {name}
{entry.size > 0 && ( {formatBytes(entry.size, locale)} )} {entry.lastModified != null && ( {formatDate(entry.lastModified, locale)} )} e.stopPropagation()} /> e.stopPropagation()} onCloseAutoFocus={(e) => e.preventDefault()} > {!isDir && ( onOpenFile({ entry, namespace, protocol, backendType }) } > View )} { await copyToClipboard(entry.path); toast({ title: "Copied to clipboard" }); }} > Copy path {!isDir && ( handleDownload()}> Download )} {STORAGE_SNIPPETS.map((snippet) => { const code = snippet.getCode({ variableName: namespace, protocol, entry, backendType, }); if (code === null) { return null; } const Icon = snippet.icon; return ( addCodeToNewCell(code)} > {snippet.label} ); })}
{isDir && effectiveExpanded && ( )} ); }; const StorageNamespaceSection: React.FC<{ namespace: StorageNamespace; locale: string; searchValue: string; onOpenFile: (info: OpenFileInfo) => void; }> = ({ namespace, locale, searchValue, onOpenFile }) => { const [isExpanded, setIsExpanded] = useState(true); const { entriesByPath } = useStorage(); const { clearNamespaceCache } = useStorageActions(); const namespaceName = namespace.name ?? namespace.displayName; const { entries: fetchedEntries, isPending, error, refetch, } = useStorageEntries(namespaceName); const handleRefresh = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); clearNamespaceCache(namespaceName); refetch(); }, [namespaceName, clearNamespaceCache, refetch], ); // While loading, fall back to initial entries from the namespace notification const entries = isPending ? namespace.storageEntries : fetchedEntries; const filtered = filterEntries( entries, namespaceName, searchValue, entriesByPath, ); return ( <> setIsExpanded(!isExpanded)} className="flex flex-row font-semibold h-7 text-xs gap-1.5 bg-(--slate-2) text-muted-foreground rounded-none" > {namespace.displayName} {namespace.name && ( () )} {namespace.rootPath || "(root)"} {isExpanded && ( <> {isPending && entries.length === 0 && (
Loading...
)} {error && entries.length === 0 && ( )} {!isPending && entries.length === 0 && !error && (
No entries
)} {searchValue && filtered.length === 0 && entries.length > 0 && (
No matches
)} {filtered.map((entry) => ( ))} )} ); }; export const StorageInspector: React.FC = () => { const { namespaces } = useStorage(); const { locale } = useLocale(); const [searchValue, setSearchValue] = useState(""); const [openFile, setOpenFile] = useState(null); const hasSearch = !!searchValue.trim(); if (namespaces.length === 0) { return ( Create an obstore or fsspec connection in your notebook. See the{" "} docs . } action={ } icon={} /> ); } return (
{openFile && ( setOpenFile(null)} /> )}
); };