From ca8ea4f73150e617153e9e30767f320f72488a1a Mon Sep 17 00:00:00 2001 From: Sam Keen Date: Thu, 27 Aug 2026 08:32:22 -0700 Subject: [PATCH] feat(ui): an embed reads as its link, and an image resource shows the image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two halves of the same complaint: a note that embeds a picture rendered the grammar instead of the picture, and selecting the picture itself offered only an OS handoff. **`![[file]]` reads as its link.** The reading view's wikilink rule now spans the embed marker rather than starting after it, so the `!` is consumed as grammar instead of surviving beside the anchor as prose. The `|`-part changes meaning with the marker — a label on a plain wikilink, a display *size* on an embed (`![[shot.png|400]]`) — and B2 does not size images, so an embed labels itself with its target and drops the hint. Before, `![[shot.png|400]]` rendered as `!400`: the unsupported half was eating the supported one. **An image resource shows the image.** `Vault::read_resource_bytes` is `read`'s non-note sibling, inventory-checked before the filesystem is touched (the posture *Open in system default* already took), so a link a note authored cannot turn it into "read any file this process can reach". The host encodes base64 for the JSON IPC — `import_file`'s encoding, the other direction — and the card turns it into the `data:` URL the CSP already admits. The picture replaces the *No viewer available* line, not the card: metadata, backlinks and the OS handoff stay, and they are the whole card again for a class with no viewer, for an image past `IMAGE_VIEWER_MAX_BYTES` (a memory bound — the URL is held while the card is open), and for a read that failed. That last one is deliberate: the card is the truth about the file whether or not its bytes can be read, so a failed read must not fail the navigation. Tests first, each failing before its fix: `embedlink.test.ts` goes through `renderMarkdown` rather than the extension object, because the bug was the `start` hook's anchoring and the extension alone would still have passed; `resourceview.test.ts` pins the card's three states and the `data:` rule; the core test proves the inventory check by asking for a note and for an uninventoried file that is really on disk. Known gap, deliberately left: in the *editor*, `![[file]]` is claimed by CodeMirror's own image rule before B2's wikilink parser sees the `[`, so an embed stays raw text there — byte-honest, but not yet mod-clickable. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 8 +-- crates/b2-core/src/vault.rs | 20 +++++++ crates/b2-core/tests/resources.rs | 34 +++++++++++ crates/b2-desktop/src/commands.rs | 18 ++++++ crates/b2-desktop/src/main.rs | 1 + ui/src/api.ts | 9 +++ ui/src/embedlink.test.ts | 87 ++++++++++++++++++++++++++++ ui/src/main.ts | 38 +++++++++++- ui/src/render.ts | 90 +++++++++++++++++++++++++---- ui/src/resourceview.test.ts | 96 +++++++++++++++++++++++++++++++ ui/src/state.ts | 8 +++ ui/style.css | 25 +++++++- 12 files changed, 416 insertions(+), 18 deletions(-) create mode 100644 ui/src/embedlink.test.ts create mode 100644 ui/src/resourceview.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 750604d..8c842cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -144,10 +144,10 @@ The **one typed API**; the CLI and the desktop host are its only clients (every module is called directly only by integration tests). Surface: lifecycle + indexing (`open` / `open_with_embedder` / `reindex` / `reindex_with_progress` / `plan_reindex` / `project` / `embed`), reads (`read` / `list_notes` / `list_resources` / `list_dirs` / `neighbors` / `explain` / -`explain_resource` / `search` / `search_evidence` / `similar` / `ask`), writes (`add_note` / -`create_note` / `create_dir` / `import_file` / `import_path` / `move_note` / `move_resource` / -`move_dir` / `link` / `write` / `write_frontmatter` / `delete_note` / `delete_resource` / -`delete_dir`). **Add operations when a command needs them; do not pre-build a broad surface.** The +`explain_resource` / `read_resource_bytes` / `search` / `search_evidence` / `similar` / +`ask`), writes (`add_note` / `create_note` / `create_dir` / `import_file` / `import_path` / +`move_note` / `move_resource` / `move_dir` / `link` / `write` / `write_frontmatter` / +`delete_note` / `delete_resource` / `delete_dir`). **Add operations when a command needs them; do not pre-build a broad surface.** The embedder is injected here: `open` defaults to the fake, `open_with_embedder` wires the real model. ### Data flows diff --git a/crates/b2-core/src/vault.rs b/crates/b2-core/src/vault.rs index 11a7843..41320b5 100644 --- a/crates/b2-core/src/vault.rs +++ b/crates/b2-core/src/vault.rs @@ -895,6 +895,26 @@ impl Vault { dirs::list_dirs(&self.root) } + /// Read a resource's **bytes** — [`read`](Self::read)'s non-note sibling, for the + /// viewers an adapter shows in place of the fallback card (an image, today). + /// + /// Inventory-checked *before* the filesystem is touched, the same posture the + /// desktop's *Open in system default* takes: the path must name a row the walk put + /// in `resources`, so this can never be talked into "read any file this process can + /// reach" by a link a note authored. Errors with [`Error::ResourceNotFound`] + /// otherwise — including for a note, which is not resource inventory. + /// + /// Reads whole, because every caller wants the whole file; a resource too large for + /// a viewer is the *adapter's* judgement (it knows the size from + /// [`explain_resource`](Self::explain_resource) before asking), not a rule the + /// engine imposes. + pub fn read_resource_bytes(&self, path: &str) -> Result> { + let _op = tracing::debug_span!(target: "b2::vault", "read_resource_bytes", path).entered(); + db::resource_detail(&self.conn, path)? + .ok_or_else(|| Error::ResourceNotFound(path.to_string()))?; + Ok(fs::read(self.root.join(path))?) + } + /// The fallback card's data for one resource: inventory metadata plus the /// backlinks panel, straight off the materialized graph. `path` is vault-relative /// (the adapters dispatch here via [`crate::resource::doc_kind`]); errors with diff --git a/crates/b2-core/tests/resources.rs b/crates/b2-core/tests/resources.rs index d85acf8..a8f62d1 100644 --- a/crates/b2-core/tests/resources.rs +++ b/crates/b2-core/tests/resources.rs @@ -525,6 +525,40 @@ fn explain_resource_carries_metadata_and_backlinks() { assert!(matches!(missing, Err(b2_core::Error::ResourceNotFound(_)))); } +/// The viewer's read: inventoried bytes come back verbatim; anything the walk never +/// put in the inventory refuses, so the op cannot be steered off the vault. +#[test] +fn read_resource_bytes_returns_the_file_and_refuses_the_uninventoried() { + let tmp = tempfile::TempDir::new().unwrap(); + common::golden_vault_copy(tmp.path()); + let vault = Vault::open(tmp.path()).unwrap(); + vault.project(false).unwrap(); + + let bytes = vault.read_resource_bytes("resources/diagram.png").unwrap(); + assert_eq!( + bytes, + fs::read(tmp.path().join("resources/diagram.png")).unwrap(), + "the viewer sees exactly the file on disk" + ); + + // A path the walk never met: no row, no read — whatever is on disk beside it. + fs::write(tmp.path().join("resources/unwalked.png"), b"not indexed").unwrap(); + assert!(matches!( + vault.read_resource_bytes("resources/unwalked.png"), + Err(b2_core::Error::ResourceNotFound(_)) + )); + + // A note is not resource inventory, and an escaping path names no row at all. + assert!(matches!( + vault.read_resource_bytes("notes/alpha.md"), + Err(b2_core::Error::ResourceNotFound(_)) + )); + assert!(matches!( + vault.read_resource_bytes("../outside.png"), + Err(b2_core::Error::ResourceNotFound(_)) + )); +} + /// A resource move rewrites inbound links in BOTH syntaxes, each keeping its own /// convention (note-relative stays relative, vault-root stays root), moves the /// file, and leaves the index equal to a fresh rebuild. diff --git a/crates/b2-desktop/src/commands.rs b/crates/b2-desktop/src/commands.rs index d453151..843df4e 100644 --- a/crates/b2-desktop/src/commands.rs +++ b/crates/b2-desktop/src/commands.rs @@ -143,6 +143,24 @@ pub fn explain_resource( Ok(vault.explain_resource(&path)?) } +/// A resource's **bytes**, base64, for the viewer the card shows in place of the +/// *No viewer available* fallback (an image, today). +/// +/// base64 because the IPC is JSON and these are arbitrary bytes — the same encoding +/// [`import_file`] takes in the other direction, and what the webview turns straight +/// into the `data:` URL the CSP already admits (`img-src 'self' data:`). It carries a +/// copy of the file, so the frontend asks only for what it will actually display: the +/// size is on the card's view before this is ever called. +/// +/// The path is re-validated host-side against the inventory (in the façade), because +/// the note that authored the link is untrusted input (ADR-0016) — the caller passes, +/// the host validates, exactly as [`open_resource`] does. +#[tauri::command(async)] +pub fn read_resource(state: State<'_, AppState>, path: String) -> Result { + let vault = open_read(state.inner())?; + Ok(BASE64.encode(vault.read_resource_bytes(&path)?)) +} + /// *Open in system default* on the fallback card — an **OS handoff**, never /// in-webview execution (spec §6 security posture). Host infrastructure like the /// folder dialog: the webview holds no opener permission; this command validates diff --git a/crates/b2-desktop/src/main.rs b/crates/b2-desktop/src/main.rs index fd72bfa..eb37567 100644 --- a/crates/b2-desktop/src/main.rs +++ b/crates/b2-desktop/src/main.rs @@ -441,6 +441,7 @@ fn main() { commands::list_dirs, commands::list_resources, commands::explain_resource, + commands::read_resource, commands::open_resource, commands::open_external, commands::clipboard_text, diff --git a/ui/src/api.ts b/ui/src/api.ts index a6934ef..df35b42 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -100,6 +100,15 @@ export const api = { explainResource: (path: string): Promise => invoke("explain_resource", { path }), + /** + * A resource's bytes, base64 — what the in-app viewer shows in place of the + * *No viewer available* card. This carries a copy of the whole file across the IPC, + * so ask only for what will actually be displayed; the size is on + * `explainResource`'s view before this is called. The host re-validates the path + * against the inventory. + */ + readResource: (path: string): Promise => invoke("read_resource", { path }), + /** * *Open in system default* — an OS handoff performed host-side (the webview holds * no opener permission); the host validates the path against the inventory first. diff --git a/ui/src/embedlink.test.ts b/ui/src/embedlink.test.ts new file mode 100644 index 0000000..c25570b --- /dev/null +++ b/ui/src/embedlink.test.ts @@ -0,0 +1,87 @@ +// Tests for the `[[wikilink]]` / `![[embed]]` grammar in the reading view (render.ts's +// `marked` extension). Run directly: +// node --experimental-strip-types src/embedlink.test.ts +// Hand-rolled asserts, the sanitize.test.ts idiom. +// +// Through `renderMarkdown` rather than the extension object, because the claim under test +// is what a note *looks like on screen*, and that is the composition of the extension, the +// tokenizer ordering against marked's own image rule, and the sanitizer hook. The +// extension in isolation would still pass with the `start` hook mis-anchored, which is +// exactly the bug that put a stray `!` in front of every embed. +// +// jsdom supplies the DOM DOMPurify parses with — a devDependency, nothing ships it. + +import { JSDOM } from "jsdom"; +import { renderMarkdown } from "./render.ts"; + +(globalThis as unknown as { window: unknown }).window = new JSDOM("").window; + +let checks = 0; + +function assertHas(haystack: string, needle: string, label: string): void { + if (!haystack.includes(needle)) { + throw new Error(`${label}\n missing: ${needle}\n in: ${haystack}`); + } + checks++; +} + +function assertNot(haystack: string, needle: string, label: string): void { + if (haystack.includes(needle)) { + throw new Error(`${label}\n found: ${needle}\n in: ${haystack}`); + } + checks++; +} + +// --- the plain wikilink: target carried, label shown ---------------------------------- + +const plain = renderMarkdown("see [[concepts/memory]] today\n"); +assertHas(plain, 'data-target="concepts/memory"', "a bare wikilink carries its target"); +assertHas(plain, ">concepts/memory", "…and labels itself with it"); + +const labelled = renderMarkdown("see [[concepts/memory|how it works]]\n"); +assertHas(labelled, 'data-target="concepts/memory"', "a `|` alias leaves the target alone"); +assertHas(labelled, ">how it works", "…and is what the reader sees"); + +// --- the embed form: the `!` is markup, not text -------------------------------------- +// +// `![[file]]` is the same link with the core's embed marker in front (link.rs). B2 has no +// inline viewer for it yet, so it renders as the link — but the `!` is *grammar*, and a +// grammar character that leaks into the prose is a rendering bug, not a partial feature. + +const embed = renderMarkdown("![[__Attachments/Screenshot 1.png]]\n"); +assertNot(embed, ">!", "the embed marker never reaches the reader as text"); +assertNot(embed, "

