From f42ba9b561257e610dbe5770742b121ea270e34a Mon Sep 17 00:00:00 2001 From: iris Date: Thu, 21 May 2026 21:49:15 +0200 Subject: [PATCH] dashboard file preview: markdown tabs + raster image rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 . /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 --- docs/web-ui.md | 23 +++++---- hive-c0re/assets/app.js | 86 ++++++++++++++++++++++------------ hive-c0re/assets/dashboard.css | 8 ++-- hive-c0re/src/dashboard.rs | 32 +++++++++++++ 4 files changed, 107 insertions(+), 42 deletions(-) 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(),