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
46 changes: 46 additions & 0 deletions src-tauri/src/commands/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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");
}
}
15 changes: 15 additions & 0 deletions src/components/WorkspaceFilePreviewPanel.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
26 changes: 24 additions & 2 deletions src/components/WorkspaceFilePreviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <div className={styles.error}>{file.error}</div>;
// 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 (
<div className={styles.error}>
<div>{file.error}</div>
<button
type="button"
className={styles.openExternalButton}
onClick={onOpenExternal}
>
Open with the system app
</button>
</div>
);
}
if (!file.content) {
return <div className={styles.empty}>This file is empty.</div>;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -789,7 +811,7 @@ export default function WorkspaceFilePreviewPanel({
)}
{loading && <div className={styles.empty}>Loading…</div>}
{!loading && error && <div className={styles.error}>{error}</div>}
{!loading && !error && renderBody(file, htmlMode, htmlBundle, bundling, handleMarkdownLinkClick)}
{!loading && !error && renderBody(file, htmlMode, htmlBundle, bundling, handleMarkdownLinkClick, handleOpenExternal)}
</div>
</aside>
);
Expand Down
41 changes: 39 additions & 2 deletions src/pages/Workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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' });
Expand Down Expand Up @@ -2153,6 +2169,27 @@ const Workspace = () => {
<line x1="9" y1="15" x2="15" y2="15" />
</svg>
</button>
<button
type="button"
className={styles.workspaceDrawerIconAction}
onClick={() => handleOpenWorkspaceIn('system')}
title="Open the workspace folder in your file browser"
aria-label="Open the workspace folder in your file browser"
>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z" />
</svg>
</button>
<button
type="button"
className={styles.workspaceDrawerIconAction}
Expand Down Expand Up @@ -2257,7 +2294,7 @@ const Workspace = () => {
workspaceId={workspaceId}
totalCount={artifactCount}
latestModifiedAt={Number(snapshot?.artifactLatestModifiedAt ?? 0)}
onSelect={(entry) => openPreviewEntry({ kind: 'artifact', entry })}
onSelect={handleSelectArtifact}
onDeleted={handleArtifactDeleted}
/>
)}
Expand Down
Loading