From e18f67cb7af60ba7c790330ce81c7754e6344f2e Mon Sep 17 00:00:00 2001 From: juan <2930882+juacker@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:35:05 +0200 Subject: [PATCH] feat(artifacts): open binary files & the folder with the OS app Two artifacts-drawer improvements: 1. Binary/rich files (pdf, office docs, images, archives, media, db, fonts, binaries) now open in the OS default application instead of the in-app text preview, which failed with a UTF-8 error. viewer_for_path classifies these as "external"; clicking such a tree row launches the OS app (open_workspace_path target "system", from the #80 machinery). The preview error state also gains an "Open with the system app" button for unrecognized binary extensions that still fall through. 2. New "open the workspace folder in your file browser" icon in the artifacts drawer header (Files/Explorer/Finder), alongside the existing open-in-editor / open-in-terminal actions. --- src-tauri/src/commands/workspace.rs | 46 +++++++++++++++++++ .../WorkspaceFilePreviewPanel.module.css | 15 ++++++ src/components/WorkspaceFilePreviewPanel.tsx | 26 ++++++++++- src/pages/Workspace.tsx | 41 ++++++++++++++++- 4 files changed, 124 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/commands/workspace.rs b/src-tauri/src/commands/workspace.rs index da5fa72..e7d222e 100644 --- a/src-tauri/src/commands/workspace.rs +++ b/src-tauri/src/commands/workspace.rs @@ -476,9 +476,42 @@ fn viewer_for_path(path: &Path) -> String { return "json".to_string(); } + // Binary / rich formats the in-app preview can't render as UTF-8 text. + // The frontend treats "external" by launching the OS default app for the + // file (see `open_workspace_path` target "system") instead of opening the + // text preview panel, which would otherwise fail with a UTF-8 error. + if is_external_viewer_ext(&ext) { + return "external".to_string(); + } + "text".to_string() } +/// Extensions whose files are opened with the OS default application rather +/// than the in-app text preview. Kept deliberately broad across documents, +/// archives, media, and binaries — anything a dev workspace might hold that +/// is not UTF-8 text. +fn is_external_viewer_ext(ext: &str) -> bool { + matches!( + ext, + // Documents + "pdf" | "doc" | "docx" | "xls" | "xlsx" | "ppt" | "pptx" | "odt" | "ods" + | "odp" | "rtf" | "epub" + // Images (no inline image preview yet — open externally) + | "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "ico" | "tiff" | "tif" | "avif" + // Audio / video + | "mp3" | "wav" | "flac" | "ogg" | "m4a" | "aac" + | "mp4" | "mov" | "mkv" | "webm" | "avi" | "wmv" | "m4v" + // Archives + | "zip" | "tar" | "gz" | "tgz" | "bz2" | "xz" | "7z" | "rar" | "zst" + // Binaries / other + | "exe" | "dll" | "so" | "dylib" | "bin" | "o" | "a" + | "wasm" | "class" | "jar" + | "sqlite" | "db" | "parquet" + | "woff" | "woff2" | "ttf" | "otf" | "eot" + ) +} + /// Best-effort MIME type from a file extension, used to build `data:` URIs /// when inlining preview resources. Falls back to `application/octet-stream` /// for unknown extensions — browsers still load most binary assets from a @@ -3206,4 +3239,17 @@ mod tests { ))); assert!(!is_protected_artifact_path(Path::new("images/pic.png"))); } + + #[test] + fn viewer_for_path_classifies_binary_as_external() { + use std::path::Path; + assert_eq!(viewer_for_path(Path::new("report.pdf")), "external"); + assert_eq!(viewer_for_path(Path::new("photo.PNG")), "external"); + assert_eq!(viewer_for_path(Path::new("archive.zip")), "external"); + assert_eq!(viewer_for_path(Path::new("data.sqlite")), "external"); + // Text/known viewers are unchanged. + assert_eq!(viewer_for_path(Path::new("notes.md")), "markdown"); + assert_eq!(viewer_for_path(Path::new("main.rs")), "text"); + assert_eq!(viewer_for_path(Path::new("data.json")), "json"); + } } diff --git a/src/components/WorkspaceFilePreviewPanel.module.css b/src/components/WorkspaceFilePreviewPanel.module.css index 093a48b..0e2b45e 100644 --- a/src/components/WorkspaceFilePreviewPanel.module.css +++ b/src/components/WorkspaceFilePreviewPanel.module.css @@ -300,3 +300,18 @@ background: var(--color-bg-elevated); } +.openExternalButton { + margin-top: 12px; + padding: 6px 12px; + font-size: 12px; + border: 1px solid var(--color-border-light); + border-radius: var(--radius-sm); + background: var(--color-bg-elevated); + color: var(--color-text-primary); + cursor: pointer; +} + +.openExternalButton:hover { + border-color: var(--color-primary); + color: var(--color-primary); +} diff --git a/src/components/WorkspaceFilePreviewPanel.tsx b/src/components/WorkspaceFilePreviewPanel.tsx index 543ee7d..f006abd 100644 --- a/src/components/WorkspaceFilePreviewPanel.tsx +++ b/src/components/WorkspaceFilePreviewPanel.tsx @@ -305,10 +305,25 @@ const renderBody = ( htmlBundle: string | null, bundling: boolean, onMarkdownLinkClick: (event: React.MouseEvent) => void, + onOpenExternal: () => void, ) => { if (!file) return null; if (file.error) { - return
{file.error}
; + // Binary files are routed to the OS app before ever reaching the preview + // (viewer === 'external'), but an unrecognized binary extension can still + // land here and fail the UTF-8 read — offer to open it with the OS app. + return ( +
+
{file.error}
+ +
+ ); } if (!file.content) { return
This file is empty.
; @@ -525,6 +540,13 @@ export default function WorkspaceFilePreviewPanel({ return slash === -1 ? entry.path : entry.path.slice(slash + 1); })(); + const handleOpenExternal = () => { + if (!entry?.path) return; + openWorkspacePath(workspaceId, entry.path, 'system').catch((err) => { + setError(err instanceof Error ? err.message : 'Failed to open with the system app.'); + }); + }; + const handleOpenInEditor = async () => { if (!entry?.path) return; try { @@ -789,7 +811,7 @@ export default function WorkspaceFilePreviewPanel({ )} {loading &&
Loading…
} {!loading && error &&
{error}
} - {!loading && !error && renderBody(file, htmlMode, htmlBundle, bundling, handleMarkdownLinkClick)} + {!loading && !error && renderBody(file, htmlMode, htmlBundle, bundling, handleMarkdownLinkClick, handleOpenExternal)} ); diff --git a/src/pages/Workspace.tsx b/src/pages/Workspace.tsx index e1fd8d3..777660f 100644 --- a/src/pages/Workspace.tsx +++ b/src/pages/Workspace.tsx @@ -2026,7 +2026,7 @@ const Workspace = () => { // import files picked in the native dialog (under Flatpak the // FileChooser portal grants access to the picked files). const handleOpenWorkspaceIn = useCallback( - async (target: 'editor' | 'terminal') => { + async (target: 'editor' | 'terminal' | 'system') => { try { await openWorkspacePath(workspaceId, null, target); } catch (err) { @@ -2036,6 +2036,22 @@ const Workspace = () => { [workspaceId] ); + // Select an artifact: binary/rich files (viewer === 'external') can't be + // rendered in the text preview, so launch the OS default app instead of + // opening a panel that would error on non-UTF-8 content. + const handleSelectArtifact = useCallback( + (entry: WorkspaceFileEntry) => { + if (entry.viewer === 'external') { + openWorkspacePath(workspaceId, entry.relativePath, 'system').catch((err) => { + setError(errorMessage(err, `Failed to open ${entry.name} with the system app.`)); + }); + return; + } + openPreviewEntry({ kind: 'artifact', entry }); + }, + [workspaceId, openPreviewEntry] + ); + const handleAddFiles = useCallback(async () => { try { const picked = await openFileDialog({ multiple: true, title: 'Add files to workspace' }); @@ -2153,6 +2169,27 @@ const Workspace = () => { +