feat: add Jupyter Notebook (.ipynb) preview in FileNav
- New NotebookView component renders markdown cells (marked+DOMPurify), code cells (Shiki-highlighted with execution count gutter), and outputs (text, HTML tables, base64 images, error tracebacks) - ANSI escape codes stripped from error output - Source toggle shows raw JSON - Dark mode support throughout
This commit is contained in:
@@ -121,6 +121,7 @@
|
||||
$: isHtml = HTML_EXTS.has(getFileExt(selectedFile));
|
||||
$: isJson = ['json', 'jsonc', 'jsonl', 'json5'].includes(getFileExt(selectedFile));
|
||||
$: isSvg = getFileExt(selectedFile) === 'svg';
|
||||
$: isNotebook = getFileExt(selectedFile) === 'ipynb';
|
||||
$: isCode = isCodeFile(selectedFile);
|
||||
$: isOfficeFile = OFFICE_EXTS.has(getFileExt(selectedFile));
|
||||
$: isTextFile =
|
||||
@@ -636,7 +637,7 @@
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
{#if (isMarkdown || isCsv || isHtml || isCode || isJson || isSvg) && fileContent !== null && !editing}
|
||||
{#if (isMarkdown || isCsv || isHtml || isCode || isJson || isSvg || isNotebook) && fileContent !== null && !editing}
|
||||
<Tooltip content={showRaw ? $i18n.t('Preview') : $i18n.t('Source')}>
|
||||
<button
|
||||
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import Spinner from '../../common/Spinner.svelte';
|
||||
import PDFViewer from '../../common/PDFViewer.svelte';
|
||||
import JsonTreeView from './JsonTreeView.svelte';
|
||||
import NotebookView from './NotebookView.svelte';
|
||||
|
||||
let pdfViewerRef: PDFViewer;
|
||||
|
||||
@@ -81,6 +82,7 @@
|
||||
$: isHtml = HTML_EXTS.has(getExt(selectedFile));
|
||||
$: isJson = JSON_EXTS.has(getExt(selectedFile));
|
||||
$: isSvg = getExt(selectedFile) === 'svg';
|
||||
$: isNotebook = getExt(selectedFile) === 'ipynb';
|
||||
$: isCode = isCodeFile(selectedFile);
|
||||
$: csvDelimiter = getExt(selectedFile) === 'tsv' ? '\t' : ',';
|
||||
$: renderedHtml =
|
||||
@@ -176,6 +178,19 @@
|
||||
jsonError = null;
|
||||
}
|
||||
|
||||
// ── Notebook parsing ─────────────────────────────────────────────────
|
||||
let parsedNotebook: Record<string, unknown> | null = null;
|
||||
|
||||
$: if (isNotebook && fileContent !== null) {
|
||||
try {
|
||||
parsedNotebook = JSON.parse(fileContent);
|
||||
} catch {
|
||||
parsedNotebook = null;
|
||||
}
|
||||
} else {
|
||||
parsedNotebook = null;
|
||||
}
|
||||
|
||||
export let showRaw = false;
|
||||
$: selectedFile, (showRaw = false); // reset to preview mode when switching files
|
||||
|
||||
@@ -352,6 +367,10 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else if isNotebook && !showRaw && parsedNotebook}
|
||||
<div class="overflow-auto h-full">
|
||||
<NotebookView notebook={parsedNotebook} />
|
||||
</div>
|
||||
{:else if isJson && !showRaw && parsedJson !== undefined}
|
||||
<div class="overflow-auto h-full">
|
||||
<JsonTreeView data={parsedJson} />
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { highlightCode } from '$lib/utils/codeHighlight';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let notebook: Record<string, unknown>;
|
||||
|
||||
interface NotebookCell {
|
||||
cell_type: 'markdown' | 'code' | 'raw';
|
||||
source: string[] | string;
|
||||
outputs?: NotebookOutput[];
|
||||
execution_count?: number | null;
|
||||
}
|
||||
|
||||
interface NotebookOutput {
|
||||
output_type: 'stream' | 'execute_result' | 'display_data' | 'error';
|
||||
text?: string[] | string;
|
||||
data?: Record<string, string[] | string>;
|
||||
name?: string;
|
||||
ename?: string;
|
||||
evalue?: string;
|
||||
traceback?: string[];
|
||||
}
|
||||
|
||||
$: cells = (notebook?.cells as NotebookCell[]) ?? [];
|
||||
$: lang = (notebook?.metadata as Record<string, unknown>)?.kernelspec
|
||||
? ((notebook.metadata as Record<string, Record<string, string>>).kernelspec?.language ?? 'python')
|
||||
: 'python';
|
||||
|
||||
const toStr = (s: string[] | string | undefined): string =>
|
||||
Array.isArray(s) ? s.join('') : s ?? '';
|
||||
|
||||
// Highlight code cells with Shiki
|
||||
let highlightedCells: Record<number, string> = {};
|
||||
|
||||
const highlightCell = async (index: number, code: string, language: string) => {
|
||||
try {
|
||||
const { codeToHtml } = await import('shiki');
|
||||
const html = await codeToHtml(code, {
|
||||
lang: language,
|
||||
themes: { light: 'github-light', dark: 'github-dark' },
|
||||
defaultColor: 'light'
|
||||
});
|
||||
highlightedCells[index] = html;
|
||||
highlightedCells = highlightedCells; // trigger reactivity
|
||||
} catch {
|
||||
// fallback handled in template
|
||||
}
|
||||
};
|
||||
|
||||
$: {
|
||||
highlightedCells = {};
|
||||
cells.forEach((cell, i) => {
|
||||
if (cell.cell_type === 'code') {
|
||||
highlightCell(i, toStr(cell.source), lang);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const renderMarkdown = (src: string): string =>
|
||||
DOMPurify.sanitize(marked.parse(src, { async: false }) as string);
|
||||
|
||||
const stripAnsi = (s: string): string =>
|
||||
s.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
|
||||
|
||||
const getOutputImages = (output: NotebookOutput): string[] => {
|
||||
if (!output.data) return [];
|
||||
const images: string[] = [];
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return images;
|
||||
};
|
||||
|
||||
const getOutputHtml = (output: NotebookOutput): string | null => {
|
||||
if (!output.data?.['text/html']) return null;
|
||||
const raw = toStr(output.data['text/html']);
|
||||
return DOMPurify.sanitize(raw);
|
||||
};
|
||||
|
||||
const getOutputText = (output: NotebookOutput): string | null => {
|
||||
if (output.output_type === 'stream') return toStr(output.text);
|
||||
if (output.data?.['text/plain']) return toStr(output.data['text/plain']);
|
||||
return null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="notebook-view">
|
||||
{#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>
|
||||
{: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}
|
||||
[ ]
|
||||
{/if}
|
||||
</div>
|
||||
<div class="nb-code-content">
|
||||
{#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}
|
||||
|
||||
{#if cell.outputs && cell.outputs.length > 0}
|
||||
<div class="nb-outputs">
|
||||
{#each cell.outputs as output}
|
||||
{#if output.output_type === 'error'}
|
||||
<pre class="nb-error">{stripAnsi((output.traceback ?? []).join('\n') || `${output.ename}: ${output.evalue}`)}</pre>
|
||||
{:else}
|
||||
{@const html = getOutputHtml(output)}
|
||||
{@const images = getOutputImages(output)}
|
||||
{@const text = getOutputText(output)}
|
||||
{#if html}
|
||||
<div class="nb-output-html">{@html html}</div>
|
||||
{/if}
|
||||
{#each images as src}
|
||||
<img {src} alt="Output" class="nb-output-img" />
|
||||
{/each}
|
||||
{#if text}
|
||||
<pre class="nb-output-text">{text}</pre>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else if cell.cell_type === 'raw'}
|
||||
<pre class="nb-raw">{toStr(cell.source)}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.notebook-view {
|
||||
padding: 0.5rem 0;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.nb-cell {
|
||||
padding: 0.25rem 0.75rem;
|
||||
}
|
||||
.nb-cell-markdown {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
.nb-markdown :global(h1) { font-size: 1.4rem; }
|
||||
.nb-markdown :global(h2) { font-size: 1.2rem; }
|
||||
.nb-markdown :global(h3) { font-size: 1.05rem; }
|
||||
.nb-markdown :global(p) { margin: 0.4em 0; }
|
||||
.nb-markdown :global(pre) {
|
||||
background: rgba(128, 128, 128, 0.06);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.nb-code-wrap {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.nb-cell-label {
|
||||
flex-shrink: 0;
|
||||
width: 2.5rem;
|
||||
text-align: right;
|
||||
color: #9ca3af;
|
||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
||||
font-size: 0.65rem;
|
||||
padding-top: 0.5rem;
|
||||
user-select: none;
|
||||
}
|
||||
:global(.dark) .nb-cell-label {
|
||||
color: #4b5563;
|
||||
}
|
||||
.nb-code-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
:global(.dark) .nb-code-content {
|
||||
border-color: rgba(128, 128, 128, 0.25);
|
||||
}
|
||||
.nb-code-source :global(pre.shiki) {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
border-radius: 0;
|
||||
}
|
||||
/* Remove line numbers for notebook cells */
|
||||
.nb-code-source :global(pre.shiki code > .line::before) {
|
||||
display: none;
|
||||
}
|
||||
.nb-code-source-raw {
|
||||
margin: 0;
|
||||
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: #f8f8f8;
|
||||
color: #1f2937;
|
||||
}
|
||||
:global(.dark) .nb-code-source-raw {
|
||||
background: #161b22;
|
||||
color: #e6edf3;
|
||||
}
|
||||
.nb-outputs {
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
:global(.dark) .nb-outputs {
|
||||
border-top-color: rgba(128, 128, 128, 0.25);
|
||||
}
|
||||
.nb-output-text {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.5;
|
||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
||||
color: #374151;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
:global(.dark) .nb-output-text {
|
||||
color: #d1d5db;
|
||||
}
|
||||
.nb-output-img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
.nb-output-html {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.nb-output-html :global(table) {
|
||||
border-collapse: collapse;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
.nb-output-html :global(td),
|
||||
.nb-output-html :global(th) {
|
||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||
padding: 3px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
.nb-output-html :global(th) {
|
||||
background: rgba(128, 128, 128, 0.06);
|
||||
font-weight: 600;
|
||||
}
|
||||
.nb-error {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.5;
|
||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
||||
background: rgba(239, 68, 68, 0.05);
|
||||
color: #dc2626;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
:global(.dark) .nb-error {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #f87171;
|
||||
}
|
||||
.nb-raw {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
||||
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;
|
||||
background-color: var(--shiki-dark-bg) !important;
|
||||
font-style: var(--shiki-dark-font-style) !important;
|
||||
font-weight: var(--shiki-dark-font-weight) !important;
|
||||
text-decoration: var(--shiki-dark-text-decoration) !important;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user