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 = () => { +