/* Copyright 2026 Marimo. All rights reserved. */ import { keymap } from "@codemirror/view"; import { useAtomValue } from "jotai"; import { AlertTriangleIcon, CopyIcon, ExternalLinkIcon, SaveIcon, } from "lucide-react"; import type React from "react"; import { useEffect, useRef, useState } from "react"; import { renderShortcut } from "@/components/shortcuts/renderShortcut"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Tooltip } from "@/components/ui/tooltip"; import { disableFileDownloadsAtom, hotkeysAtom } from "@/core/config/config"; import { useRequestClient } from "@/core/network/requests"; import type { FileInfo } from "@/core/network/types"; import { filenameAtom } from "@/core/saving/file-state"; 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 { copyToClipboard } from "@/utils/copy"; import { downloadBlob, downloadByURL } from "@/utils/download"; import { type Base64String, base64ToDataURL } from "@/utils/json/base64"; import { FilePreviewHeader } from "./file-header"; import { FileContentRenderer, isMediaMime, MIME_TO_LANGUAGE, } from "./renderers"; interface Props { file: FileInfo; onOpenNotebook: ( evt: Pick, ) => void; } const unsavedContentsForFile = new Map(); export const FileViewer: React.FC = ({ file, onOpenNotebook }) => { const { sendFileDetails, sendUpdateFile } = useRequestClient(); const hotkeys = useAtomValue(hotkeysAtom); const disableFileDownloads = useAtomValue(disableFileDownloadsAtom); const currentNotebookFilename = useAtomValue(filenameAtom); // undefined value means not modified yet const [internalValue, setInternalValue] = useState(""); const { data, isPending, error, setData, refetch } = useAsyncData(async () => { const details = await sendFileDetails({ path: file.path }); const contents = details.contents || ""; setInternalValue(unsavedContentsForFile.get(file.path) || contents); return details; }, [file.path]); const handleSaveFile = async () => { if (internalValue === data?.contents) { return; } await sendUpdateFile({ path: file.path, contents: internalValue }).then( (response) => { if (response.success) { // Update the last saved value setData((prev) => ({ ...prev, contents: internalValue })); setInternalValue(internalValue); } }, ); }; // On file change or unmount, save the unsaved contents // We use a ref for internalValue so we don't call this effect on each keystroke const internalValueRef = useRef(internalValue); internalValueRef.current = internalValue; useEffect(() => { return () => { if (!data?.contents) { return; } const draft = internalValueRef.current; if (draft === data.contents) { unsavedContentsForFile.delete(file.path); } else { unsavedContentsForFile.set(file.path, draft); } }; }, [file.path, data?.contents]); if (error) { return ; } if (isPending || !data) { return null; } const mimeType = data.mimeType || "text/plain"; const isEditable = mimeType in MIME_TO_LANGUAGE; const isActiveNotebook = currentNotebookFilename && data.file.isMarimoFile && (file.path === currentNotebookFilename || // This may capture other notebook files in subdirectories // but this is an okay heuristic for now. file.path.endsWith(`/${currentNotebookFilename}`)); if (!data.contents && !isEditable) { // Show details instead of contents return (
Name
{data.file.name}
Type
{mimeType}
); } const handleDownload = () => { if (data.isBase64 && data.contents) { if (isMediaMime(mimeType)) { const dataURL = base64ToDataURL( data.contents as Base64String, mimeType, ); downloadByURL(dataURL, data.file.name); } else { const blob = deserializeBlob( base64ToDataURL( data.contents as Base64String, data.mimeType || "application/octet-stream", ), ); downloadBlob(blob, data.file.name); } return; } downloadBlob( new Blob([data.contents || internalValue], { type: mimeType }), data.file.name, ); }; const header = ( {file.isMarimoFile && !isWasm() && ( )} {!isMediaMime(mimeType) && ( <> )} } /> ); const isMedia = isMediaMime(mimeType); const isText = !isMedia && mimeType !== "text/csv"; const warningBanner = isText && isActiveNotebook && ( Editing the notebook file directly while running in marimo's editor may cause unintended changes. Please use with caution. ); return ( <> {header} {warningBanner} { if (internalValue !== data.contents) { handleSaveFile(); return true; } return false; }, }, ]), ]} /> ); };