import { useCallback, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, type CSSProperties, } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import type { NotebookCell } from "@/bindings"; import { tx } from "@/platform/i18n"; import { useMarkdownPreviewFontSize } from "@/platform/languageSettings"; import { useProject } from "@/platform/project"; import { Button, Dialog, EmptyState, IconButton, Select, Spinner } from "@/ui"; import type { EditorInput } from "@/workbench/contributions"; import { CellEditor } from "./CellEditor"; import { CellOutputs, MarkdownCell } from "./OutputView"; import { notebookSession, type NotebookSession } from "./session"; import styles from "./Notebook.module.css"; export function NotebookEditor({ input }: { input: EditorInput }) { if (!input.path) return ; return ; } function NotebookDocument({ session }: { session: NotebookSession }) { const state = useSyncExternalStore(session.subscribe, session.getState); const root = useRef(null); const scroll = useRef(null); const [selected, select] = useState(session.selectedCell); const [reloadOpen, setReloadOpen] = useState(false); const cells = session.cells(); const active = cells.find((cell) => cell.id === selected) ?? cells.find((cell) => cell.cellType === "code"); const invoke = useCallback( (work: Promise) => { void work.catch((error) => session.report(error)); }, [session], ); const choose = (id: string) => { session.selectedCell = id; select(id); }; const virtualizer = useVirtualizer({ count: cells.length, getScrollElement: () => scroll.current, getItemKey: (index) => cells[index]!.id, estimateSize: () => 220, overscan: 3, initialOffset: session.scrollTop, }); useEffect(() => { invoke(session.load()); }, [session, invoke]); useLayoutEffect(() => { const element = root.current; if (!element) return; const history = (event: InputEvent) => { if (event.inputType !== "historyUndo" && event.inputType !== "historyRedo") return; event.preventDefault(); event.stopImmediatePropagation(); invoke(event.inputType === "historyUndo" ? session.undo() : session.redo()); }; element.addEventListener("beforeinput", history, true); return () => element.removeEventListener("beforeinput", history, true); }, [session, invoke]); // Markdown cells follow the Markdown preview size, code cells and outputs the editor font, not the UI font. const markdownFontSize = useMarkdownPreviewFontSize(); const editor = useProject((s) => s.settings?.editor as { fontSize?: number; lineHeight?: number } | undefined); const fontVars = { "--markdown-preview-font-size": `${markdownFontSize}px`, ...(editor?.fontSize ? { "--openidea-editor-font-size": `${editor.fontSize}px` } : {}), ...(editor?.lineHeight ? { "--openidea-editor-line-height": `${editor.lineHeight}px` } : {}), } as CSSProperties; const disabled = state.frozen || !state.snapshot; const historyDisabled = disabled || state.running; const options = state.environments.map((sdk) => ({ value: sdk.path, label: sdk.name, detail: sdk.path })); if (state.python && !options.some((option) => option.value === state.python)) options.unshift({ value: state.python, label: state.python, detail: state.python, }); return ( { if (event.nativeEvent.isComposing || event.defaultPrevented) return; if (event.metaKey || event.ctrlKey) { const key = event.key.toLowerCase(); if (key === "s") { event.preventDefault(); event.stopPropagation(); invoke(session.save()); } if (key === "z" || key === "y") { event.preventDefault(); event.stopPropagation(); invoke(event.shiftKey || key === "y" ? session.redo() : session.undo()); } } if (event.key === "Enter" && (event.shiftKey || event.ctrlKey) && active?.cellType === "code") { event.preventDefault(); event.stopPropagation(); invoke(session.execute([active.id])); } }} > active && invoke(session.execute([active.id]))} > Run Cell cell.cellType === "code")} onClick={() => invoke(session.execute())} > Run All invoke(session.interrupt())} /> invoke(session.restart())} /> session.selectPython(value)} /> invoke(session.detectEnvironments())} /> {state.running && } {tx(state.running ? "busy" : state.kernel)} invoke(session.undo())} /> invoke(session.redo())} /> invoke(session.save())} /> setReloadOpen(true)} /> {state.error && ( {state.error} {/ipykernel/i.test(state.error) && ( {tx( "Install ipykernel in the selected Python environment, then restart the kernel. No packages are installed automatically.", )} )} {state.pending > 0 && ( {tx("Uncommitted source edits")} {session.pendingSources().map(({ cell, source }) => ( {source} ))} )} {state.pending > 0 && ( invoke(session.flush())}> Retry Pending Edits )} {!state.snapshot && ( invoke(session.load())}> Retry )} )} {state.loading && !state.snapshot ? ( ) : !state.snapshot ? ( {tx("Notebook editing and execution require the desktop app.")} ) : ( { session.scrollTop = event.currentTarget.scrollTop; }} > {cells.length === 0 ? ( session.edit({ type: "insertCell", after: null, cellType: "code" })} > Add Code Cell } /> ) : ( {virtualizer.getVirtualItems().map((item) => { const cell = cells[item.index]!; return ( choose(cell.id)} /> ); })} )} session.edit({ type: "insertCell", after: cells.at(-1)?.id ?? null, cellType: "code" })} > Add Code Cell session.edit({ type: "insertCell", after: cells.at(-1)?.id ?? null, cellType: "markdown" }) } > Add Markdown Cell )} setReloadOpen(false)} title={tx("Reload Notebook from Disk")} icon="warning" buttons={[ { label: "Cancel", role: "cancel" }, { label: "Discard Changes and Reload", role: "default", onClick: () => { setReloadOpen(false); invoke(session.discard().finally(() => session.cancelClose())); }, }, ]} > {tx("Discard all unsaved notebook changes and stop execution? This cannot be undone.")} ); } function NotebookCellView({ session, cell, index, cells, selected, frozen, running, onSelect, }: { session: NotebookSession; cell: NotebookCell; index: number; cells: NotebookCell[]; selected: boolean; frozen: boolean; running: boolean; onSelect: () => void; }) { const [editing, setEditing] = useState(session.markdownEditing.has(cell.id)); const known = cell.cellType === "code" || cell.cellType === "markdown"; const structuralDisabled = frozen || running; return ( {cell.cellType === "code" ? `[${cell.executionCount ?? " "}]` : `${index + 1}`} session.edit({ type: "setCellType", cell: cell.id, cellType: value })} /> {cell.cellType === "code" && ( { void session.execute([cell.id]).catch((error) => session.report(error)); }} /> )} {cell.cellType === "markdown" && ( { if (editing) session.markdownEditing.delete(cell.id); else session.markdownEditing.add(cell.id); setEditing(!editing); }} /> )} session.edit({ type: "moveCell", cell: cell.id, before: cells[index - 1]!.id })} /> session.edit({ type: "moveCell", cell: cell.id, before: cells[index + 2]?.id ?? null })} /> session.edit({ type: "insertCell", after: cell.id, cellType: "code" })} /> session.edit({ type: "deleteCell", cell: cell.id })} /> {!known ? ( {tx("Raw or unknown cell; original content is preserved read-only.")} {cell.source} ) : cell.cellType === "markdown" && !editing ? ( { if (!frozen) { session.markdownEditing.add(cell.id); setEditing(true); } }} > {cell.source ? ( ) : ( { session.markdownEditing.add(cell.id); setEditing(true); }} > Edit Markdown )} ) : ( )} {cell.cellType === "code" && } ); }
{source}
{tx("Raw or unknown cell; original content is preserved read-only.")}
{cell.source}