diff --git a/docs/web-ui.md b/docs/web-ui.md
index d5cf46d..8f08662 100644
--- a/docs/web-ui.md
+++ b/docs/web-ui.md
@@ -65,14 +65,21 @@ swipes in from the right — a singleton `#side-panel` with a
titled header, a close button, and a scrollable body. Closes on
the button, a backdrop click, or `Escape`. `Panel.open(title,
node)` swaps the body; the JS builders for file previews,
-approval diffs, and journald logs all render into it. Markdown
-file previews (`.md` / `.markdown`) render through the vendored
-`marked` bundle (`GET /static/marked.js`) into a `.md` block;
-SVG previews (`.svg`) get a `rendered` / `source` tabbed view —
-`rendered` shows the image via an `` `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 `
`. +approval diffs, and journald logs all render into it. File +previews are type-aware: + +- **Markdown** (`.md` / `.markdown`) — a `rendered` / `plain` + tabbed view: `rendered` (default) is the vendored `marked` + bundle (`GET /static/marked.js`), `plain` is the raw source. +- **SVG** (`.svg`) — a `rendered` / `source` tabbed view; + `rendered` shows the image via an `` `data:` URI (the + browser's secure static mode, so an untrusted SVG can't run + scripts), `source` shows the raw markup. +- **Raster images** (`.png` / `.jpg` / `.gif` / `.webp` / + `.bmp` / `.ico` / `.avif`) — render as an `
` pointed at + `/api/state-file`, which serves them as binary with their + real content-type (text files stay UTF-8-lossy `text/plain`). +- **Everything else** — raw text in a `
`. Both bind their listeners with `SO_REUSEADDR` via `tokio::net::TcpSocket` plus a retry loop on `AddrInUse` (12 tries, diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index c535c99..e23d222 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -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 - //data: URI —
-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
for an SVG, loaded via an
data: URI — + //
-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
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
. + // Markdown + SVG get a rendered/plain tabbed view; raster images + // render as an; every other file stays raw text in a
. 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); diff --git a/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css index 46c55f8..0e05cc9 100644 --- a/hive-c0re/assets/dashboard.css +++ b/hive-c0re/assets/dashboard.css @@ -327,16 +327,16 @@ code { border-color: var(--purple); background: rgba(203, 166, 247, 0.08); } -/* SVG file preview (issue #188) */ -.svg-host { margin-top: 0.5em; } -.svg-render { +/* Image / tabbed file preview (issues #188, #192) */ +.preview-host { margin-top: 0.5em; } +.img-preview { 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 */ + /* checkerboard so transparent regions of the image read clearly */ background: repeating-conic-gradient(#313244 0% 25%, #1e1e2e 0% 50%) 50% / 18px 18px; } .approval-tabs { diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 742a5c7..e66296a 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -1292,6 +1292,20 @@ async fn get_state_file( Ok(b) => b, Err(e) => return error_response(&format!("state-file: read {}: {e}", canonical.display())), }; + // Raster images: serve the raw bytes with their real content-type + // so the dashboard can render them in an. Not truncated — + // a clipped binary is corrupt, so over-cap images are rejected + // instead. (SVG stays on the text path: it's text, and the client + // renders it via a data: URI.) + if let Some(ct) = image_content_type(&canonical) { + if bytes.len() > MAX_BYTES { + return error_response(&format!( + "state-file: image {} is {size} bytes, over the {MAX_BYTES}-byte preview cap", + canonical.display() + )); + } + return ([("content-type", ct)], bytes).into_response(); + } let truncated = bytes.len() > MAX_BYTES; let body_bytes = if truncated { &bytes[..MAX_BYTES] } else { &bytes[..] }; let mut body = String::from_utf8_lossy(body_bytes).into_owned(); @@ -1302,6 +1316,24 @@ async fn get_state_file( ([("content-type", "text/plain; charset=utf-8")], body).into_response() } +/// Content-type for a raster image the dashboard can preview in an +/// `
`, keyed off the file extension. `None` for non-image, SVG, +/// and text files (SVG is served on the text path and rendered +/// client-side via a `data:` URI). +fn image_content_type(path: &Path) -> Option<&'static str> { + let ext = path.extension()?.to_str()?.to_ascii_lowercase(); + Some(match ext.as_str() { + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "bmp" => "image/bmp", + "ico" => "image/x-icon", + "avif" => "image/avif", + _ => return None, + }) +} + async fn api_reminders(State(state): State
) -> Response { match state.coord.broker.list_pending_reminders() { Ok(rows) => axum::Json(rows).into_response(),