feat: notebook per-cell execution via open-terminal REST endpoints

- Add notebook API functions (createNotebookSession, executeNotebookCell, stopNotebookSession)
- Create CellEditor component with CodeMirror for cell editing
- Rewrite NotebookView with session-based execution, Run All, Restart, Stop
- Kernel status indicator with tooltips
- Wire baseUrl/apiKey through FilePreview and FileNav
This commit is contained in:
Timothy Jaeryang Baek
2026-03-05 16:08:11 -06:00
parent 8cd2157564
commit 4b3ed3e802
5 changed files with 523 additions and 33 deletions

View File

@@ -258,3 +258,81 @@ export const getListeningPorts = async (
export const getPortProxyUrl = (baseUrl: string, port: number, path: string = ''): string => {
return `${baseUrl.replace(/\/$/, '')}/proxy/${port}/${path}`;
};
// ---------------------------------------------------------------------------
// Notebook execution
// ---------------------------------------------------------------------------
export const createNotebookSession = async (
baseUrl: string,
apiKey: string,
path: string
): Promise<{ id: string; kernel: string; status: string } | { error: string }> => {
const url = `${baseUrl.replace(/\/$/, '')}/notebooks`;
const res = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ path })
})
.then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
return { error: body?.detail ?? `HTTP ${res.status}` };
}
return res.json();
})
.catch((err) => {
console.error('open-terminal createNotebookSession error:', err);
return { error: 'Connection failed' };
});
return res;
};
export const executeNotebookCell = async (
baseUrl: string,
apiKey: string,
sessionId: string,
cellIndex: number,
source?: string
): Promise<{ status: string; execution_count?: number; outputs: any[] } | { error: string }> => {
const url = `${baseUrl.replace(/\/$/, '')}/notebooks/${sessionId}/execute`;
const body: Record<string, any> = { cell_index: cellIndex };
if (source !== undefined) body.source = source;
const res = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
})
.then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
return { error: body?.detail ?? `HTTP ${res.status}` };
}
return res.json();
})
.catch((err) => {
console.error('open-terminal executeNotebookCell error:', err);
return { error: 'Connection failed' };
});
return res;
};
export const stopNotebookSession = async (
baseUrl: string,
apiKey: string,
sessionId: string
): Promise<boolean> => {
const url = `${baseUrl.replace(/\/$/, '')}/notebooks/${sessionId}`;
const res = await fetch(url, {
method: 'DELETE',
headers: { Authorization: `Bearer ${apiKey}` }
}).catch(() => null);
return res?.ok ?? false;
};

View File