!", "…not even at the head of its own paragraph"); +assertHas(embed, 'data-target="__Attachments/Screenshot 1.png"', "the embed carries its target"); +assertHas(embed, ">__Attachments/Screenshot 1.png", "…and labels itself with it"); + +// --- an embed's `|`-part is a size hint, not a label ---------------------------------- +// +// `![[img.png|400]]` asks for a 400px-wide render. B2 does not size images, and the +// unsupported half must not eat the supported one: dropping the hint is right, replacing +// the filename with "400" is the bug this pins. + +const sized = renderMarkdown("![[__Attachments/Screenshot 2.png|400]]\n"); +assertHas(sized, 'data-target="__Attachments/Screenshot 2.png"', "a sized embed keeps its target"); +assertHas(sized, ">__Attachments/Screenshot 2.png", "…and still reads as the filename"); +assertNot(sized, ">400<", "the size hint is dropped, not shown"); +assertNot(sized, "

!", "…and the marker is still markup"); + +// --- neighbours the grammar must not swallow ------------------------------------------ + +assertHas( + renderMarkdown("![alt text](resources/diagram.png)\n"), + ' void): Promise { } } +/** + * The open resource's picture, or null when there isn't one to show. + * + * Null for every class without an in-app viewer, for an image too large to hold on + * screen (`IMAGE_VIEWER_MAX_BYTES` — the card's size is already in hand, so the decision + * costs no IPC), and for a read that failed. That last one is deliberate: the card is the + * truth about the file whether or not its bytes can be read, so a failed read falls back + * to *Open in system default* rather than failing the navigation and leaving the pane on + * the previous document. + */ +async function loadResourceImage(r: ResourceExplainView): Promise { + if (r.class !== "image" || r.size > IMAGE_VIEWER_MAX_BYTES) return null; + try { + return imageDataUrl(r.path, await api.readResource(r.path)); + } catch { + return null; + } +} + /** The resource sibling of `loadNote` — same core/commit split, for `openResource` * and back/forward. Discovery doesn't apply (resources have no chunks until file-type * slice 3), so the side pane clears. */ @@ -607,6 +630,7 @@ async function loadResource(path: string, commit: (path: string) => void): Promi try { const resource = await api.explainResource(path); state.currentResource = resource; + state.resourceImage = await loadResourceImage(resource); state.current = null; commit(resource.path); expandAncestors(resource.path); @@ -1593,7 +1617,9 @@ async function executeMove(node: TreeNodeRef, to: string): Promise { state.current = await api.readNote(openNotePath); } if (openResourcePath !== null) { - state.currentResource = await api.explainResource(openResourcePath); + const moved = await api.explainResource(openResourcePath); + state.currentResource = moved; + state.resourceImage = await loadResourceImage(moved); } await loadNotes(); if (openNotePath !== null) await refreshDiscovery(); // backlinks may show new paths @@ -1701,6 +1727,7 @@ async function executeDelete(node: TreeNodeRef): Promise { if (affected) { state.current = null; state.currentResource = null; + state.resourceImage = null; state.similar = []; state.connections = []; state.resourceLinks = []; @@ -2641,6 +2668,7 @@ async function switchVault(): Promise { state.notesTotal = info.notes_total; state.current = null; state.currentResource = null; + state.resourceImage = null; state.similar = []; state.connections = []; state.resourceLinks = []; @@ -3918,7 +3946,13 @@ async function reconcileExternalChange(): Promise { const cur = state.currentResource; try { const fresh = await api.explainResource(cur.path); - if (state.currentResource?.path === cur.path) state.currentResource = fresh; + // The bytes are re-read too: an external edit can rewrite the picture in place + // without the path ever changing, and a stale `data:` URL would show the old one. + const picture = await loadResourceImage(fresh); + if (state.currentResource?.path === cur.path) { + state.currentResource = fresh; + state.resourceImage = picture; + } } catch { if (state.currentResource?.path === cur.path) { flash("This file is no longer on disk — it was moved or removed."); diff --git a/ui/src/render.ts b/ui/src/render.ts index a3510ec..4d7a644 100644 --- a/ui/src/render.ts +++ b/ui/src/render.ts @@ -89,21 +89,34 @@ export { escapeHtml }; // A `[[target]]` / `[[target|label]]` wikilink becomes an in-app anchor carrying the // raw target; main.ts delegates a click on `.wikilink` to open that note. This is the // MVP's in-app navigation (spec §4) — the buffer stays byte-honest Markdown. +// +// `![[target]]` is the **embed** form of the same link — the core reads the `!` as the +// embed marker and records it on the edge (`link.rs`). B2 has no inline embed viewer, so +// it renders the link; but the marker is *grammar*, so it must be consumed rather than +// left beside the anchor as a stray `!`. The `|`-part changes meaning with the marker: on +// a plain wikilink it is the display label, on an embed it is a display **size** +// (`![[shot.png|400]]`), which B2 does not support — so an embed labels itself with its +// target and drops the hint, rather than showing "400" where the filename belongs. const wikilink: TokenizerAndRendererExtension = { name: "wikilink", level: "inline", start(src: string) { const i = src.indexOf("[["); - return i < 0 ? undefined : i; + if (i < 0) return undefined; + // Back up over the embed marker: this offset is where marked stops emitting plain + // text, so anchoring it at the `[[` hands the reader the `!` before we ever tokenize. + return i > 0 && src[i - 1] === "!" ? i - 1 : i; }, tokenizer(src: string) { - const m = /^\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/.exec(src); + const m = /^(!?)\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/.exec(src); if (!m) return undefined; + const embed = m[1] === "!"; + const target = m[2].trim(); return { type: "wikilink", raw: m[0], - target: m[1].trim(), - label: (m[2] ?? m[1]).trim(), + target, + label: embed ? target : (m[3] ?? m[2]).trim(), } as Tokens.Generic; }, renderer(token: Tokens.Generic) { @@ -472,6 +485,50 @@ function noteBarHtml(state: AppState, note: NoteView): string { `; } +// --- the image viewer (file-type slice 2) --------------------------------------------- + +/** Extension → MIME type for the image classes the card renders in place. Extension-only, + * the same rule the core classifies on (`resource.rs`) and the same table of extensions — + * no content sniffing, so a mislabeled file degrades to a broken `` rather than + * being guessed at. **Change it with `ResourceClass::Image`**: a class the core calls an + * image and this table doesn't know reaches the card, asks for its bytes, and then has + * nowhere to put them. */ +const IMAGE_MIME: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + svg: "image/svg+xml", + avif: "image/avif", +}; + +/** + * How large an image the card will pull across the IPC and hold on screen. + * + * The bytes cross as base64 (a third larger) and the `data:` URL lives in the webview's + * heap for as long as the card is open, so this is a memory bound, not a taste one. Past + * it the card keeps the *Open in system default* handoff it has always had, which costs + * nothing and shows the file at full fidelity in a real image app. Screenshots — what a + * vault actually accumulates — are a couple of megabytes. + */ +export const IMAGE_VIEWER_MAX_BYTES = 25 * 1024 * 1024; + +/** + * A resource's base64 bytes as the `src` an `` can use, or `null` when the + * extension names no image this webview renders. + * + * A `data:` URL rather than a path, because the webview's origin is the *app bundle*, + * not the vault: there is no URL by which it could fetch a vault file, and the CSP + * admits exactly this one shape (`img-src 'self' data:` in `tauri.conf.json`) — so the + * bytes travel through the same command seam as everything else the host lends the UI. + */ +export function imageDataUrl(path: string, base64: string): string | null { + const ext = path.includes(".") ? (path.split(".").pop() ?? "").toLowerCase() : ""; + const mime = IMAGE_MIME[ext]; + return mime ? `data:${mime};base64,${base64}` : null; +} + /** Human-readable byte count for the card ("67 B", "1.4 KB", "3.2 MB"). */ function formatSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; @@ -479,13 +536,18 @@ function formatSize(bytes: number): string { return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } -// The resource **fallback card** (file-type slice 1, spec §6): selecting any file in -// the tree opens *something*. Slice 1 shows the card for every resource class — +// The resource **card** (spec §6): selecting any file in the tree opens *something* — // filename, class, size, modified, content hash — plus the backlinks panel (which // notes reference this file, with their authored captions) and one action, *Open in -// system default* (an OS handoff performed host-side). Per-class viewers replace the -// card's body in slice 2; the card remains the `binary` catch-all. -function resourceCardHtml(r: ResourceExplainView): string { +// system default* (an OS handoff performed host-side). +// +// `image` is the one class with a viewer so far: `image` is the bytes main.ts fetched +// for it, and it *replaces* the "no viewer" line rather than the card, so the metadata, +// the backlinks and the OS handoff stay put — the handoff is still how you get to a real +// image app, and it is the whole card when the file is too large to hold on screen +// (`IMAGE_VIEWER_MAX_BYTES`) or its bytes could not be read. Every other class is still +// the fallback card, and `binary` is its permanent catch-all. +function resourceCardHtml(r: ResourceExplainView, image: string | null): string { const modified = r.mtime ? new Date(r.mtime * 1000).toLocaleString() : "—"; const backlinks = r.backlinks.length ? `

${r.backlinks @@ -505,6 +567,12 @@ function resourceCardHtml(r: ResourceExplainView): string { .join("")}
` : `

No notes link to this file yet.

`; const name = r.path.split("/").pop() ?? r.path; + // The alt text is the filename: the `

` right above already says it, so a screen + // reader that reads both is repeating itself rather than being told nothing — and B2 + // has no description of the picture to offer that would be truer than its name. + const viewer = image + ? `${escapeHtml(name)}` + : `

No viewer available for this file type yet.

`; return `

${escapeHtml(name)}

@@ -513,7 +581,7 @@ function resourceCardHtml(r: ResourceExplainView): string { )} · modified ${escapeHtml(modified)}
-

No viewer available for this file type yet.

+ ${viewer} @@ -527,7 +595,7 @@ function resourceCardHtml(r: ResourceExplainView): string { } export function notePaneHtml(state: AppState): string { - if (state.currentResource) return resourceCardHtml(state.currentResource); + if (state.currentResource) return resourceCardHtml(state.currentResource, state.resourceImage); const n = state.current; if (n && state.graphOpen) return graphPaneHtml(state, n); if (n) { diff --git a/ui/src/resourceview.test.ts b/ui/src/resourceview.test.ts new file mode 100644 index 0000000..9214870 --- /dev/null +++ b/ui/src/resourceview.test.ts @@ -0,0 +1,96 @@ +// Tests for the resource card's **image viewer** (render.ts) — the `data:` URL rule and +// what the card shows in each of its states. Run directly: +// node --experimental-strip-types src/resourceview.test.ts +// Hand-rolled asserts, the sanitize.test.ts / render.test.ts idiom. +// +// The card is chrome, not note content, so it never passes through the sanitizer — but +// `notePaneHtml` is one function over the whole pane and the note branch does, so jsdom +// is here for the same reason it is in render.test.ts. + +import { JSDOM } from "jsdom"; +import { imageDataUrl, IMAGE_VIEWER_MAX_BYTES, notePaneHtml } from "./render.ts"; +import { state, type AppState } from "./state.ts"; +import type { ResourceExplainView } from "./types.ts"; + +(globalThis as unknown as { window: unknown }).window = new JSDOM("").window; + +let checks = 0; + +function assert(cond: boolean, msg: string): void { + if (!cond) throw new Error(msg); + checks++; +} + +function resource(over: Partial = {}): ResourceExplainView { + return { + path: "__Attachments/Screenshot 1.png", + class: "image", + size: 1_200_000, + mtime: 0, + content_hash: "e".repeat(64), + backlinks: [], + ...over, + }; +} + +/** The pane rendered for one resource, with `image` as its loaded picture. */ +function pane(r: ResourceExplainView, image: string | null): string { + const s: AppState = { ...state, currentResource: r, resourceImage: image, current: null }; + return notePaneHtml(s); +} + +// --- imageDataUrl: extension → the one src shape the CSP admits ----------------------- + +assert( + imageDataUrl("__Attachments/Shot.png", "AAAA") === "data:image/png;base64,AAAA", + "a png's bytes become a png data URL", +); +assert( + imageDataUrl("a/b/PHOTO.JPEG", "AAAA") === "data:image/jpeg;base64,AAAA", + "the extension is matched case-insensitively, and jpeg is jpg's MIME type", +); +assert( + imageDataUrl("drawing.svg", "AAAA") === "data:image/svg+xml;base64,AAAA", + "svg is an image the webview renders, so it gets its own MIME type", +); +assert(imageDataUrl("notes/paper.pdf", "AAAA") === null, "a class with no viewer gets no URL"); +assert(imageDataUrl("Makefile", "AAAA") === null, "…and neither does an extensionless file"); + +// --- the card's three states ---------------------------------------------------------- + +const shown = pane(resource(), "data:image/png;base64,AAAA"); +assert(shown.includes(' 5 * 1024 * 1024, + "the bound clears the screenshots a vault actually accumulates", +); +assert( + IMAGE_VIEWER_MAX_BYTES < 200 * 1024 * 1024, + "…and is small enough to still be a bound (base64 inflates it by a third in the heap)", +); + +console.log(`resourceview.test.ts: ${checks} checks passed`); diff --git a/ui/src/state.ts b/ui/src/state.ts index 9d9d2fd..1bda5b6 100644 --- a/ui/src/state.ts +++ b/ui/src/state.ts @@ -171,6 +171,13 @@ export interface AppState { * selecting either kind clears the other — the note pane shows one document). */ currentResource: ResourceExplainView | null; + /** + * The open resource's bytes as a `data:` URL, when its class has an in-app viewer and + * the read succeeded — otherwise null, and the card shows its *Open in system default* + * fallback. Loaded alongside `currentResource` and cleared with it, so the pane can + * never paint one document's picture over another's card. + */ + resourceImage: string | null; /** Whether the note pane's frontmatter drawer is expanded (sticky across notes). */ frontmatterOpen: boolean; /** @@ -379,6 +386,7 @@ export const state: AppState = { deleteTarget: null, current: null, currentResource: null, + resourceImage: null, frontmatterOpen: false, fmEditing: false, sourceOpen: false, diff --git a/ui/style.css b/ui/style.css index 3bc8c8e..df0a230 100644 --- a/ui/style.css +++ b/ui/style.css @@ -2660,7 +2660,7 @@ button.move-dest:hover { z-index: 30; } -/* --- resource fallback card (file-type slice 1) -------------------------------- */ +/* --- resource card + its viewers ----------------------------------------------- */ /* The bar-less pane state: no top bar supplies headroom, so the card brings its own (the note pane itself has no top padding — see .note-pane). */ @@ -2681,6 +2681,29 @@ button.move-dest:hover { margin: 0; } +/* The image viewer, in the "no viewer" line's place. Shown at its own size up to the + pane's width, never scaled *up*: a 64px icon blown across the pane is a worse view of + it than the icon. The checkerboard is what makes a transparent PNG legible in either + theme without the card guessing a background colour for it. */ +.resource-image { + max-width: 100%; + height: auto; + border-radius: 8px; + border: 1px solid var(--border); + background-color: var(--surface); + background-image: + linear-gradient(45deg, rgba(128, 128, 128, 0.14) 25%, transparent 25%), + linear-gradient(-45deg, rgba(128, 128, 128, 0.14) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, rgba(128, 128, 128, 0.14) 75%), + linear-gradient(-45deg, transparent 75%, rgba(128, 128, 128, 0.14) 75%); + background-size: 16px 16px; + background-position: + 0 0, + 0 8px, + 8px -8px, + -8px 0; +} + .resource-open { border: 1px solid var(--border); background: var(--surface);