dashboard file preview: markdown tabs + raster image rendering
Follow-up to #188. Two additions to the side-panel file preview: - Markdown files get a rendered/plain tabbed view (was: always rendered, no way to see source) — same tab pattern as SVG. - Raster images (png/jpg/gif/webp/bmp/ico/avif) render as an <img>. /api/state-file previously from_utf8_lossy-stringified every file and served text/plain, which corrupts binary; it now serves image files as raw bytes with their real content-type (over-cap images are rejected, not truncated — a clipped binary is corrupt). buildSvgPanel generalised to buildTabbedPreview, shared by SVG + markdown. .svg-host/.svg-render renamed .preview-host/.img-preview since they now back images + md too. closes #192
This commit is contained in:
parent
0884a54960
commit
f42ba9b561
4 changed files with 107 additions and 42 deletions
|
|
@ -91,31 +91,21 @@
|
|||
if (!resp.ok) throw new Error(text || ('HTTP ' + resp.status));
|
||||
return text;
|
||||
}
|
||||
// SVG file preview: a "rendered" tab (default) showing the image and
|
||||
// a "source" tab with the raw markup. The image is loaded via an
|
||||
// <img> data: URI — <img>-loaded SVG runs in the browser's secure
|
||||
// static mode (no scripts, no external fetches), so an untrusted
|
||||
// SVG from an agent's state dir can't execute code in the dashboard.
|
||||
function buildSvgPanel(text) {
|
||||
// A 2-tab file preview: a "rendered" tab (default) + a raw-text tab.
|
||||
// `renderRendered()` produces the rendered-tab node fresh on each
|
||||
// switch; `plainText` backs the raw tab; `plainLabel` names it.
|
||||
function buildTabbedPreview(renderRendered, plainText, plainLabel) {
|
||||
const tabs = el('div', { class: 'diff-base-tabs' });
|
||||
const host = el('div', { class: 'svg-host' });
|
||||
const host = el('div', { class: 'preview-host' });
|
||||
function show(mode) {
|
||||
for (const b of tabs.children) {
|
||||
b.classList.toggle('active', b.dataset.mode === mode);
|
||||
}
|
||||
if (mode === 'source') {
|
||||
host.replaceChildren(el('pre', { class: 'path-preview-body' }, text));
|
||||
return;
|
||||
}
|
||||
const img = el('img', { class: 'svg-render', alt: 'SVG preview' });
|
||||
img.addEventListener('error', () => {
|
||||
host.replaceChildren(el('div', { class: 'meta' },
|
||||
'(could not render — see the source tab)'));
|
||||
});
|
||||
img.src = 'data:image/svg+xml,' + encodeURIComponent(text);
|
||||
host.replaceChildren(img);
|
||||
host.replaceChildren(mode === 'plain'
|
||||
? el('pre', { class: 'path-preview-body' }, plainText)
|
||||
: renderRendered());
|
||||
}
|
||||
for (const [mode, label] of [['rendered', 'rendered'], ['source', 'source']]) {
|
||||
for (const [mode, label] of [['rendered', 'rendered'], ['plain', plainLabel]]) {
|
||||
const b = el('button',
|
||||
{ type: 'button', class: 'diff-base-tab', 'data-mode': mode }, label);
|
||||
b.addEventListener('click', () => show(mode));
|
||||
|
|
@ -124,26 +114,62 @@
|
|||
show('rendered');
|
||||
return el('div', {}, tabs, host);
|
||||
}
|
||||
// Rendered <img> for an SVG, loaded via an <img> data: URI —
|
||||
// <img>-loaded SVG runs in the browser's secure static mode (no
|
||||
// scripts, no external fetches), so an untrusted SVG from an
|
||||
// agent's state dir can't execute code in the dashboard.
|
||||
function svgImage(text) {
|
||||
const img = el('img', { class: 'img-preview', alt: 'SVG preview' });
|
||||
img.addEventListener('error', () => {
|
||||
img.replaceWith(el('div', { class: 'meta' },
|
||||
'(could not render — see the source tab)'));
|
||||
});
|
||||
img.src = 'data:image/svg+xml,' + encodeURIComponent(text);
|
||||
return img;
|
||||
}
|
||||
// Marked-rendered markdown node (raw text fallback if `marked`
|
||||
// failed to load).
|
||||
function mdNode(text) {
|
||||
const div = el('div', { class: 'md' });
|
||||
if (window.marked && typeof window.marked.parse === 'function') {
|
||||
marked.setOptions({ breaks: true, gfm: true });
|
||||
div.innerHTML = marked.parse(text);
|
||||
} else {
|
||||
div.textContent = text;
|
||||
}
|
||||
return div;
|
||||
}
|
||||
// Raster image extensions the preview renders as an <img> pointed
|
||||
// straight at /api/state-file (served binary with a real
|
||||
// content-type). SVG is handled on the text path instead.
|
||||
const RASTER_RE = /\.(png|jpe?g|gif|webp|bmp|ico|avif)$/i;
|
||||
// Lazy-load `path` from /api/state-file into the side panel.
|
||||
// Markdown → `marked` into a `.md` block; SVG → rendered/source
|
||||
// tabbed view; every other file stays raw text in a <pre>.
|
||||
// Markdown + SVG get a rendered/plain tabbed view; raster images
|
||||
// render as an <img>; every other file stays raw text in a <pre>.
|
||||
async function openFilePanel(path) {
|
||||
if (RASTER_RE.test(path)) {
|
||||
const img = el('img', { class: 'img-preview', alt: path });
|
||||
img.addEventListener('error', () => {
|
||||
img.replaceWith(el('pre', { class: 'path-preview-body' },
|
||||
'(could not load image — it may be missing or over the preview size cap)'));
|
||||
});
|
||||
img.src = '/api/state-file?path=' + encodeURIComponent(path);
|
||||
Panel.open('↳ ' + path, img);
|
||||
return;
|
||||
}
|
||||
const isMd = /\.(md|markdown)$/i.test(path);
|
||||
const isSvg = /\.svg$/i.test(path);
|
||||
const view = isMd ? el('div', { class: 'md' })
|
||||
: isSvg ? el('div')
|
||||
: el('pre', { class: 'path-preview-body' });
|
||||
const view = el('div');
|
||||
view.textContent = '(fetching…)';
|
||||
Panel.open('↳ ' + path, view);
|
||||
try {
|
||||
const text = await fetchStateFile(path);
|
||||
if (isMd && window.marked && typeof window.marked.parse === 'function') {
|
||||
marked.setOptions({ breaks: true, gfm: true });
|
||||
view.innerHTML = marked.parse(text);
|
||||
} else if (isSvg) {
|
||||
view.replaceChildren(buildSvgPanel(text));
|
||||
if (isSvg) {
|
||||
view.replaceChildren(buildTabbedPreview(() => svgImage(text), text, 'source'));
|
||||
} else if (isMd) {
|
||||
view.replaceChildren(buildTabbedPreview(() => mdNode(text), text, 'plain'));
|
||||
} else {
|
||||
view.textContent = text;
|
||||
view.replaceChildren(el('pre', { class: 'path-preview-body' }, text));
|
||||
}
|
||||
} catch (e) {
|
||||
view.textContent = 'error: ' + (e.message || e);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue