/* Copyright 2026 Marimo. All rights reserved. */ import { CommandList } from "cmdk"; import { CopyIcon, DownloadIcon, FolderIcon, HardDriveIcon, HelpCircleIcon, LoaderCircle, 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 { AddConnectionButton } from "@/components/editor/connections/add-connection-button"; 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, useStoragePageFetcher, } from "@/core/storage/state"; import type { StorageEntry, StorageNamespace, StoragePageMetadata, 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; } function directoryPrefix(path: string): string { return path.endsWith("/") ? path : `${path}/`; } /** * Stable, unique identity for an entry row. Prefer the * backend's stable id when present and fall back to the list index */ export function storageEntryKey(entry: StorageEntry, index: number): string { const id = entry.metadata?.id; if (typeof id === "string" && id.length > 0) { return id; } return `${entry.path}::${index}`; } interface SearchContext { namespace: string; searchValue: string; entriesByPath: ReadonlyMap; } /** * Recursively check whether an entry (or any of its loaded descendants) * matches the search query. */ function entryMatchesSearch( entry: StorageEntry, { namespace, searchValue, entriesByPath }: SearchContext, ): boolean { const query = searchValue.trim().toLowerCase(); const path = entry.path.toLowerCase(); const name = displayName(entry.path).toLowerCase(); if (name.includes(query) || path.includes(query)) { return true; } // For directories, check loaded children recursively if (entry.kind === "directory") { const children = entriesByPath.get( storagePathKey(namespace, directoryPrefix(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. */ export function filterEntries( entries: StorageEntry[], context: SearchContext, ): StorageEntry[] { if (!context.searchValue.trim()) { return entries; } return entries.filter((entry) => entryMatchesSearch(entry, context)); } const MAX_REMOTE_SEARCH_PAGES = 5; type RemoteSearchState = | { query: string; status: "idle" } | { query: string; status: "searching" } | { query: string; status: "found" } | { query: string; status: "exhausted" } | { query: string; status: "capped" } | { query: string; status: "error"; error: Error }; type RemoteSearchByNamespace = Record; function idleRemoteSearch(query: string): RemoteSearchState { return { query, status: "idle" }; } function canRetryRemoteSearch(remoteSearch: RemoteSearchState): boolean { return ( remoteSearch.status === "idle" || remoteSearch.status === "error" || remoteSearch.status === "capped" ); } /** * Whether the backend may still have an unfetched page for a prefix: either we * have never listed it, or its last listing returned a next-page token. */ function hasUnfetchedPrefixPage( searchKey: StoragePathKey, entriesByPath: ReadonlyMap, pageMetadataByPath: ReadonlyMap, ): boolean { return ( entriesByPath.get(searchKey) === undefined || pageMetadataByPath.get(searchKey)?.nextPageToken != null ); } function canSearchMoreRemoteEntries({ hasSearch, hasLoadedMatches, isPending, remoteSearch, searchKey, entriesByPath, pageMetadataByPath, }: { hasSearch: boolean; hasLoadedMatches: boolean; isPending: boolean; remoteSearch: RemoteSearchState; searchKey: StoragePathKey; entriesByPath: ReadonlyMap; pageMetadataByPath: ReadonlyMap; }): boolean { if (!hasSearch || hasLoadedMatches || isPending) { return false; } if (!canRetryRemoteSearch(remoteSearch)) { return false; } return hasUnfetchedPrefixPage(searchKey, entriesByPath, pageMetadataByPath); } /** * Returns the directory prefix to query the backend with for a given search. * * Object stores like obstore evaluate prefixes on a path-segment basis * (`folder/x` would only match `folder/x/...`, never `folder/xsomething`), so * for substring searches we list the parent directory and filter on the * client. Returns `""` when the search has no directory component. */ export function remoteSearchPrefix(searchValue: string): string { const trimmed = searchValue.trim(); const lastSlash = trimmed.lastIndexOf("/"); return lastSlash === -1 ? "" : trimmed.slice(0, lastSlash + 1); } /** * Shallow check (no recursion into loaded children) used inside the * remote-search pagination loop to decide whether a fetched page has * any candidates worth surfacing to the user. */ function entryMatchesQueryShallow( entry: StorageEntry, searchValue: string, ): boolean { const query = searchValue.trim().toLowerCase(); if (!query) { return true; } return ( entry.path.toLowerCase().includes(query) || displayName(entry.path).toLowerCase().includes(query) ); } const LoadMoreStorageEntries: React.FC<{ depth: number; isLoading: boolean; error?: Error; onLoadMore: () => void; }> = ({ depth, isLoading, error, onLoadMore }) => { return (
{error && ( Failed to load: {error.message} )}
); }; /** * 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, hasMore, loadMore, isLoadingMore, loadMoreError, } = 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 rowKey = storageEntryKey(child, children.indexOf(child)); return ( ); })} {hasMore && ( )} ); }; const StorageEntryRow: React.FC<{ entry: StorageEntry; rowKey: string; namespace: string; protocol: string; rootPath: string; backendType: StorageNamespace["backendType"]; depth: number; locale: string; searchValue: string; onOpenFile: (info: OpenFileInfo) => void; }> = ({ entry, rowKey, 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, directoryPrefix(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; remoteSearch: RemoteSearchState; onContinueRemoteSearch: () => void; onOpenFile: (info: OpenFileInfo) => void; }> = ({ namespace, locale, searchValue, remoteSearch, onContinueRemoteSearch, onOpenFile, }) => { const [isExpanded, setIsExpanded] = useState(true); const { entriesByPath, pageMetadataByPath } = useStorage(); const { clearNamespaceCache } = useStorageActions(); const namespaceName = namespace.name ?? namespace.displayName; const { entries: fetchedEntries, isPending, error, hasMore, loadMore, isLoadingMore, loadMoreError, 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, { namespace: namespaceName, searchValue, entriesByPath, }); const searchPrefix = remoteSearchPrefix(searchValue); const searchKey = storagePathKey(namespaceName, searchPrefix); const remoteEntries = searchPrefix === "" ? [] : (entriesByPath.get(searchKey) ?? []); // The fetched page is the whole parent directory; we still need to filter // it by the full search query before showing entries to the user. const filteredRemoteEntries = filterEntries(remoteEntries, { namespace: namespaceName, searchValue, entriesByPath, }); const hasSearch = !!searchValue.trim(); const hasLoadedMatches = filtered.length > 0 || filteredRemoteEntries.length > 0; const canSearchMore = searchPrefix !== "" && canSearchMoreRemoteEntries({ hasSearch, hasLoadedMatches, isPending, remoteSearch, searchKey, entriesByPath, pageMetadataByPath, }); const showRemoteResults = hasSearch && filtered.length === 0; const statusRow = (() => { if (isPending && entries.length === 0) { return ( Loading... ); } if (remoteSearch.status === "searching") { return ( Searching more entries... ); } if (remoteSearch.status === "error") { return ( Search failed: {remoteSearch.error.message} ); } if (remoteSearch.status === "capped") { return ( Searched more entries. (or press Enter) ); } if (remoteSearch.status === "exhausted" && !hasLoadedMatches) { return "No matches"; } if (!hasSearch && !isPending && entries.length === 0) { return "No entries"; } if (canSearchMore) { return ( No loaded matches. (or press Enter) ); } if (hasSearch && !hasLoadedMatches && entries.length > 0) { return "No matches"; } return null; })(); 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 && ( <> {error && entries.length === 0 && ( )} {!error && statusRow && (
{statusRow}
)} {filtered.map((entry) => { const rowKey = storageEntryKey(entry, entries.indexOf(entry)); return ( ); })} {showRemoteResults && filteredRemoteEntries.map((entry) => { const rowKey = storageEntryKey( entry, remoteEntries.indexOf(entry), ); return ( ); })} {hasMore && !canSearchMore && ( )} )} ); }; export const StorageInspector: React.FC = () => { const { namespaces, entriesByPath, pageMetadataByPath } = useStorage(); const { locale } = useLocale(); const [searchValue, setSearchValue] = useState(""); const [remoteSearchByNamespace, setRemoteSearchByNamespace] = useState({}); const [openFile, setOpenFile] = useState(null); const fetchStoragePage = useStoragePageFetcher(); const hasSearch = !!searchValue.trim(); const currentQuery = searchValue.trim(); const remoteSearchForNamespace = useCallback( (namespaceName: string): RemoteSearchState => { const remoteSearch = remoteSearchByNamespace[namespaceName]; if (remoteSearch?.query === currentQuery) { return remoteSearch; } return idleRemoteSearch(currentQuery); }, [currentQuery, remoteSearchByNamespace], ); const setRemoteSearch = useCallback( (namespaceName: string, remoteSearch: RemoteSearchState) => { setRemoteSearchByNamespace((state) => ({ ...state, [namespaceName]: remoteSearch, })); }, [], ); const canContinueRemoteSearch = useCallback( (namespace: StorageNamespace): boolean => { if (!currentQuery) { return false; } const namespaceName = namespace.name ?? namespace.displayName; const searchPrefix = remoteSearchPrefix(currentQuery); // No directory component in the query - the user is doing a fuzzy // search and the backend can't help; rely on local filtering instead. if (searchPrefix === "") { return false; } const remoteSearch = remoteSearchForNamespace(namespaceName); if (!canRetryRemoteSearch(remoteSearch)) { return false; } // Already surfacing matches from loaded entries? const rootEntries = entriesByPath.get(storagePathKey(namespaceName, "")) ?? namespace.storageEntries; const rootMatches = filterEntries(rootEntries, { namespace: namespaceName, searchValue: currentQuery, entriesByPath, }); if (rootMatches.length > 0) { return false; } const searchKey = storagePathKey(namespaceName, searchPrefix); const prefixEntries = entriesByPath.get(searchKey) ?? []; const prefixMatches = filterEntries(prefixEntries, { namespace: namespaceName, searchValue: currentQuery, entriesByPath, }); if (prefixMatches.length > 0) { return false; } return hasUnfetchedPrefixPage( searchKey, entriesByPath, pageMetadataByPath, ); }, [currentQuery, entriesByPath, pageMetadataByPath, remoteSearchForNamespace], ); const continueRemoteSearch = useCallback( async (namespace: StorageNamespace) => { if (!canContinueRemoteSearch(namespace)) { return; } const query = currentQuery; const namespaceName = namespace.name ?? namespace.displayName; const prefix = remoteSearchPrefix(query); const key = storagePathKey(namespaceName, prefix); const cachedEntries = entriesByPath.get(key); let nextPageToken = pageMetadataByPath.get(key)?.nextPageToken ?? null; let hasFetchedAny = cachedEntries !== undefined; setRemoteSearch(namespaceName, { query, status: "searching" }); try { for (let page = 0; page < MAX_REMOTE_SEARCH_PAGES; page++) { // First iteration with a stale cache hit needs no fetch; just check // the cached page before paginating. const shouldFetch = !hasFetchedAny || nextPageToken !== null; let newEntries: StorageEntry[] = []; if (shouldFetch) { const result = await fetchStoragePage({ namespace: namespaceName, prefix, pageToken: nextPageToken, append: hasFetchedAny, }); newEntries = result.entries; nextPageToken = result.next_page_token ?? null; hasFetchedAny = true; } const entriesToCheck = shouldFetch ? newEntries : (cachedEntries ?? []); const hasMatches = entriesToCheck.some((entry) => entryMatchesQueryShallow(entry, query), ); if (hasMatches) { setRemoteSearch(namespaceName, { query, status: "found" }); return; } if (nextPageToken === null) { setRemoteSearch(namespaceName, { query, status: "exhausted" }); return; } } setRemoteSearch(namespaceName, { query, status: "capped" }); } catch (error) { setRemoteSearch(namespaceName, { query, status: "error", error: error instanceof Error ? error : new Error(String(error)), }); } }, [ canContinueRemoteSearch, currentQuery, entriesByPath, fetchStoragePage, pageMetadataByPath, setRemoteSearch, ], ); const continueRemoteSearches = useCallback(() => { const searchableNamespaces = namespaces.filter(canContinueRemoteSearch); for (const namespace of searchableNamespaces) { void continueRemoteSearch(namespace); } }, [canContinueRemoteSearch, continueRemoteSearch, namespaces]); if (namespaces.length === 0) { return ( Create an obstore or fsspec connection in your notebook. See the{" "} docs . } action={ } icon={} /> ); } return (
{openFile && ( setOpenFile(null)} /> )}
); };