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:
iris 2026-05-21 21:49:15 +02:00
parent 0884a54960
commit f42ba9b561
4 changed files with 107 additions and 42 deletions

View file

@ -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 <img>. 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
/// `<img>`, 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<AppState>) -> Response {
match state.coord.broker.list_pending_reminders() {
Ok(rows) => axum::Json(rows).into_response(),