Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
179 changes: 124 additions & 55 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4963,16 +4963,16 @@ fn task_file_read(id: String, path: String) -> Result<String, String> {
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 `<img src="data:...">`, 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 `<img src="data:...">`,
/// 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"),
Expand All @@ -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,
}
}
Expand All @@ -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<Base64Read, String> {
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<String>) -> Result<Base64Read, String> {
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())?
Expand Down Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
83 changes: 83 additions & 0 deletions src/components/task/PreviewPane.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Read-only preview for image/PDF files opened from the file tree. No
// CodeMirror instance: just an <img>/<embed> 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<string | null>(null);
const [url, setUrl] = useState<string | null>(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<string | undefined>(undefined);
const identityRef = useRef<string | null>(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 (
<div className="relative h-full overflow-auto bg-[var(--color-bg)]">
{loading && <div className="p-4 text-[14px] text-[var(--color-fg-dim)]">Loading…</div>}
{err && <div className="p-4 text-[14px] text-[var(--color-err)]">Error: {err}</div>}
{url && kind === "image" && (
<div className="flex h-full items-center justify-center p-4">
<img src={url} alt={tab.title} className="max-h-full max-w-full object-contain" />
</div>
)}
{url && kind === "pdf" && (
<embed src={url} type="application/pdf" className="h-full w-full" />
)}
</div>
);
}
12 changes: 11 additions & 1 deletion src/components/task/TaskView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -338,7 +340,15 @@ export function TaskView({ task }: { task: Task }) {
{t.type === "terminal" && ((t as TerminalTab).runTab
? <RunPane task={task} tab={t as TerminalTab} active={tabActive} />
: <TerminalPane task={task} tab={t as TerminalTab} active={tabActive} />)}
{t.type === "edit" && <Suspense fallback={null}>{isMarkdownPath(t.path) ? <MarkdownPane task={task} tab={t} /> : <EditorPane task={task} tab={t} active={tabActive} />}</Suspense>}
{t.type === "edit" && (
<Suspense fallback={null}>
{previewKindForPath(t.path)
? <PreviewPane task={task} tab={t} />
: isMarkdownPath(t.path)
? <MarkdownPane task={task} tab={t} />
: <EditorPane task={task} tab={t} active={tabActive} />}
</Suspense>
)}
{t.type === "diff" && <Suspense fallback={null}><DiffPane task={task} tab={t} /></Suspense>}
</div>
);
Expand Down
13 changes: 7 additions & 6 deletions src/lib/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>("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 },
Expand Down
21 changes: 21 additions & 0 deletions src/lib/previewPaths.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading