From 7c599a8b975f236038b69ede2a93a91e2b159a8c Mon Sep 17 00:00:00 2001 From: Adam Matan Date: Fri, 10 Jul 2026 14:20:54 -0400 Subject: [PATCH 1/2] feat: image and PDF preview in the file pane Clicking an image or PDF in the file tree previously opened it as a code-editor tab and failed with a generic "not valid UTF-8" error. Route those extensions to a new read-only PreviewPane (img/embed fed a base64 data URL) instead of CodeMirror, reusing the existing task_file_read_base64 command (added for markdown image preview) instead of introducing a parallel one. preview_mime_for_ext extends image_mime_for_ext with a PDF case, kept as a separate function so image_mime_for_ext's existing image-only contract (and its test asserting .pdf -> None) stays intact. task_file_read_base64 is split into a thin command wrapper plus a task_file_read_base64_for_task helper (mirroring task_file_diff_sides_for_task), so it's testable end-to-end with an in-memory Task instead of load_tasks(): root-file read+decode roundtrip, member-composition path resolution, known_fp short-circuit, and non-previewable-extension rejection. Loosens object-src in the CSP from 'none' to 'self' data: so can render PDF data URLs. This is a deliberate, scoped exception alongside the existing img-src https: one. --- src-tauri/src/lib.rs | 179 +++++++++++++++++++--------- src-tauri/tauri.conf.json | 2 +- src/components/task/PreviewPane.tsx | 83 +++++++++++++ src/components/task/TaskView.tsx | 12 +- src/lib/ipc.ts | 13 +- src/lib/previewPaths.test.ts | 21 ++++ src/lib/previewPaths.ts | 21 ++++ 7 files changed, 268 insertions(+), 63 deletions(-) create mode 100644 src/components/task/PreviewPane.tsx create mode 100644 src/lib/previewPaths.test.ts create mode 100644 src/lib/previewPaths.ts diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c6865b2..3186fee 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4963,16 +4963,16 @@ fn task_file_read(id: String, path: String) -> Result { String::from_utf8(bytes).map_err(|_| "file is not valid UTF-8".to_string()) } -/// Mime type by extension for images the markdown preview may embed. Kept in -/// sync by hand with `BINARY_LINK_RE` in markdownPaths.ts (the frontend link -/// handler's "editor can't render this, reveal it in the file manager -/// instead" list) — that list is broader (adds archives/media), this one is -/// the image-only subset the base64 channel accepts. Unknown extensions are -/// rejected here so this stays an image channel, not a generic binary read -/// wider than the preview needs. SVG is safe in this context: the preview -/// only ever renders it via ``, where embedded scripts -/// never execute. -fn image_mime_for_ext(p: &Path) -> Option<&'static str> { +/// Mime type by extension for images/PDFs the markdown preview or file-tree +/// preview pane may embed. Kept in sync by hand with `BINARY_LINK_RE` in +/// markdownPaths.ts (the frontend link handler's "editor can't render this, +/// reveal it in the file manager instead" list) — that list is broader (adds +/// archives/media), this one is the subset the base64 channel accepts. +/// Unknown extensions are rejected here so this stays a preview channel, not +/// a generic binary read wider than the preview needs. SVG is safe in this +/// context: the preview only ever renders it via ``, +/// where embedded scripts never execute. +fn preview_mime_for_ext(p: &Path) -> Option<&'static str> { match p.extension()?.to_str()?.to_ascii_lowercase().as_str() { "png" => Some("image/png"), "jpg" | "jpeg" => Some("image/jpeg"), @@ -4982,6 +4982,7 @@ fn image_mime_for_ext(p: &Path) -> Option<&'static str> { "bmp" => Some("image/bmp"), "ico" => Some("image/x-icon"), "avif" => Some("image/avif"), + "pdf" => Some("application/pdf"), _ => None, } } @@ -5003,44 +5004,52 @@ struct Base64Read { fp: String, } -/// Read a workspace image as base64 for the markdown preview, or confirm -/// it's unchanged since `known_fp` (see `Base64Read`). Same member-aware -/// resolution + worktree containment as `task_file_read`, plus an -/// image-extension whitelist and a 10 MB cap. Async + spawn_blocking because -/// a multi-MB read IS the heavy-IO case the IPC discipline targets (unlike -/// the 2 MB-capped text read above). +/// Same member-aware resolution + worktree containment as `task_file_read`, +/// plus an image/PDF extension whitelist and a 20 MB cap. Split from the +/// `#[tauri::command]` wrapper (mirroring `task_file_diff_sides_for_task`) +/// so tests can exercise it with an in-memory `Task` instead of +/// `load_tasks()`. +fn task_file_read_base64_for_task(w: &Task, path: &str, known_fp: Option<&str>) -> Result { + use base64::Engine as _; + let (cwd, rel) = resolve_task_git_path(w, path)?; + let abs = safe_task_path(&cwd, &rel)?; + let mime = preview_mime_for_ext(&abs).ok_or_else(|| format!("not previewable: {path}"))?; + // Cheap pre-read stat: if it matches what the caller already has + // cached, skip the read + base64 encode entirely. Only bother when + // there's a known_fp to compare against — a first load (no cache + // yet) has nothing to match, so skip this stat rather than pay for + // one that can never short-circuit anything. An empty current fp + // means the file is missing/unreadable — always fall through to the + // real read below so its error path (not a silent "unchanged") fires. + if let Some(known) = known_fp { + let current_fp = file_fp(&abs); + if !current_fp.is_empty() && known == current_fp { + return Ok(Base64Read { unchanged: true, mime: None, data: None, fp: current_fp }); + } + } + let bytes = read_capped_file(&abs, 20_000_000)?; + // Re-stat AFTER the read so `fp` is correlated with the bytes just + // returned (not the pre-read snapshot, which a concurrent write + // could have already invalidated). + let fp = file_fp(&abs); + Ok(Base64Read { + unchanged: false, + mime: Some(mime.to_string()), + data: Some(base64::engine::general_purpose::STANDARD.encode(&bytes)), + fp, + }) +} + +/// Read a task image or PDF as base64 for the markdown preview / file-tree +/// preview pane, or confirm it's unchanged since `known_fp` (see +/// `Base64Read`). Async + spawn_blocking because a multi-MB read IS the +/// heavy-IO case the IPC discipline targets (unlike the 2 MB-capped text +/// read above). #[tauri::command] async fn task_file_read_base64(id: String, path: String, known_fp: Option) -> Result { tauri::async_runtime::spawn_blocking(move || { - use base64::Engine as _; - let w = load_tasks().into_iter().find(|w| w.id == id).ok_or("no ws")?; - let (cwd, rel) = resolve_task_git_path(&w, &path)?; - let abs = safe_task_path(&cwd, &rel)?; - let mime = image_mime_for_ext(&abs).ok_or_else(|| format!("not an image: {path}"))?; - // Cheap pre-read stat: if it matches what the caller already has - // cached, skip the read + base64 encode entirely. Only bother when - // there's a known_fp to compare against — a first load (no cache - // yet) has nothing to match, so skip this stat rather than pay for - // one that can never short-circuit anything. An empty current fp - // means the file is missing/unreadable — always fall through to the - // real read below so its error path (not a silent "unchanged") fires. - if let Some(known) = known_fp.as_deref() { - let current_fp = file_fp(&abs); - if !current_fp.is_empty() && known == current_fp { - return Ok(Base64Read { unchanged: true, mime: None, data: None, fp: current_fp }); - } - } - let bytes = read_capped_file(&abs, 10_000_000)?; - // Re-stat AFTER the read so `fp` is correlated with the bytes just - // returned (not the pre-read snapshot, which a concurrent write - // could have already invalidated). - let fp = file_fp(&abs); - Ok(Base64Read { - unchanged: false, - mime: Some(mime.to_string()), - data: Some(base64::engine::general_purpose::STANDARD.encode(&bytes)), - fp, - }) + let w = load_tasks().into_iter().find(|w| w.id == id).ok_or("no task")?; + task_file_read_base64_for_task(&w, &path, known_fp.as_deref()) }) .await .map_err(|e| e.to_string())? @@ -8473,20 +8482,80 @@ mod tests { } #[test] - fn image_mime_for_ext_known_extensions() { - assert_eq!(image_mime_for_ext(Path::new("a/b.png")), Some("image/png")); - assert_eq!(image_mime_for_ext(Path::new("shot.JPG")), Some("image/jpeg")); - assert_eq!(image_mime_for_ext(Path::new("d.svg")), Some("image/svg+xml")); - assert_eq!(image_mime_for_ext(Path::new("p.avif")), Some("image/avif")); + fn preview_mime_for_ext_known_extensions() { + assert_eq!(preview_mime_for_ext(Path::new("a/b.png")), Some("image/png")); + assert_eq!(preview_mime_for_ext(Path::new("shot.JPG")), Some("image/jpeg")); + assert_eq!(preview_mime_for_ext(Path::new("d.svg")), Some("image/svg+xml")); + assert_eq!(preview_mime_for_ext(Path::new("p.avif")), Some("image/avif")); + assert_eq!(preview_mime_for_ext(Path::new("doc.PDF")), Some("application/pdf")); } #[test] - fn image_mime_for_ext_rejects_non_images() { + fn preview_mime_for_ext_rejects_non_previewable() { // The base64 read must not become a generic binary-file channel. - assert_eq!(image_mime_for_ext(Path::new("id_rsa")), None); - assert_eq!(image_mime_for_ext(Path::new("a.pdf")), None); - assert_eq!(image_mime_for_ext(Path::new("script.sh")), None); - assert_eq!(image_mime_for_ext(Path::new("noext")), None); + assert_eq!(preview_mime_for_ext(Path::new("id_rsa")), None); + assert_eq!(preview_mime_for_ext(Path::new("script.sh")), None); + assert_eq!(preview_mime_for_ext(Path::new("noext")), None); + } + + #[test] + fn task_file_read_base64_for_task_roundtrips_root_file() { + use base64::Engine as _; + let dir = tempdir().unwrap(); + let png_bytes: &[u8] = b"\x89PNG\r\n\x1a\nnot-a-real-png-but-that's-fine-here"; + fs::write(dir.path().join("shot.png"), png_bytes).unwrap(); + let task = Task { path: dir.path().to_string_lossy().into_owned(), ..Default::default() }; + + let read = task_file_read_base64_for_task(&task, "shot.png", None).unwrap(); + assert!(!read.unchanged); + assert_eq!(read.mime.as_deref(), Some("image/png")); + assert_eq!(base64::engine::general_purpose::STANDARD.decode(read.data.unwrap()).unwrap(), png_bytes); + } + + #[test] + fn task_file_read_base64_for_task_resolves_member_path() { + use base64::Engine as _; + let host = tempdir().unwrap(); + let member = tempdir().unwrap(); + let pdf_bytes: &[u8] = b"%PDF-1.4\nnot-a-real-pdf-but-that's-fine-here"; + fs::write(member.path().join("report.pdf"), pdf_bytes).unwrap(); + + let task = Task { + path: host.path().to_string_lossy().into_owned(), + composition: vec![TaskMember { + dir_name: "docs".into(), + mode: MemberMode::RepoRoot, + path: member.path().to_string_lossy().into_owned(), + ..Default::default() + }], + ..Default::default() + }; + + let read = task_file_read_base64_for_task(&task, "docs/report.pdf", None).unwrap(); + assert_eq!(read.mime.as_deref(), Some("application/pdf")); + assert_eq!(base64::engine::general_purpose::STANDARD.decode(read.data.unwrap()).unwrap(), pdf_bytes); + } + + #[test] + fn task_file_read_base64_for_task_short_circuits_on_matching_known_fp() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("shot.png"), b"hello").unwrap(); + let task = Task { path: dir.path().to_string_lossy().into_owned(), ..Default::default() }; + + let first = task_file_read_base64_for_task(&task, "shot.png", None).unwrap(); + let second = task_file_read_base64_for_task(&task, "shot.png", Some(&first.fp)).unwrap(); + assert!(second.unchanged); + assert!(second.mime.is_none()); + assert!(second.data.is_none()); + } + + #[test] + fn task_file_read_base64_for_task_rejects_non_previewable_extension() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("notes.txt"), "hello").unwrap(); + let task = Task { path: dir.path().to_string_lossy().into_owned(), ..Default::default() }; + + assert!(task_file_read_base64_for_task(&task, "notes.txt", None).is_err()); } #[test] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index cb2ab51..7a0dc20 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -13,7 +13,7 @@ "withGlobalTauri": false, "windows": [], "security": { - "csp": "default-src 'self' ipc: http://ipc.localhost; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost https:; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost ws: wss: https://termic.dev; worker-src 'self' blob:; frame-src 'none'; object-src 'none'" + "csp": "default-src 'self' ipc: http://ipc.localhost; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost https:; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost ws: wss: https://termic.dev; worker-src 'self' blob:; frame-src 'none'; object-src 'self' data:" } }, "bundle": { diff --git a/src/components/task/PreviewPane.tsx b/src/components/task/PreviewPane.tsx new file mode 100644 index 0000000..87e91d9 --- /dev/null +++ b/src/components/task/PreviewPane.tsx @@ -0,0 +1,83 @@ +// Read-only preview for image/PDF files opened from the file tree. No +// CodeMirror instance: just an / fed a base64 data URL read +// over IPC (taskFileReadBase64), same extension whitelist as the Rust +// side (previewKindForPath / preview_mime_for_ext). + +import { useEffect, useRef, useState } from "react"; +import type { EditTab, Task } from "@/lib/types"; +import { taskFileReadBase64 } from "@/lib/ipc"; +import { previewKindForPath } from "@/lib/previewPaths"; +import { useApp } from "@/store/app"; + +export function PreviewPane({ task, tab }: { task: Task; tab: EditTab }) { + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(null); + const [url, setUrl] = useState(null); + + // Last-read `mtime:len` fingerprint, sent as `knownFp` on the next read so + // an agent-settle refetch of an unchanged file skips the read + base64 + // encode entirely (`unchanged: true`, no `mime`/`data`). Reset whenever the + // tab switches to a different file, a fresh file has nothing to compare + // against. + const fpRef = useRef(undefined); + const identityRef = useRef(null); + + // Per-task "files changed" tick (bumped on agent-settle) — re-fetch a + // preview an agent just (re)wrote. No dirty/unsaved state to protect here + // (the pane is read-only), so this can reload silently, unlike EditorPane's + // disk-changed prompt. + const fsRevision = useApp(s => s.fsRevision[task.id] ?? 0); + + useEffect(() => { + let alive = true; + const identity = `${task.id}:${tab.path}`; + const isNewFile = identityRef.current !== identity; + identityRef.current = identity; + if (isNewFile) { + fpRef.current = undefined; + setLoading(true); + setErr(null); + setUrl(null); + } + taskFileReadBase64(task.id, tab.path, fpRef.current) + .then(({ unchanged, mime, data, fp }) => { + if (!alive) return; + fpRef.current = fp; + if (unchanged) { + // Bytes already on screen are still correct. + setLoading(false); + return; + } + if (!mime || !data) { + setErr("empty response"); + setLoading(false); + return; + } + setUrl(`data:${mime};base64,${data}`); + setLoading(false); + }) + .catch(e => { + if (!alive) return; + setErr(String(e)); + setLoading(false); + }); + return () => { alive = false; }; + }, [task.id, tab.path, fsRevision]); + + const kind = previewKindForPath(tab.path); + + return ( +
+ {loading &&
Loading…
} + {err &&
Error: {err}
} + {url && kind === "image" && ( +
+ {tab.title} +
+ )} + {url && kind === "pdf" && ( + + )} +
+ ); +} diff --git a/src/components/task/TaskView.tsx b/src/components/task/TaskView.tsx index f82d504..b85d8fc 100644 --- a/src/components/task/TaskView.tsx +++ b/src/components/task/TaskView.tsx @@ -25,9 +25,11 @@ import { ResizeHandle } from "@/components/ui/ResizeHandle"; import { ContextMenuRoot, ContextMenuTrigger, ContextMenuContent } from "@/components/ui/ContextMenu"; import { CopyPathItems } from "./CopyPathItems"; import { dirnamePosix, MARKDOWN_EXT_RE } from "@/lib/markdownPaths"; +import { previewKindForPath } from "@/lib/previewPaths"; const EditorPane = lazy(() => import("./EditorPane").then(m => ({ default: m.EditorPane }))); const DiffPane = lazy(() => import("./DiffPane").then(m => ({ default: m.DiffPane }))); const MarkdownPane = lazy(() => import("./MarkdownPane").then(m => ({ default: m.MarkdownPane }))); +const PreviewPane = lazy(() => import("./PreviewPane").then(m => ({ default: m.PreviewPane }))); // Lightweight extension check so we don't import the (lazy) MarkdownPane // module just to ask whether a path is markdown. Shared with the markdown // preview's link handler (markdownPaths.ts) so both agree on what counts. @@ -338,7 +340,15 @@ export function TaskView({ task }: { task: Task }) { {t.type === "terminal" && ((t as TerminalTab).runTab ? : )} - {t.type === "edit" && {isMarkdownPath(t.path) ? : }} + {t.type === "edit" && ( + + {previewKindForPath(t.path) + ? + : isMarkdownPath(t.path) + ? + : } + + )} {t.type === "diff" && } ); diff --git a/src/lib/ipc.ts b/src/lib/ipc.ts index d14d3de..76e564d 100644 --- a/src/lib/ipc.ts +++ b/src/lib/ipc.ts @@ -303,12 +303,13 @@ export const taskFileDiffSides = (id: string, path: string) => "task_file_diff_sides", { id, path }, ); export const taskFileRead = (id: string, path: string) => invoke("task_file_read", { id, path }); -/** Read a task image as base64 for the markdown preview (image - * extensions only, 10 MB cap). Member-aware + worktree-contained, same - * checks as taskFileRead. Pass the `fp` from a previous successful - * read as `knownFp` to let the backend skip the read+encode entirely when - * the file hasn't changed (`unchanged: true`, no `mime`/`data`) — the fast - * path an agent-settle revalidation of every mounted preview relies on. */ +/** Read a task image or PDF as base64, for the markdown preview's inline + * images or the file-tree preview pane (image/PDF extensions, 20 MB cap). + * Member-aware + worktree-contained, same checks as taskFileRead. Pass the + * `fp` from a previous successful read as `knownFp` to let the backend skip + * the read+encode entirely when the file hasn't changed (`unchanged: true`, + * no `mime`/`data`) — the fast path an agent-settle revalidation of every + * mounted preview relies on. */ export const taskFileReadBase64 = (id: string, path: string, knownFp?: string) => invoke<{ unchanged: boolean; mime?: string; data?: string; fp: string }>( "task_file_read_base64", { id, path, knownFp }, diff --git a/src/lib/previewPaths.test.ts b/src/lib/previewPaths.test.ts new file mode 100644 index 0000000..22f8975 --- /dev/null +++ b/src/lib/previewPaths.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from "vitest"; +import { previewKindForPath } from "@/lib/previewPaths"; + +describe("previewKindForPath", () => { + it("routes image extensions to \"image\"", () => { + expect(previewKindForPath("shot.png")).toBe("image"); + expect(previewKindForPath("nested/dir/photo.JPG")).toBe("image"); + expect(previewKindForPath("icon.svg")).toBe("image"); + }); + + it("routes .pdf to \"pdf\"", () => { + expect(previewKindForPath("doc.pdf")).toBe("pdf"); + expect(previewKindForPath("Report.PDF")).toBe("pdf"); + }); + + it("returns null for non-previewable and extension-less files", () => { + expect(previewKindForPath("main.ts")).toBeNull(); + expect(previewKindForPath("README.md")).toBeNull(); + expect(previewKindForPath("Makefile")).toBeNull(); + }); +}); diff --git a/src/lib/previewPaths.ts b/src/lib/previewPaths.ts new file mode 100644 index 0000000..873c959 --- /dev/null +++ b/src/lib/previewPaths.ts @@ -0,0 +1,21 @@ +// Which file-tree tabs render as a binary preview (image/PDF) instead of +// the CodeMirror editor. Kept in sync by hand with `preview_mime_for_ext` +// in src-tauri/src/lib.rs — the backend's whitelist for the base64 read +// channel; this is the frontend's routing subset of the same extensions. + +const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"]); + +function extOf(path: string): string { + const base = path.split("/").pop() || path; + return base.includes(".") ? base.slice(base.lastIndexOf(".") + 1).toLowerCase() : ""; +} + +/** "image" | "pdf" for a path the preview pane can render, else null (route + * to the regular editor). Extension-only, no IPC round trip — used at + * open-time to pick which pane component mounts. */ +export function previewKindForPath(path: string): "image" | "pdf" | null { + const ext = extOf(path); + if (ext === "pdf") return "pdf"; + if (IMAGE_EXTENSIONS.has(ext)) return "image"; + return null; +} From d547bd87656ce338a6d737003fe9325fbc308179 Mon Sep 17 00:00:00 2001 From: Adam Matan Date: Fri, 10 Jul 2026 15:16:01 -0400 Subject: [PATCH 2/2] docs: add Unreleased changelog entry for image/PDF preview Left unversioned since entries in this repo are normally written by the maintainer during their own release cut (#66). --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7031bc..87d5dfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to Termic, newest first. This file is the human-authored source of truth: the in-app Update card and the /changelog page on termic.dev are generated from it. See the `release` skill for how entries are added. +## [Unreleased] + +Preview images and PDFs in the file pane instead of a binary error. + +### Features +- Clicking an image or PDF in the file tree now previews it inline instead of showing a "not valid UTF-8" error (#66). + ## [0.20.1] - 2026-07-10 Workspaces are now Tasks, and you can create one in two clicks from the sidebar.