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
|
|
@ -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
|
titled header, a close button, and a scrollable body. Closes on
|
||||||
the button, a backdrop click, or `Escape`. `Panel.open(title,
|
the button, a backdrop click, or `Escape`. `Panel.open(title,
|
||||||
node)` swaps the body; the JS builders for file previews,
|
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. File
|
||||||
file previews (`.md` / `.markdown`) render through the vendored
|
previews are type-aware:
|
||||||
`marked` bundle (`GET /static/marked.js`) into a `.md` block;
|
|
||||||
SVG previews (`.svg`) get a `rendered` / `source` tabbed view —
|
- **Markdown** (`.md` / `.markdown`) — a `rendered` / `plain`
|
||||||
`rendered` shows the image via an `<img>` `data:` URI (the
|
tabbed view: `rendered` (default) is the vendored `marked`
|
||||||
browser's secure static mode, so an untrusted SVG can't run
|
bundle (`GET /static/marked.js`), `plain` is the raw source.
|
||||||
scripts), `source` shows the raw markup; other files stay raw
|
- **SVG** (`.svg`) — a `rendered` / `source` tabbed view;
|
||||||
in a `<pre>`.
|
`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.
|
||||||
|
- **Raster images** (`.png` / `.jpg` / `.gif` / `.webp` /
|
||||||
|
`.bmp` / `.ico` / `.avif`) — render as an `<img>` 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 `<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,31 +91,21 @@
|
||||||
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 2-tab file preview: a "rendered" tab (default) + a raw-text tab.
|
||||||
// a "source" tab with the raw markup. The image is loaded via an
|
// `renderRendered()` produces the rendered-tab node fresh on each
|
||||||
// <img> data: URI — <img>-loaded SVG runs in the browser's secure
|
// switch; `plainText` backs the raw tab; `plainLabel` names it.
|
||||||
// static mode (no scripts, no external fetches), so an untrusted
|
function buildTabbedPreview(renderRendered, plainText, plainLabel) {
|
||||||
// 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 tabs = el('div', { class: 'diff-base-tabs' });
|
||||||
const host = el('div', { class: 'svg-host' });
|
const host = el('div', { class: 'preview-host' });
|
||||||
function show(mode) {
|
function show(mode) {
|
||||||
for (const b of tabs.children) {
|
for (const b of tabs.children) {
|
||||||
b.classList.toggle('active', b.dataset.mode === mode);
|
b.classList.toggle('active', b.dataset.mode === mode);
|
||||||
}
|
}
|
||||||
if (mode === 'source') {
|
host.replaceChildren(mode === 'plain'
|
||||||
host.replaceChildren(el('pre', { class: 'path-preview-body' }, text));
|
? el('pre', { class: 'path-preview-body' }, plainText)
|
||||||
return;
|
: renderRendered());
|
||||||
}
|
|
||||||
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']]) {
|
for (const [mode, label] of [['rendered', 'rendered'], ['plain', plainLabel]]) {
|
||||||
const b = el('button',
|
const b = el('button',
|
||||||
{ type: 'button', class: 'diff-base-tab', 'data-mode': mode }, label);
|
{ type: 'button', class: 'diff-base-tab', 'data-mode': mode }, label);
|
||||||
b.addEventListener('click', () => show(mode));
|
b.addEventListener('click', () => show(mode));
|
||||||
|
|
@ -124,26 +114,62 @@
|
||||||
show('rendered');
|
show('rendered');
|
||||||
return el('div', {}, tabs, host);
|
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.
|
// Lazy-load `path` from /api/state-file into the side panel.
|
||||||
// Markdown → `marked` into a `.md` block; SVG → rendered/source
|
// Markdown + SVG get a rendered/plain tabbed view; raster images
|
||||||
// tabbed view; every other file stays raw text in a <pre>.
|
// render as an <img>; every other file stays raw text in a <pre>.
|
||||||
async function openFilePanel(path) {
|
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 isMd = /\.(md|markdown)$/i.test(path);
|
||||||
const isSvg = /\.svg$/i.test(path);
|
const isSvg = /\.svg$/i.test(path);
|
||||||
const view = isMd ? el('div', { class: 'md' })
|
const view = el('div');
|
||||||
: isSvg ? el('div')
|
|
||||||
: el('pre', { class: 'path-preview-body' });
|
|
||||||
view.textContent = '(fetching…)';
|
view.textContent = '(fetching…)';
|
||||||
Panel.open('↳ ' + path, view);
|
Panel.open('↳ ' + path, view);
|
||||||
try {
|
try {
|
||||||
const text = await fetchStateFile(path);
|
const text = await fetchStateFile(path);
|
||||||
if (isMd && window.marked && typeof window.marked.parse === 'function') {
|
if (isSvg) {
|
||||||
marked.setOptions({ breaks: true, gfm: true });
|
view.replaceChildren(buildTabbedPreview(() => svgImage(text), text, 'source'));
|
||||||
view.innerHTML = marked.parse(text);
|
} else if (isMd) {
|
||||||
} else if (isSvg) {
|
view.replaceChildren(buildTabbedPreview(() => mdNode(text), text, 'plain'));
|
||||||
view.replaceChildren(buildSvgPanel(text));
|
|
||||||
} else {
|
} else {
|
||||||
view.textContent = text;
|
view.replaceChildren(el('pre', { class: 'path-preview-body' }, text));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
view.textContent = 'error: ' + (e.message || e);
|
view.textContent = 'error: ' + (e.message || e);
|
||||||
|
|
|
||||||
|
|
@ -327,16 +327,16 @@ 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) */
|
/* Image / tabbed file preview (issues #188, #192) */
|
||||||
.svg-host { margin-top: 0.5em; }
|
.preview-host { margin-top: 0.5em; }
|
||||||
.svg-render {
|
.img-preview {
|
||||||
display: block;
|
display: block;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 4px;
|
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;
|
background: repeating-conic-gradient(#313244 0% 25%, #1e1e2e 0% 50%) 50% / 18px 18px;
|
||||||
}
|
}
|
||||||
.approval-tabs {
|
.approval-tabs {
|
||||||
|
|
|
||||||
|
|
@ -1292,6 +1292,20 @@ async fn get_state_file(
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(e) => return error_response(&format!("state-file: read {}: {e}", canonical.display())),
|
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 truncated = bytes.len() > MAX_BYTES;
|
||||||
let body_bytes = if truncated { &bytes[..MAX_BYTES] } else { &bytes[..] };
|
let body_bytes = if truncated { &bytes[..MAX_BYTES] } else { &bytes[..] };
|
||||||
let mut body = String::from_utf8_lossy(body_bytes).into_owned();
|
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", "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 {
|
async fn api_reminders(State(state): State<AppState>) -> Response {
|
||||||
match state.coord.broker.list_pending_reminders() {
|
match state.coord.broker.list_pending_reminders() {
|
||||||
Ok(rows) => axum::Json(rows).into_response(),
|
Ok(rows) => axum::Json(rows).into_response(),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue