/* Copyright 2026 Marimo. All rights reserved. */ import { useAtom, useSetAtom } from "jotai"; import { BookTextIcon, ChevronDownIcon, ChevronRightIcon, ChevronsDownUpIcon, ClockIcon, ExternalLinkIcon, PlayCircleIcon, PowerOffIcon, RefreshCcwIcon, SearchIcon, } from "lucide-react"; import type React from "react"; import { Suspense, use, useEffect, useMemo, useRef, useState } from "react"; import { type NodeApi, type NodeRendererProps, Tree, type TreeApi, } from "react-arborist"; import { useLocale } from "react-aria"; import useEvent from "react-use-event-hook"; import { MarkdownIcon } from "@/components/editor/cell/code/icons"; import { FILE_ICON as FILE_TYPE_ICONS, type FileIconType as FileType, guessFileIconType as guessFileType, } from "@/components/editor/file-tree/file-icons"; import { FileNameInput } from "@/components/editor/file-tree/file-name-input"; import { DeleteMenuItem, DuplicateMenuItem, FileActionsDropdown, RenameMenuItem, useFileOperations, useNotebookFileActions, } from "@/components/editor/file-tree/file-operations"; import { useImperativeModal } from "@/components/modal/ImperativeModal"; import { AlertDialogDestructiveAction } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu"; import { Label } from "@/components/ui/label"; import { Tooltip } from "@/components/ui/tooltip"; import { toast } from "@/components/ui/use-toast"; import { getSessionId, isSessionId } from "@/core/kernel/session"; import { useRequestClient } from "@/core/network/requests"; import type { FileInfo, MarimoFile } from "@/core/network/types"; import { combineAsyncData, useAsyncData } from "@/hooks/useAsyncData"; import { useInterval } from "@/hooks/useInterval"; import { Banner } from "@/plugins/impl/common/error-banner"; import { assertExists } from "@/utils/assertExists"; import { cn } from "@/utils/cn"; import { timeAgo } from "@/utils/dates"; import { prettyError } from "@/utils/errors"; import { Maps } from "@/utils/maps"; import { Paths } from "@/utils/paths"; import { asURL } from "@/utils/url"; import { newNotebookURL } from "@/utils/urls"; import { ConfigButton } from "../app-config/app-config-button"; import { ErrorBoundary } from "../editor/boundary/ErrorBoundary"; import { ShutdownButton } from "../editor/controls/shutdown-button"; import { Header, OpenTutorialDropDown, ResourceLinks, } from "../home/components"; import { expandedFoldersAtom, includeMarkdownAtom, RunningNotebooksContext, WorkspaceContext, } from "../home/state"; import { Spinner } from "../icons/spinner"; import { Input } from "../ui/input"; function tabTarget(path: string) { // Consistent tab target so we open in the same tab when clicking on the same notebook return `${getSessionId()}-${encodeURIComponent(path)}`; } const HomePage: React.FC = () => { const [nonce, setNonce] = useState(0); const { getRecentFiles, getRunningNotebooks } = useRequestClient(); const recentsResponse = useAsyncData(() => getRecentFiles(), []); useInterval( () => { setNonce((nonce) => nonce + 1); }, // Refresh every 10 seconds, or when the document becomes visible { delayMs: 10_000, whenVisible: true }, ); const runningResponse = useAsyncData(async () => { const response = await getRunningNotebooks(); return Maps.keyBy(response.files, (file) => file.path); }, [nonce]); const response = combineAsyncData(recentsResponse, runningResponse); if (response.error) { throw response.error; } const data = response.data; if (!data) { return ; } const [recents, running] = data; return (
marimo logo Running notebooks} files={[...running.values()]} /> Recent notebooks} files={recents.files} />
); }; const WorkspaceNotebooks: React.FC<{ onRefreshRecents: () => void }> = ({ onRefreshRecents, }) => { const { getWorkspaceFiles } = useRequestClient(); const [includeMarkdown, setIncludeMarkdown] = useAtom(includeMarkdownAtom); const [searchText, setSearchText] = useState(""); const { isPending, data: workspace, error, isFetching, refetch, } = useAsyncData( () => getWorkspaceFiles({ includeMarkdown }), [includeMarkdown], ); // Fire-and-forget refresh of both the workspace tree and the "Recent // notebooks" list — file mutations on the workspace tree can affect both, // so we invalidate them together rather than having two refresh triggers. const refreshWorkspace = useEvent(() => { refetch(); onRefreshRecents(); }); const workspaceContextValue = useMemo( () => ({ root: workspace?.root ?? "", refreshWorkspace }), [workspace?.root, refreshWorkspace], ); if (isPending) { return ; } if (error) { return ( {prettyError(error)} ); } return (
{workspace.hasMore && ( Showing first {workspace.fileCount} files. Your workspace has more files. )}
} onChange={(e) => setSearchText(e.target.value)} placeholder="Search" className="mb-0 border-border" /> setIncludeMarkdown(Boolean(checked)) } />
} > Workspace {isFetching && }
); }; const CollapseAllButton: React.FC = () => { const setOpenState = useSetAtom(expandedFoldersAtom); return ( ); }; const NotebookFileTree: React.FC<{ files: FileInfo[]; searchText?: string; }> = ({ files, searchText }) => { const [openState, setOpenState] = useAtom(expandedFoldersAtom); const openStateIsEmpty = Object.keys(openState).length === 0; const ref = useRef>(undefined); const { root, refreshWorkspace } = use(WorkspaceContext); const { renameFile } = useFileOperations({ root }); useEffect(() => { // If empty, collapse all if (openStateIsEmpty) { ref.current?.closeAll(); } }, [openStateIsEmpty]); const handleRename = useEvent(async (id: string, name: string) => { const node = ref.current?.get(id); if (!node) { toast({ title: "Failed", description: `Node with id ${id} not found in the tree`, }); return; } const result = await renameFile(node.data, name); if (result) { refreshWorkspace(); } }); if (files.length === 0) { return (

No files in this workspace

); } return ( ref={ref} width="100%" height={500} searchTerm={searchText} className="h-full" idAccessor={(data) => data.path} data={files} openByDefault={false} initialOpenState={openState} onToggle={async (id) => { const prevOpen = openState[id] ?? false; setOpenState({ ...openState, [id]: !prevOpen }); }} onRename={async ({ id, name }) => { await handleRename(id, name); }} padding={5} rowHeight={35} indent={15} overscanCount={1000} // Hide the drop cursor renderCursor={() => null} // Disable interactions disableDrop={true} disableDrag={true} disableMultiSelection={true} > {Node} ); }; const Node = ({ node, style }: NodeRendererProps) => { const fileType: FileType = node.data.isDirectory ? "directory" : guessFileType(node.data.name); const Icon = FILE_TYPE_ICONS[fileType]; const iconEl = ; const { root } = use(WorkspaceContext); const { runningNotebooks } = use(RunningNotebooksContext); const renderItem = () => { const itemClassName = "flex items-center pl-1 cursor-pointer hover:bg-accent/50 hover:text-accent-foreground rounded-l flex-1 overflow-hidden h-full pr-3 gap-2"; // Inline rename input; react-arborist flips `node.isEditing` when // `node.edit()` is called from the FileActions menu. if (node.isEditing) { return (
{iconEl}
); } if (node.data.isDirectory) { return ( {iconEl} {node.data.name} ); } const relativePath = node.data.path.startsWith(root) && Paths.isAbsolute(node.data.path) ? Paths.rest(node.data.path, root) : node.data.path; const isMarkdown = relativePath.endsWith(".md") || relativePath.endsWith(".qmd"); const isRunning = runningNotebooks.has(relativePath); return ( {iconEl} {node.data.name} {isMarkdown && } {/* Trailing action slots. Using a fixed-width row here (rather than conditionally rendered inline elements) keeps every row's right edge aligned even though any individual slot may be empty. */}
); }; return (
{ evt.stopPropagation(); if (node.data.isDirectory) { node.toggle(); } }} > {renderItem()}
); }; const FileActions = ({ node, isRunning, }: { node: NodeApi; isRunning: boolean; }) => { const { root, refreshWorkspace } = use(WorkspaceContext); const { handleRename, handleDuplicate, handleDelete } = useNotebookFileActions({ node, root, onAfterChange: refreshWorkspace }); const lockedReason = isRunning ? "Stop the notebook's kernel before renaming or deleting." : undefined; return ( ); }; const FolderArrow = ({ node }: { node: NodeApi }) => { if (!node.data.isDirectory) { return ; } return node.isOpen ? ( ) : ( ); }; const NotebookList: React.FC<{ header: React.ReactNode; files: MarimoFile[]; }> = ({ header, files }) => { if (files.length === 0) { return null; } return (
{header}
{files.map((file) => { return ; })}
); }; const MarimoFileComponent = ({ file }: { file: MarimoFile }) => { const { locale } = useLocale(); // If path is a sessionId, then it has not been saved yet // We want to keep the sessionId in this case const isNewNotebook = isSessionId(file.path); const href = isNewNotebook ? asURL( `?file=${encodeURIComponent(file.initializationId ?? file.path)}&session_id=${file.path}`, ) : asURL(`?file=${encodeURIComponent(file.path)}`); const isMarkdown = file.path.endsWith(".md"); return (
{file.name} {isMarkdown && ( )}

{file.path}

{!!file.lastModified && (
{timeAgo(file.lastModified * 1000, locale)}
)}
); }; const SessionShutdownButton: React.FC<{ filePath: string }> = ({ filePath, }) => { const { openConfirm, closeModal } = useImperativeModal(); const { shutdownSession } = useRequestClient(); const { runningNotebooks, setRunningNotebooks } = use( RunningNotebooksContext, ); if (!runningNotebooks.has(filePath)) { return null; } return ( ); }; const CreateNewNotebook: React.FC = () => { const url = newNotebookURL(); return (

Create a new notebook

); }; export default HomePage;