Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/features/layout/components/center-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 })));
Expand Down Expand Up @@ -62,6 +63,7 @@ import {
Columns2,
LayoutDashboard,
Layers,
NotebookText,
} from "lucide-react";
import type { TabType } from "@/lib/constants";

Expand All @@ -83,6 +85,7 @@ const tabIcons: Record<TabType, React.ElementType> = {
media: Code,
svg: Code,
pdf: FileText,
notebook: NotebookText,
unsupported: Code,
pomodoro: Timer,
"mission-control": LayoutDashboard,
Expand Down Expand Up @@ -576,6 +579,8 @@ function TabContent({ tab }: { tab: Tab }) {
return <SvgViewer filePath={tab.data.filePath as string} />;
case "pdf":
return <PdfViewer filePath={tab.data.filePath as string} tabId={tab.id} />;
case "notebook":
return <NotebookViewer filePath={tab.data.filePath as string} />;
case "diff":
return (
<GitDiffPanel
Expand Down
10 changes: 6 additions & 4 deletions src/features/layout/stores/layout-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,10 +411,11 @@ export const useLayoutStore = createSelectors(
addTab: (tab, groupId) =>
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" ||
Expand All @@ -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;
Expand Down
216 changes: 216 additions & 0 deletions src/features/notebook/components/notebook-viewer.tsx
Original file line number Diff line number Diff line change
@@ -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<LoadState>({ status: "loading" });

useEffect(() => {
let cancelled = false;
setState({ status: "loading" });
invoke<string>("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 (
<div className="h-full w-full flex flex-col bg-[var(--bg-base)]">
<div className="flex items-center px-3 h-[32px] border-b border-[var(--border-default)] shrink-0 text-[11px] font-mono text-[var(--text-tertiary)] truncate">
{filePath}
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
{state.status === "loading" ? (
<div className="h-full flex items-center justify-center text-[var(--text-tertiary)]">
<Loader2 size={16} className="animate-spin" />
</div>
) : state.status === "error" ? (
<div className="p-6 text-[12px] text-[var(--danger,#e5484d)]">
Couldn't parse this notebook: {state.message}
</div>
) : (
<NotebookBody notebook={state.notebook} />
)}
</div>
</div>
);
}

function NotebookBody({ notebook }: { notebook: NotebookFile }) {
const language = useMemo(() => notebookLanguage(notebook), [notebook]);

if (notebook.cells.length === 0) {
return (
<div className="text-[12px] text-[var(--text-tertiary)] text-center py-12">
Empty notebook
</div>
);
}

return (
<div className="max-w-[900px] mx-auto px-4 py-4 space-y-3">
{notebook.cells.map((cell, i) => (
<NotebookCellView key={i} cell={cell} language={language} />
))}
</div>
);
}

function NotebookCellView({
cell,
language,
}: {
cell: NotebookCell;
language: string;
}) {
const source = joinSource(cell.source);

if (cell.cell_type === "markdown") {
return source.trim() ? (
<div className="px-1">
<Markdown>{source}</Markdown>
</div>
) : null;
}

if (cell.cell_type === "raw") {
return (
<pre className="rounded-md border border-[var(--border-default)] bg-[var(--bg-secondary)] p-3 text-[12px] font-mono whitespace-pre-wrap overflow-x-auto text-[var(--text-secondary)]">
{source}
</pre>
);
}

// code cell
const count = cell.execution_count;
return (
<div className="flex gap-2">
<div className="w-10 shrink-0 pt-1.5 text-right text-[11px] font-mono text-[var(--text-tertiary)] select-none">
{count != null ? (
`[${count}]`
) : (
<Play size={10} className="inline opacity-40" />
)}
</div>
<div className="flex-1 min-w-0 space-y-1.5">
{source.trim() && (
<Markdown>{"```" + language + "\n" + source + "\n```"}</Markdown>
)}
{cell.outputs?.map((out, i) => (
<NotebookOutputView key={i} output={out} />
))}
</div>
</div>
);
}

function NotebookOutputView({ output }: { output: NotebookOutput }) {
if (output.output_type === "stream") {
return (
<pre
className={cn(
"rounded-md border p-2.5 text-[11px] font-mono whitespace-pre-wrap overflow-x-auto",
output.name === "stderr"
? "border-[var(--danger,#e5484d)]/30 bg-[rgba(229,72,77,0.06)] text-[var(--danger,#e5484d)]"
: "border-[var(--border-default)] bg-[var(--bg-secondary)] text-[var(--text-secondary)]",
)}
>
{joinSource(output.text)}
</pre>
);
}

if (output.output_type === "error") {
const trace = (output.traceback ?? []).map(stripAnsi).join("\n");
return (
<pre className="rounded-md border border-[var(--danger,#e5484d)]/30 bg-[rgba(229,72,77,0.06)] p-2.5 text-[11px] font-mono whitespace-pre-wrap overflow-x-auto text-[var(--danger,#e5484d)]">
{trace || `${output.ename}: ${output.evalue}`}
</pre>
);
}

// 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 (
<img
src={`data:${imageMime};base64,${raw}`}
alt="Cell output"
className="max-w-full rounded-md border border-[var(--border-default)]"
/>
);
}
if (data["text/plain"]) {
return (
<pre className="rounded-md border border-[var(--border-default)] bg-[var(--bg-secondary)] p-2.5 text-[11px] font-mono whitespace-pre-wrap overflow-x-auto text-[var(--text-secondary)]">
{joinSource(data["text/plain"])}
</pre>
);
}
const anyMime = Object.keys(data)[0];
if (anyMime) {
return (
<div className="rounded-md border border-[var(--border-default)] bg-[var(--bg-secondary)] p-2.5 text-[11px] text-[var(--text-tertiary)]">
Output type <span className="font-mono">{anyMime}</span> isn't rendered
in Atlas yet.
</div>
);
}
return null;
}
61 changes: 61 additions & 0 deletions src/features/notebook/lib/notebook-types.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | string[]>;
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, "");
}
1 change: 1 addition & 0 deletions src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const TAB_TYPES = [
"media",
"svg",
"pdf",
"notebook",
"unsupported",
"pomodoro",
"mission-control",
Expand Down
7 changes: 6 additions & 1 deletion src/lib/file-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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"]);
Expand Down Expand Up @@ -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";
Expand Down
5 changes: 4 additions & 1 deletion src/lib/open-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,13 @@ export async function openFileOrReveal(path: string): Promise<void> {
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";
}
Loading