Skip to content

feat: image and PDF preview in the file pane#75

Merged
simion merged 2 commits into
simion:mainfrom
adamatan:feature/binary-preview
Jul 11, 2026
Merged

feat: image and PDF preview in the file pane#75
simion merged 2 commits into
simion:mainfrom
adamatan:feature/binary-preview

Conversation

@adamatan

@adamatan adamatan commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Summary

fixes #66

Clicking an image or PDF in the file tree opened it as a code-editor tab and
failed with a generic "not valid UTF-8, so it can't be shown" error. Both now
render inline.

Extension-based routing (previewKindForPath in src/lib/previewPaths.ts)
sends image/PDF tabs to a new read-only PreviewPane instead of EditorPane.
The pane fetches bytes over a new workspace_file_read_base64 Tauri command
(same member-aware resolution + worktree containment as workspace_file_read,
plus an extension whitelist and a 20 MB cap) and renders an <img> for images
or an <embed type="application/pdf"> for PDFs, fed a base64 data URL. No
CodeMirror instance is created, so there's no dirty/unsaved state to worry
about; the pane just re-fetches silently on the workspace's fsRevision tick
(agent-settle) so a preview an agent just wrote reflects on screen.

Rust side: read_capped_file is TOCTOU-safe (fstat on the already-open
handle, not a separate path-based metadata() call), so a symlink retarget or
truncate-and-replace between the check and the read can't smuggle a larger or
non-regular file past the cap. preview_mime_for_ext is a hardcoded
extension allowlist so this stays a preview-only channel, not a generic
binary-read path. workspace_file_read_base64 is split into a thin command
wrapper plus a _for_workspace helper (mirroring
workspace_file_diff_sides_for_workspace), so it's testable end-to-end with
an in-memory Workspace instead of needing load_workspaces().

CSP: object-src loosens from 'none' to 'self' data: so <embed> can
render a data:application/pdf;... URL. img-src already allowed data:
from #65, so images needed no CSP change.

Changelog: added an ## [Unreleased] entry at the top of CHANGELOG.md
rather than a versioned section, since entries in this repo are normally
written by the maintainer during their own release cut. Feel free to fold it
into whichever version this lands in, or drop it if you'd rather add it
yourself at release time.

Tradeoffs / open questions

  • The image/PDF extension allowlist is hand-synced in two places
    (preview_mime_for_ext in Rust, previewKindForPath in TS) — same
    pattern (and same caveat) as the allowlist duplication called out in feat: images and links in markdown preview #65.

Test plan

  • npm testpreviewPaths.test.ts (image/PDF/non-previewable extension
    routing)
  • cargo test --lib — mime allowlist, capped-read within/over cap, directory
    rejection, plus end-to-end workspace_file_read_base64 coverage
    (root-file roundtrip, member-composition path resolution,
    non-previewable-extension rejection)
  • npx tsc -b, npm run build
  • Manual: open a .png/.svg/.pdf from the file tree, confirm it renders
    instead of the binary error; confirm a .ts/.md file still opens in the
    normal editor/markdown pane

🤖 Generated with Claude Code

@adamatan

Copy link
Copy Markdown
Contributor Author

Working on the changelog and conflicts

@simion

simion commented Jul 10, 2026

Copy link
Copy Markdown
Owner

No need to add a changelog entry.

I will add them all to a new entry because I will group more fixes in a release.

Thanks

@simion simion left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this, the feature is wanted and the security posture (allowlist, cap, containment, spawn_blocking) is exactly right. Two things before it can merge:

1. CSP: approved. Widening object-src from 'none' to 'self' data: for the PDF <embed> is fine, same status as the img-src data: exception from #65. No change needed there.

