mirror of
https://github.com/open-webui/open-webui.git
synced 2026-07-10 20:40:18 +02:00
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:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
109
src/lib/components/chat/FileNav/CellEditor.svelte
Normal file
109
src/lib/components/chat/FileNav/CellEditor.svelte
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user