dashboard: render SVG file previews
SVG files in the side-panel file preview showed only raw source. Add a rendered/source tabbed view: 'rendered' (default) shows the image, 'source' shows the markup. The image loads via an <img> data: URI — <img>-loaded SVG runs in the browser's secure static mode (scripts + external fetches disabled), so an untrusted SVG from an agent's state dir can't execute code in the dashboard origin. Tabs reuse the existing diff-base-tab styling; a checkerboard backs the image so transparent regions read clearly. closes #188
This commit is contained in:
parent
fc3490086b
commit
a8ab91ecd8
3 changed files with 57 additions and 5 deletions
|
|
@ -68,7 +68,11 @@ node)` swaps the body; the JS builders for file previews,
|
||||||
approval diffs, and journald logs all render into it. Markdown
|
approval diffs, and journald logs all render into it. Markdown
|
||||||
file previews (`.md` / `.markdown`) render through the vendored
|
file previews (`.md` / `.markdown`) render through the vendored
|
||||||
`marked` bundle (`GET /static/marked.js`) into a `.md` block;
|
`marked` bundle (`GET /static/marked.js`) into a `.md` block;
|
||||||
other files stay raw in a `<pre>`.
|
SVG previews (`.svg`) get a `rendered` / `source` tabbed view —
|
||||||
|
`rendered` shows the image via an `<img>` `data:` URI (the
|
||||||
|
browser's secure static mode, so an untrusted SVG can't run
|
||||||
|
scripts), `source` shows the raw markup; other files stay raw
|
||||||
|
in a `<pre>`.
|
||||||
|
|
||||||
Both bind their listeners with `SO_REUSEADDR` via
|
Both bind their listeners with `SO_REUSEADDR` via
|
||||||
`tokio::net::TcpSocket` plus a retry loop on `AddrInUse` (12 tries,
|
`tokio::net::TcpSocket` plus a retry loop on `AddrInUse` (12 tries,
|
||||||
|
|
|
||||||
|
|
@ -91,13 +91,47 @@
|
||||||
if (!resp.ok) throw new Error(text || ('HTTP ' + resp.status));
|
if (!resp.ok) throw new Error(text || ('HTTP ' + resp.status));
|
||||||
return text;
|
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) {
|
||||||
|
const tabs = el('div', { class: 'diff-base-tabs' });
|
||||||
|
const host = el('div', { class: 'svg-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);
|
||||||
|
}
|
||||||
|
for (const [mode, label] of [['rendered', 'rendered'], ['source', 'source']]) {
|
||||||
|
const b = el('button',
|
||||||
|
{ type: 'button', class: 'diff-base-tab', 'data-mode': mode }, label);
|
||||||
|
b.addEventListener('click', () => show(mode));
|
||||||
|
tabs.append(b);
|
||||||
|
}
|
||||||
|
show('rendered');
|
||||||
|
return el('div', {}, tabs, host);
|
||||||
|
}
|
||||||
// Lazy-load `path` from /api/state-file into the side panel.
|
// Lazy-load `path` from /api/state-file into the side panel.
|
||||||
// Markdown files render through `marked` into a `.md` block; every
|
// Markdown → `marked` into a `.md` block; SVG → rendered/source
|
||||||
// other file stays raw text in a <pre>.
|
// tabbed view; every other file stays raw text in a <pre>.
|
||||||
async function openFilePanel(path) {
|
async function openFilePanel(path) {
|
||||||
const isMd = /\.(md|markdown)$/i.test(path);
|
const isMd = /\.(md|markdown)$/i.test(path);
|
||||||
const view = isMd
|
const isSvg = /\.svg$/i.test(path);
|
||||||
? el('div', { class: 'md' })
|
const view = isMd ? el('div', { class: 'md' })
|
||||||
|
: isSvg ? el('div')
|
||||||
: el('pre', { class: 'path-preview-body' });
|
: el('pre', { class: 'path-preview-body' });
|
||||||
view.textContent = '(fetching…)';
|
view.textContent = '(fetching…)';
|
||||||
Panel.open('↳ ' + path, view);
|
Panel.open('↳ ' + path, view);
|
||||||
|
|
@ -106,6 +140,8 @@
|
||||||
if (isMd && window.marked && typeof window.marked.parse === 'function') {
|
if (isMd && window.marked && typeof window.marked.parse === 'function') {
|
||||||
marked.setOptions({ breaks: true, gfm: true });
|
marked.setOptions({ breaks: true, gfm: true });
|
||||||
view.innerHTML = marked.parse(text);
|
view.innerHTML = marked.parse(text);
|
||||||
|
} else if (isSvg) {
|
||||||
|
view.replaceChildren(buildSvgPanel(text));
|
||||||
} else {
|
} else {
|
||||||
view.textContent = text;
|
view.textContent = text;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -327,6 +327,18 @@ code {
|
||||||
border-color: var(--purple);
|
border-color: var(--purple);
|
||||||
background: rgba(203, 166, 247, 0.08);
|
background: rgba(203, 166, 247, 0.08);
|
||||||
}
|
}
|
||||||
|
/* SVG file preview (issue #188) */
|
||||||
|
.svg-host { margin-top: 0.5em; }
|
||||||
|
.svg-render {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
margin: 0 auto;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
/* checkerboard so transparent regions of the SVG read clearly */
|
||||||
|
background: repeating-conic-gradient(#313244 0% 25%, #1e1e2e 0% 50%) 50% / 18px 18px;
|
||||||
|
}
|
||||||
.approval-tabs {
|
.approval-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.4em;
|
gap: 0.4em;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue