From c087e11b34bfc3e6b12206bfe3554f407df14f70 Mon Sep 17 00:00:00 2001 From: ithun-y-ittesaf Date: Sun, 2 Aug 2026 02:32:25 +0600 Subject: [PATCH] feat: add read-only .ipynb notebook viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `.ipynb` file kind and viewer instead of routing notebooks to the unsupported-file fallback. Markdown/code cells render through the shared Markdown component; outputs handle stream/error/image/text MIME types. Read-only — no cell editing or kernel execution. Closes #33 --- .../layout/components/center-panel.tsx | 5 + src/features/layout/stores/layout-store.ts | 10 +- .../notebook/components/notebook-viewer.tsx | 216 ++++++++++++++++++ src/features/notebook/lib/notebook-types.ts | 61 +++++ src/lib/constants.ts | 1 + src/lib/file-types.ts | 7 +- src/lib/open-file.ts | 5 +- 7 files changed, 299 insertions(+), 6 deletions(-) create mode 100644 src/features/notebook/components/notebook-viewer.tsx create mode 100644 src/features/notebook/lib/notebook-types.ts diff --git a/src/features/layout/components/center-panel.tsx b/src/features/layout/components/center-panel.tsx index e79a8f8a..37333f11 100644 --- a/src/features/layout/components/center-panel.tsx +++ b/src/features/layout/components/center-panel.tsx @@ -21,6 +21,7 @@ const BrowserPanel = lazy(() => import("@/features/browser/components/browser-pa const MediaViewer = lazy(() => import("@/features/media/components/media-viewer").then(m => ({ default: m.MediaViewer }))); const SvgViewer = lazy(() => import("@/features/svg/components/svg-viewer").then(m => ({ default: m.SvgViewer }))); const PdfViewer = lazy(() => import("@/features/pdf/components/pdf-viewer").then(m => ({ default: m.PdfViewer }))); +const NotebookViewer = lazy(() => import("@/features/notebook/components/notebook-viewer").then(m => ({ default: m.NotebookViewer }))); const GitDiffPanel = lazy(() => import("@/features/git/components/git-diff-panel").then(m => ({ default: m.GitDiffPanel }))); const CanvasPanel = lazy(() => import("@/features/canvas/components/canvas-panel").then(m => ({ default: m.CanvasPanel }))); const KnowledgePanel = lazy(() => import("@/features/knowledge/components/knowledge-panel").then(m => ({ default: m.KnowledgePanel }))); @@ -62,6 +63,7 @@ import { Columns2, LayoutDashboard, Layers, + NotebookText, } from "lucide-react"; import type { TabType } from "@/lib/constants"; @@ -83,6 +85,7 @@ const tabIcons: Record = { media: Code, svg: Code, pdf: FileText, + notebook: NotebookText, unsupported: Code, pomodoro: Timer, "mission-control": LayoutDashboard, @@ -576,6 +579,8 @@ function TabContent({ tab }: { tab: Tab }) { return ; case "pdf": return ; + case "notebook": + return ; case "diff": return ( set((s) => { // chat: each session is its own tab. File-backed viewers (editor / - // diff / media / svg / pdf / unsupported) are one-per-FILE (deduped - // by id, which is `${type}:${path}`) so opening a different file - // always gets its own tab. Everything else is a singleton PER COLUMN - // (focus the existing instance in the target column, else open one). + // diff / media / svg / pdf / notebook / unsupported) are one-per-FILE + // (deduped by id, which is `${type}:${path}`) so opening a different + // file always gets its own tab. Everything else is a singleton PER + // COLUMN (focus the existing instance in the target column, else open + // one). const allowMultiple = tab.type === "editor" || tab.type === "diff" || @@ -423,6 +424,7 @@ export const useLayoutStore = createSelectors( tab.type === "media" || tab.type === "svg" || tab.type === "pdf" || + tab.type === "notebook" || tab.type === "unsupported"; let targetId = tab.id; diff --git a/src/features/notebook/components/notebook-viewer.tsx b/src/features/notebook/components/notebook-viewer.tsx new file mode 100644 index 00000000..7151c4ab --- /dev/null +++ b/src/features/notebook/components/notebook-viewer.tsx @@ -0,0 +1,216 @@ +import { useEffect, useMemo, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { Loader2, Play } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Markdown } from "@/lib/markdown"; +import { + joinSource, + notebookLanguage, + stripAnsi, + type NotebookCell, + type NotebookFile, + type NotebookOutput, +} from "../lib/notebook-types"; + +interface NotebookViewerProps { + filePath: string; +} + +type LoadState = + | { status: "loading" } + | { status: "error"; message: string } + | { status: "ready"; notebook: NotebookFile }; + +/** + * `.ipynb` tab handler. Read-only render of nbformat v4: markdown cells go + * through the shared `Markdown` renderer, code cells are rendered as a fenced + * block (same renderer, borrows its highlight.js styling) with an execution + * count gutter, and outputs are rendered per MIME type. Not a Jupyter + * client — nothing here executes; it's a viewer for the notebook as saved. + */ +export function NotebookViewer({ filePath }: NotebookViewerProps) { + const [state, setState] = useState({ status: "loading" }); + + useEffect(() => { + let cancelled = false; + setState({ status: "loading" }); + invoke("read_file_content", { path: filePath }) + .then((text) => { + if (cancelled) return; + try { + const notebook = JSON.parse(text) as NotebookFile; + if (!Array.isArray(notebook.cells)) { + throw new Error('missing a top-level "cells" array'); + } + setState({ status: "ready", notebook }); + } catch (err) { + setState({ + status: "error", + message: err instanceof Error ? err.message : String(err), + }); + } + }) + .catch((err) => { + if (!cancelled) { + setState({ + status: "error", + message: err instanceof Error ? err.message : String(err), + }); + } + }); + return () => { + cancelled = true; + }; + }, [filePath]); + + return ( +
+
+ {filePath} +
+
+ {state.status === "loading" ? ( +
+ +
+ ) : state.status === "error" ? ( +
+ Couldn't parse this notebook: {state.message} +
+ ) : ( + + )} +
+
+ ); +} + +function NotebookBody({ notebook }: { notebook: NotebookFile }) { + const language = useMemo(() => notebookLanguage(notebook), [notebook]); + + if (notebook.cells.length === 0) { + return ( +
+ Empty notebook +
+ ); + } + + return ( +
+ {notebook.cells.map((cell, i) => ( + + ))} +
+ ); +} + +function NotebookCellView({ + cell, + language, +}: { + cell: NotebookCell; + language: string; +}) { + const source = joinSource(cell.source); + + if (cell.cell_type === "markdown") { + return source.trim() ? ( +
+ {source} +
+ ) : null; + } + + if (cell.cell_type === "raw") { + return ( +
+        {source}
+      
+ ); + } + + // code cell + const count = cell.execution_count; + return ( +
+
+ {count != null ? ( + `[${count}]` + ) : ( + + )} +
+
+ {source.trim() && ( + {"```" + language + "\n" + source + "\n```"} + )} + {cell.outputs?.map((out, i) => ( + + ))} +
+
+ ); +} + +function NotebookOutputView({ output }: { output: NotebookOutput }) { + if (output.output_type === "stream") { + return ( +
+        {joinSource(output.text)}
+      
+ ); + } + + if (output.output_type === "error") { + const trace = (output.traceback ?? []).map(stripAnsi).join("\n"); + return ( +
+        {trace || `${output.ename}: ${output.evalue}`}
+      
+ ); + } + + // execute_result / display_data — pick the richest MIME type we can safely + // render. Raw text/html is intentionally NOT rendered: nbformat outputs are + // static data embedded in the file (not re-executed on open), so dumping + // them into the DOM via dangerouslySetInnerHTML without a sanitizer would + // let a malicious notebook run script/event-handler payloads just by being + // viewed. + const data = output.data ?? {}; + const imageMime = Object.keys(data).find((m) => m.startsWith("image/")); + if (imageMime) { + const raw = joinSource(data[imageMime]).replace(/\n/g, ""); + return ( + Cell output + ); + } + if (data["text/plain"]) { + return ( +
+        {joinSource(data["text/plain"])}
+      
+ ); + } + const anyMime = Object.keys(data)[0]; + if (anyMime) { + return ( +
+ Output type {anyMime} isn't rendered + in Atlas yet. +
+ ); + } + return null; +} diff --git a/src/features/notebook/lib/notebook-types.ts b/src/features/notebook/lib/notebook-types.ts new file mode 100644 index 00000000..f306be38 --- /dev/null +++ b/src/features/notebook/lib/notebook-types.ts @@ -0,0 +1,61 @@ +// nbformat v4 types — only the fields the viewer reads. Full spec: +// https://nbformat.readthedocs.io/en/latest/format_description.html + +export interface NotebookOutput { + output_type: "stream" | "execute_result" | "display_data" | "error"; + // stream + name?: "stdout" | "stderr"; + text?: string | string[]; + // execute_result / display_data + data?: Record; + execution_count?: number | null; + // error + ename?: string; + evalue?: string; + traceback?: string[]; +} + +export interface NotebookCell { + cell_type: "code" | "markdown" | "raw"; + source: string | string[]; + outputs?: NotebookOutput[]; + execution_count?: number | null; +} + +export interface NotebookFile { + cells: NotebookCell[]; + metadata?: { + kernelspec?: { language?: string; name?: string }; + language_info?: { name?: string }; + }; + nbformat?: number; + nbformat_minor?: number; +} + +/** nbformat allows `source`/`text`/`traceback` as either a single string or an + * array of lines (no trailing newlines between entries) — normalize both. */ +export function joinSource(src: string | string[] | undefined): string { + if (src === undefined) return ""; + return Array.isArray(src) ? src.join("") : src; +} + +/** Best-effort language id for syntax highlighting, mapped to a highlight.js + * grammar name. Falls back to "python" (the overwhelmingly common case) so + * code cells still get *some* highlighting rather than none. */ +export function notebookLanguage(nb: NotebookFile): string { + const raw = + nb.metadata?.language_info?.name ?? + nb.metadata?.kernelspec?.language ?? + "python"; + const lower = raw.toLowerCase(); + if (lower.startsWith("python")) return "python"; + return lower; +} + +const ANSI_ESCAPE_RE = /\x1b\[[0-9;]*m/g; + +/** Strip ANSI color codes Jupyter kernels embed in tracebacks — Atlas has no + * terminal-style ANSI renderer in this view, so keep the text plain. */ +export function stripAnsi(s: string): string { + return s.replace(ANSI_ESCAPE_RE, ""); +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 88f9c64a..30cdabf1 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -16,6 +16,7 @@ export const TAB_TYPES = [ "media", "svg", "pdf", + "notebook", "unsupported", "pomodoro", "mission-control", diff --git a/src/lib/file-types.ts b/src/lib/file-types.ts index 4cbbba42..2fa4f9c2 100644 --- a/src/lib/file-types.ts +++ b/src/lib/file-types.ts @@ -8,7 +8,7 @@ * binary bytes into CodeMirror. */ -export type FileKind = "text" | "image" | "svg" | "video" | "audio" | "pdf" | "unsupported"; +export type FileKind = "text" | "image" | "svg" | "video" | "audio" | "pdf" | "notebook" | "unsupported"; // Extensions the CodeMirror editor handles (or can usefully attempt — even // without a language extension, plaintext display is fine). @@ -50,6 +50,10 @@ const AUDIO_EXTS = new Set([ const PDF_EXTS = new Set(["pdf"]); +// Jupyter notebooks are JSON but need cell-aware rendering (markdown/code +// cells + outputs), not raw JSON in CodeMirror, so they get their own kind. +const NOTEBOOK_EXTS = new Set(["ipynb"]); + // SVG is both renderable (it's an image) AND text (copyable source), so it gets // its own viewer instead of the code editor or the raster image viewer. const SVG_EXTS = new Set(["svg"]); @@ -79,6 +83,7 @@ export function classifyFile(path: string): FileKind { if (VIDEO_EXTS.has(ext)) return "video"; if (AUDIO_EXTS.has(ext)) return "audio"; if (PDF_EXTS.has(ext)) return "pdf"; + if (NOTEBOOK_EXTS.has(ext)) return "notebook"; } // Extensionless files like LICENSE, Makefile, Dockerfile. if (!ext && EXTENSIONLESS_TEXT_NAMES.has(base)) return "text"; diff --git a/src/lib/open-file.ts b/src/lib/open-file.ts index f8c23a29..dd208837 100644 --- a/src/lib/open-file.ts +++ b/src/lib/open-file.ts @@ -62,10 +62,13 @@ export async function openFileOrReveal(path: string): Promise { openWithKind(path, kind); } -function tabTypeFor(kind: FileKind): "editor" | "media" | "svg" | "pdf" | "unsupported" { +function tabTypeFor( + kind: FileKind, +): "editor" | "media" | "svg" | "pdf" | "notebook" | "unsupported" { if (kind === "text") return "editor"; if (kind === "image" || kind === "video" || kind === "audio") return "media"; if (kind === "svg") return "svg"; if (kind === "pdf") return "pdf"; + if (kind === "notebook") return "notebook"; return "unsupported"; }