@@ -845,6 +845,8 @@
const result = await excelToTable(excelWorkbook.Sheets[sheet]);
fileOfficeHtml = result.html;
}}
baseUrl={selectedTerminal?.url ?? ''}
apiKey={selectedTerminal?.key ?? ''}
{overlay}
onSave={async (content) => {
const terminal = selectedTerminal;

View File

@@ -0,0 +1,109 @@
<script lang="ts">
import '$lib/utils/codemirror';
import { basicSetup, EditorView } from 'codemirror';
import { keymap } from '@codemirror/view';
import { Compartment, EditorState, Prec } from '@codemirror/state';
import { indentWithTab } from '@codemirror/commands';
import { indentUnit } from '@codemirror/language';
import { languages } from '@codemirror/language-data';
import { oneDark } from '@codemirror/theme-one-dark';
import { onMount, onDestroy, createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
export let value = '';
export let lang = 'python';
let container: HTMLDivElement;
let editor: EditorView | null = null;
let editorTheme = new Compartment();
let editorLanguage = new Compartment();
const getLang = async () => {
const language = languages.find((l) => l.alias.includes(lang));
return await language?.load();
};
onMount(async () => {
const isDark = document.documentElement.classList.contains('dark');
const extensions = [
Prec.highest(keymap.of([
{
key: 'Mod-Enter',
run: () => {
dispatch('run');
return true;
}
},
{
key: 'Escape',
run: () => {
dispatch('cancel');
return true;
}
}
])),
basicSetup,
keymap.of([indentWithTab]),
indentUnit.of(' '),
EditorView.updateListener.of((e) => {
if (e.docChanged) {
value = e.state.doc.toString();
dispatch('change', value);
}
}),
editorTheme.of(isDark ? oneDark : []),
editorLanguage.of([]),
EditorView.theme({
'&': { fontSize: '0.75rem' },
'.cm-content': { padding: '0.35rem 0', fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace' },
'.cm-gutters': { display: 'none' },
'.cm-focused': { outline: 'none' },
'.cm-scroller': { overflow: 'auto' }
})
];
editor = new EditorView({
state: EditorState.create({ doc: value, extensions }),
parent: container
});
const language = await getLang();
if (language && editor) {
editor.dispatch({ effects: editorLanguage.reconfigure(language) });
}
// Watch dark mode
const observer = new MutationObserver(() => {
const dark = document.documentElement.classList.contains('dark');
editor?.dispatch({ effects: editorTheme.reconfigure(dark ? oneDark : []) });
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
editor.focus();
return () => {
observer.disconnect();
editor?.destroy();
editor = null;
};
});
onDestroy(() => {
editor?.destroy();
editor = null;
});
</script>
<div bind:this={container} class="nb-cm-editor" />
<style>
.nb-cm-editor {
width: 100%;
background: #fffef5;
}
:global(.dark) .nb-cm-editor {
background: transparent;
}
</style>

View File

@@ -25,6 +25,10 @@
export let fileSqliteData: ArrayBuffer | null = null;
export let fileContent: string | null = null;
// Terminal connection for notebook execution
export let baseUrl: string = '';
export let apiKey: string = '';
// Office preview props
export let fileOfficeHtml: string | null = null;
export let fileOfficeSlides: string[] | null = null;
@@ -409,7 +413,7 @@
</div>
{:else if isNotebook && !showRaw && parsedNotebook}
<div class="overflow-auto h-full">
<NotebookView notebook={parsedNotebook} />
<NotebookView notebook={parsedNotebook} filePath={selectedFile ?? ''} {baseUrl} {apiKey} />
</div>
{:else if isJson && !showRaw && parsedJson !== undefined}
<div class="overflow-auto h-full">

View File

@@ -1,12 +1,23 @@
<script lang="ts">
import { getContext } from 'svelte';
import { getContext, onMount, onDestroy } from 'svelte';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { highlightCode } from '$lib/utils/codeHighlight';
import {
createNotebookSession,
executeNotebookCell,
stopNotebookSession
} from '$lib/apis/terminal';
import Spinner from '../../common/Spinner.svelte';
import Tooltip from '../../common/Tooltip.svelte';
import CellEditor from './CellEditor.svelte';
const i18n = getContext('i18n');
export let notebook: Record<string, unknown>;
export let filePath: string = '';
export let baseUrl: string = '';
export let apiKey: string = '';
interface NotebookCell {
cell_type: 'markdown' | 'code' | 'raw';
@@ -33,7 +44,7 @@
const toStr = (s: string[] | string | undefined): string =>
Array.isArray(s) ? s.join('') : s ?? '';
// Highlight code cells with Shiki
// ── Syntax highlighting ──────────────────────────────────────────────
let highlightedCells: Record<number, string> = {};
const highlightCell = async (index: number, code: string, language: string) => {
@@ -45,7 +56,7 @@
defaultColor: 'light'
});
highlightedCells[index] = html;
highlightedCells = highlightedCells; // trigger reactivity
highlightedCells = highlightedCells;
} catch {
// fallback handled in template
}
@@ -54,12 +65,13 @@
$: {
highlightedCells = {};
cells.forEach((cell, i) => {
if (cell.cell_type === 'code') {
if (cell.cell_type === 'code' && !editingCell[i]) {
highlightCell(i, toStr(cell.source), lang);
}
});
}
// ── Markdown / output helpers ────────────────────────────────────────
const renderMarkdown = (src: string): string =>
DOMPurify.sanitize(marked.parse(src, { async: false }) as string);
@@ -72,11 +84,7 @@
for (const [mime, content] of Object.entries(output.data)) {
if (mime.startsWith('image/')) {
const raw = Array.isArray(content) ? content.join('') : content;
if (raw.startsWith('data:')) {
images.push(raw);
} else {
images.push(`data:${mime};base64,${raw}`);
}
images.push(raw.startsWith('data:') ? raw : `data:${mime};base64,${raw}`);
}
}
return images;
@@ -84,8 +92,7 @@
const getOutputHtml = (output: NotebookOutput): string | null => {
if (!output.data?.['text/html']) return null;
const raw = toStr(output.data['text/html']);
return DOMPurify.sanitize(raw);
return DOMPurify.sanitize(toStr(output.data['text/html']));
};
const getOutputText = (output: NotebookOutput): string | null => {
@@ -93,31 +100,234 @@
if (output.data?.['text/plain']) return toStr(output.data['text/plain']);
return null;
};
// ── Cell editing ─────────────────────────────────────────────────────
let editingCell: Record<number, boolean> = {};
let editedSources: Record<number, string> = {};
const startEditing = (index: number) => {
editingCell[index] = true;
editedSources[index] = toStr(cells[index].source);
editingCell = editingCell;
};
const cancelEditing = (index: number) => {
delete editingCell[index];
delete editedSources[index];
editingCell = editingCell;
highlightCell(index, toStr(cells[index].source), lang);
};
// ── Session / kernel ─────────────────────────────────────────────────
let sessionId: string | null = null;
let kernelReady = false;
let kernelStarting = false;
let kernelError: string | null = null;
let runningCell: number | null = null;
let runAllActive = false;
const canExecute = baseUrl && apiKey && filePath;
const startSession = async (): Promise<boolean> => {
if (!baseUrl || !apiKey || !filePath) return false;
kernelStarting = true;
kernelError = null;
const result = await createNotebookSession(baseUrl, apiKey, filePath);
if ('error' in result) {
kernelStarting = false;
kernelError = result.error;
return false;
}
sessionId = result.id;
kernelReady = true;
kernelStarting = false;
return true;
};
const stopSession = async () => {
if (sessionId && baseUrl && apiKey) {
await stopNotebookSession(baseUrl, apiKey, sessionId);
}
sessionId = null;
kernelReady = false;
};
const runCell = async (index: number) => {
if (runningCell !== null) return;
if (!kernelReady && !(await startSession())) return;
runningCell = index;
const source = editedSources[index] ?? toStr(cells[index].source);
// Apply edits
if (editedSources[index] !== undefined) {
cells[index].source = editedSources[index];
delete editingCell[index];
delete editedSources[index];
editingCell = editingCell;
}
const result = await executeNotebookCell(baseUrl, apiKey, sessionId!, index, source);
if ('error' in result) {
cells[index].outputs = [{
output_type: 'error',
ename: 'ExecutionError',
evalue: result.error,
traceback: [result.error]
}];
} else {
cells[index].outputs = result.outputs;
if (result.execution_count !== undefined) {
cells[index].execution_count = result.execution_count;
}
}
cells = cells;
runningCell = null;
highlightCell(index, toStr(cells[index].source), lang);
};
const runAll = async () => {
if (runAllActive) return;
runAllActive = true;
for (let i = 0; i < cells.length; i++) {
if (cells[i].cell_type === 'code') {
await runCell(i);
}
}
runAllActive = false;
};
const autoResize = (e: Event) => {
const ta = e.target as HTMLTextAreaElement;
ta.style.height = 'auto';
ta.style.height = ta.scrollHeight + 'px';
};
onDestroy(() => {
if (sessionId) stopSession();
});
</script>
<div class="notebook-view">
<!-- Toolbar -->
{#if baseUrl && apiKey && filePath}
<div class="nb-toolbar flex items-center gap-1 px-2 py-0.5">
<button
class="nb-btn text-[0.6rem]"
on:click={runAll}
disabled={runAllActive || runningCell !== null}
>
{$i18n.t('Run All')}
</button>
{#if kernelReady}
<button class="nb-btn text-[0.6rem]" on:click={async () => { await stopSession(); await startSession(); }}>
{$i18n.t('Restart')}
</button>
<button class="nb-btn text-[0.6rem]" on:click={stopSession}>
{$i18n.t('Stop')}
</button>
{/if}
<div class="flex-1"></div>
<div class="flex items-center select-none">
{#if kernelStarting}
<Tooltip content={$i18n.t('Starting kernel...')} placement="bottom"><Spinner className="size-2" /></Tooltip>
{:else if runningCell !== null}
<Tooltip content={$i18n.t('Running')} placement="bottom"><Spinner className="size-2" /></Tooltip>
{:else if kernelReady}
<Tooltip content="Jupyter" placement="bottom"><span class="size-1.5 rounded-full bg-green-500 inline-block"></span></Tooltip>
{:else}
<Tooltip content={$i18n.t('No kernel')} placement="bottom"><span class="size-1.5 rounded-full bg-gray-400 dark:bg-gray-600 inline-block"></span></Tooltip>
{/if}
</div>
</div>
{#if kernelError}
<div class="px-2 py-1 text-[0.65rem] text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/30 whitespace-pre-wrap font-mono">
{kernelError}
</div>
{/if}
{/if}
<!-- Cells -->
{#each cells as cell, i}
<div class="nb-cell nb-cell-{cell.cell_type}">
{#if cell.cell_type === 'markdown'}
<div class="nb-markdown prose dark:prose-invert max-w-full text-sm">
{@html renderMarkdown(toStr(cell.source))}
</div>
{#if editingCell[i]}
<textarea
class="nb-edit-textarea text-sm"
bind:value={editedSources[i]}
on:input={autoResize}
on:blur={() => cancelEditing(i)}
on:keydown={(e) => { if (e.key === 'Escape') cancelEditing(i); }}
></textarea>
{:else}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="nb-markdown prose dark:prose-invert max-w-full text-sm cursor-text"
role="textbox"
tabindex="0"
on:dblclick={() => startEditing(i)}
>
{@html renderMarkdown(toStr(cell.source))}
</div>
{/if}
{:else if cell.cell_type === 'code'}
<div class="nb-code-wrap">
<div class="nb-cell-label">
{#if cell.execution_count !== undefined && cell.execution_count !== null}
[{cell.execution_count}]
{:else}
[ ]
<div class="nb-cell-gutter">
{#if runningCell === i}
<div class="nb-cell-label"><Spinner className="size-3" /></div>
{:else if baseUrl && apiKey && filePath}
<button
class="nb-run-btn"
on:click={() => runCell(i)}
disabled={runningCell !== null}
title="Run cell (⌘+Enter)"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-3">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />
</svg>
</button>
{/if}
<div class="nb-cell-label">
{#if cell.execution_count !== undefined && cell.execution_count !== null}
[{cell.execution_count}]
{:else}
[ ]
{/if}
</div>
</div>
<div class="nb-code-content">
{#if highlightedCells[i]}
<div class="nb-code-source shiki-preview">
{@html highlightedCells[i]}
</div>
{#if editingCell[i]}
<CellEditor
value={editedSources[i]}
{lang}
on:change={(e) => { editedSources[i] = e.detail; }}
on:run={() => runCell(i)}
on:cancel={() => cancelEditing(i)}
/>
{:else}
<pre class="nb-code-source-raw">{toStr(cell.source)}</pre>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="nb-code-source-clickable"
role="textbox"
tabindex="0"
on:dblclick={() => startEditing(i)}
>
{#if highlightedCells[i]}
<div class="nb-code-source shiki-preview">
{@html highlightedCells[i]}
</div>
{:else}
<pre class="nb-code-source-raw">{toStr(cell.source)}</pre>
{/if}
</div>
{/if}
{#if cell.outputs && cell.outputs.length > 0}
@@ -153,9 +363,37 @@
<style>
.notebook-view {
padding: 0.5rem 0;
padding: 0;
font-size: 0.8rem;
}
.nb-toolbar {
position: sticky;
top: 0;
z-index: 5;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(4px);
}
:global(.dark) .nb-toolbar {
background: rgba(17, 24, 39, 0.95);
}
.nb-btn {
font-size: 0.6rem;
padding: 0.15rem 0.35rem;
border-radius: 0.25rem;
color: #6b7280;
transition: all 0.15s;
}
.nb-btn:hover:not(:disabled) {
background: rgba(128, 128, 128, 0.1);
color: #374151;
}
:global(.dark) .nb-btn:hover:not(:disabled) {
color: #d1d5db;
}
.nb-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.nb-cell {
padding: 0.25rem 0.75rem;
}
@@ -175,21 +413,52 @@
}
.nb-code-wrap {
display: flex;
gap: 0.5rem;
gap: 0;
}
.nb-cell-label {
.nb-cell-gutter {
flex-shrink: 0;
width: 2.5rem;
text-align: right;
display: flex;
flex-direction: column;
align-items: center;
padding-top: 0.35rem;
gap: 0.15rem;
}
.nb-cell-label {
color: #9ca3af;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
font-size: 0.65rem;
padding-top: 0.5rem;
font-size: 0.6rem;
user-select: none;
text-align: center;
}
:global(.dark) .nb-cell-label {
color: #4b5563;
}
.nb-run-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 0.25rem;
color: #9ca3af;
transition: all 0.15s;
}
.nb-run-btn:hover:not(:disabled) {
color: #059669;
background: rgba(5, 150, 105, 0.1);
}
:global(.dark) .nb-run-btn {
color: #6b7280;
}
:global(.dark) .nb-run-btn:hover:not(:disabled) {
color: #34d399;
background: rgba(52, 211, 153, 0.15);
}
.nb-run-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.nb-code-content {
flex: 1;
min-width: 0;
@@ -200,6 +469,9 @@
:global(.dark) .nb-code-content {
border-color: rgba(128, 128, 128, 0.25);
}
.nb-code-source-clickable {
cursor: text;
}
.nb-code-source :global(pre.shiki) {
margin: 0;
padding: 0.5rem 0.75rem;
@@ -207,7 +479,6 @@
line-height: 1.5;
border-radius: 0;
}
/* Remove line numbers for notebook cells */
.nb-code-source :global(pre.shiki code > .line::before) {
display: none;
}
@@ -224,6 +495,33 @@
background: #161b22;
color: #e6edf3;
}
.nb-code-textarea, .nb-edit-textarea {
width: 100%;
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
line-height: 1.5;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
background: #fffef5;
color: #1f2937;
border: none;
outline: none;
resize: none;
min-height: 2.5rem;
overflow: hidden;
}
:global(.dark) .nb-code-textarea,
:global(.dark) .nb-edit-textarea {
background: #1a1d23;
color: #e6edf3;
}
.nb-edit-textarea {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 0.8rem;
background: transparent;
padding: 0.5rem 0.75rem;
border: 1px dashed rgba(128, 128, 128, 0.3);
border-radius: 4px;
}
.nb-outputs {
border-top: 1px solid rgba(128, 128, 128, 0.15);
}
@@ -291,7 +589,6 @@
color: #6b7280;
white-space: pre-wrap;
}
/* Shiki dark mode for notebook cells */
:global(.dark) .nb-code-source :global(.shiki),
:global(.dark) .nb-code-source :global(.shiki span) {
color: var(--shiki-dark) !important;