2. Please rebase onto current main. The branch predates two things that landed recently, so it conflicts and duplicates infrastructure:

  • The workspaces-to-tasks rename: src/components/workspace/ is now src/components/task/, WorkspaceView is TaskView, and the Rust side uses Task / task_* naming throughout.
  • The markdown-preview images work already added most of this PR's Rust plumbing, in more evolved form:
    • read_capped_file exists on main and is hardened with f.take(cap + 1) plus a post-read length check (your copy's unbounded read_to_end can exceed the cap if the file grows between the fstat and the read).
    • image_mime_for_ext exists with the identical image allowlist.
    • task_file_read_base64 exists and has an mtime:len fingerprint short-circuit (known_fp / unchanged) so an agent settle doesn't re-read and re-encode unchanged multi-MB files.

So the rebased PR gets much smaller:

  • Add "pdf" => Some("application/pdf") to image_mime_for_ext (and rename it to something like preview_mime_for_ext). Note the existing test image_mime_for_ext_rejects_non_images explicitly asserts .pdf is rejected; update it to assert .pdf is accepted instead.
  • Keep previewPaths.ts + tests and the TaskView routing as-is (modulo the rename).
  • Rewire PreviewPane to call the existing taskFileReadBase64(id, path, knownFp) and keep the returned fp in state so the fsRevision re-fetch benefits from the unchanged short-circuit instead of re-paying a full read + base64 encode per settle tick.
  • Drop the new workspace_file_read_base64 command, the duplicate read_capped_file, and the direct base64 Cargo dependency (already there).
  • Keep the CSP change and add a CHANGELOG.md entry (Keep a Changelog format, no em dashes, new section at the top).

The end-to-end tests you added (roundtrip, member-path resolution, non-previewable rejection) are worth porting onto task_file_read_base64's test coverage. Ran your branch as-is: vitest 3/3, cargo test 119/119, tsc + vite build clean, so the logic is sound, it just needs to land on today's main.

@adamatan

Copy link
Copy Markdown
Contributor Author

On it

@adamatan adamatan force-pushed the feature/binary-preview branch from 28458f1 to f4b5754 Compare July 10, 2026 19:08
adamatan added 2 commits July 10, 2026 15:15
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 <embed>
can render PDF data URLs. This is a deliberate, scoped exception
alongside the existing img-src https: one.
Left unversioned since entries in this repo are normally written by
the maintainer during their own release cut (simion#66).
@adamatan adamatan force-pushed the feature/binary-preview branch from f4b5754 to d547bd8 Compare July 10, 2026 19:18
@adamatan

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review, this is exactly the direction. Both requested changes are in:

  1. Merged image_mime_for_ext into preview_mime_for_ext rather than keeping a wrapper: "pdf" => Some("application/pdf") is now a direct match arm alongside the image extensions. Updated the old image_mime_for_ext_rejects_non_images test to assert PDF is now accepted (renamed to preview_mime_for_ext_known_extensions / preview_mime_for_ext_rejects_non_previewable, consolidating the four mime tests into two).

  2. PreviewPane now uses the fingerprint cache. It keeps the last-read fp in a ref and passes it as knownFp on every refetch, so an fsRevision tick (agent settle) that finds the file unchanged gets unchanged: true back and skips replacing the displayed bytes entirely, no re-read, no re-encode, no flicker. The ref resets when the tab switches to a different file so a fresh file always does a full read.

Everything else from the rebase (reusing task_file_read_base64 instead of a new command, dropping the duplicate read_capped_file/base64 dependency, Task/TaskView naming, CSP change, CHANGELOG entry, ported end-to-end tests) was already in from the previous push.

Re-ran everything after these two changes: cargo test --lib (134 passed), npm test (217 passed), tsc -b, and npm run build, all clean. Force-pushed the update.

@simion simion merged commit e7d776d into simion:main Jul 11, 2026
2 checks passed
simion added a commit that referenced this pull request Jul 11, 2026
PreviewPane only reset error state on a file switch, not on the
fsRevision refetch, so a load that failed once (transient error, too
large) then succeeded on an agent-settle tick left the error banner
rendered on top of the freshly loaded image/PDF. Clear the error in
the success path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019x3kbDKdUAfbCKeWhBnmJw
simion added a commit that referenced this pull request Jul 11, 2026
Changelog entries are authored at release time via the release skill,
not per-PR. The image/PDF preview line will land in the next version's
entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019x3kbDKdUAfbCKeWhBnmJw
simion added a commit that referenced this pull request Jul 11, 2026
Contributors and agents kept adding [Unreleased] changelog entries and
were unclear on who cuts releases (see #75). Make it explicit in
CONTRIBUTING.md (a new PR rule + a note atop the Releasing section) and
CLAUDE.md (a maintainer-only banner on the Releasing section): don't run
make release, don't bump the version, don't touch CHANGELOG.md /
changelog.json unless the maintainer explicitly asks. Add AGENTS.md as a
symlink to CLAUDE.md so codex-family agents pick up the same rules.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019x3kbDKdUAfbCKeWhBnmJw
simion added a commit that referenced this pull request Jul 11, 2026
…follow-up)

PR #75 fed <embed type="application/pdf"> a base64 data: URL, which
renders blank in WKWebView: its PDF support only engages for a real
resource load with an application/pdf MIME, not a data: URL. Images were
fine (<img> via img-src); PDFs showed an empty pane.

Add a custom `taskpdf:` async URI-scheme handler
(register_asynchronous_uri_scheme_protocol) that streams the bytes as
application/pdf so WKWebView renders them natively. It reuses a new
read_preview_file helper, so the SAME member-aware containment
(resolve_task_git_path + safe_task_path, rejects ../symlink escapes),
extension allowlist (preview_mime_for_ext, image/PDF only), and 20 MB
cap as the base64 channel apply. A rejected path is a 404, not a generic
file read. PreviewPane points the embed at
taskpdf://localhost/<enc id>/<enc path>?v=<fsRevision>; the v= param
busts the webview cache on agent-settle so a rewritten PDF reloads.

CSP: object-src goes from 'self' data: to 'self' taskpdf: (data: is
dropped since PDFs no longer use it and images render via img-src), a
net tightening. taskpdf: is not in img-src/connect-src, so only
<embed>/<object> can reach it. percent-encoding is pinned direct
(already in the tree via tauri/url; no new crate, no binary-size
impact).

Tests: preview-read roundtrip + non-previewable rejection, and
taskpdf_response 400 (malformed) / 404 (unknown task) paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019x3kbDKdUAfbCKeWhBnmJw
@simion

simion commented Jul 11, 2026

Copy link
Copy Markdown
Owner

Thanks @adamatan, merged and shipped. Genuinely clean PR: the TOCTOU-safe capped read (fstat on the open handle + take(cap+1)), the extension allowlist as a hard boundary, and the _for_task split for testability were all exactly right, and the writeup made review fast.

Two small follow-ups landed on main after the merge:

1. Stale error banner (1d1abd8). PreviewPane only reset error state on a file switch, not on the fsRevision refetch, so a load that failed once (transient / too large) then succeeded on an agent-settle tick left the error rendered on top of the image. Cleared it in the success path.

2. PDF rendering (0aa051e). This is the one that needed a different approach. <embed> fed a data:application/pdf URL renders blank in WKWebView: its PDF support only engages for a real resource load with an application/pdf MIME, not a data: URL. Images were fine via <img>, but PDFs opened to an empty pane. Fix: a custom taskpdf: async URI-scheme handler that streams the bytes with the right MIME so WKWebView renders them natively. It reuses your resolver via a new read_preview_file, so the exact same member-aware containment (resolve_task_git_path + safe_task_path), extension allowlist, and 20 MB cap apply; a rejected path is a 404, not a generic file read. CSP object-src went from 'self' data: to 'self' taskpdf: (dropping data:, so it's a net tightening), and taskpdf: isn't in img-src/connect-src so only <embed> can reach it. No new crates (percent-encoding was already in the tree). One cosmetic note: the native PDF viewer's grey page-surround is WKWebView/PDFKit chrome and isn't CSS-themeable, so I left it rather than pull in pdf.js.

On the changelog: I pulled the [Unreleased] entry for now because releases and changelog entries are cut maintainer-side. I'll fold your #66 line in and cut a release tonight. I also clarified in CONTRIBUTING.md and CLAUDE.md that the changelog and releases are maintainer-only, so that's off contributors' plates going forward, no worries there. Thanks again, great contribution.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature Request] Preview images/PDFs in the file pane instead of a binary error

2 participants