@@ -24,16 +36,30 @@ export function FilePreviewFallback({
Preview not available
- {onDownload
- ? "Download this file to view it"
- : "This file cannot be previewed"}
+ {canOpen
+ ? `Open this file in ${appName} to view it`
+ : onDownload
+ ? "Download this file to view it"
+ : "This file cannot be previewed"}
- {onDownload && (
-
);
diff --git a/apps/studio/src/client/components/file-viewer.tsx b/apps/studio/src/client/components/file-viewer.tsx
index 0cebab8bb..6202483b1 100644
--- a/apps/studio/src/client/components/file-viewer.tsx
+++ b/apps/studio/src/client/components/file-viewer.tsx
@@ -28,12 +28,16 @@ import {
IMAGE_PANZOOM_VIEWPORT_CLASS,
useImagePanzoom,
} from "../hooks/use-image-panzoom";
+import { useOpenTaskFile } from "../hooks/use-open-task-file";
import { useSyntaxHighlighting } from "../hooks/use-syntax-highlighting";
+import { useTaskFileOpenTarget } from "../hooks/use-task-file-open-target";
import { useTimedFlag } from "../hooks/use-timed-flag";
import { FileActionsMenuItems } from "./file-actions-menu";
import { FilePreviewFallback } from "./file-preview-fallback";
import { RevealInFolderIcon } from "./icons/reveal-in-folder";
import { ImageWithFallback } from "./image-with-fallback";
+import { OpenTargetIcon } from "./open-target-icon";
+import { OpenWithMenu } from "./open-with-menu";
import { SandboxedHtmlIframe } from "./sandboxed-html-iframe";
import { SessionMarkdown } from "./session-markdown";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
@@ -55,7 +59,10 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
-import { contextMenuComponents } from "./ui/menu-components";
+import {
+ contextMenuComponents,
+ dropdownMenuComponents,
+} from "./ui/menu-components";
import { Spinner } from "./ui/spinner";
import { toolbarClassName } from "./ui/toggle";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
@@ -311,6 +318,8 @@ export function FileViewer({
const imageLoadError = imageErrorUrl === url;
const contentRef = useRef
(null);
const { active: copied, trigger: triggerCopied } = useTimedFlag();
+ const openTaskFile = useOpenTaskFile();
+ const { openLabel, showOpen, showOpenWith } = useTaskFileOpenTarget(file);
const revealFileMutation = useMutation(
rpcClient.utils.showTaskFileInFolder.mutationOptions({
onError: (error) => {
@@ -330,9 +339,17 @@ export function FileViewer({
const fileType = getFileType(file);
const hasPreview = fileType === "markdown" || fileType === "html";
const fileActions = useFileActionVisibility(file);
- const hasHeaderMenuActions = onExpand != null || fileActions.showReveal;
+ const hasHeaderMenuActions =
+ onExpand != null ||
+ showOpenWith ||
+ fileActions.showDownload ||
+ fileActions.showReveal;
const showOverflowMenu =
- fileActions.showReveal || hasPreview || Boolean(onExpand);
+ showOpenWith ||
+ fileActions.showDownload ||
+ fileActions.showReveal ||
+ hasPreview ||
+ Boolean(onExpand);
const handleDownload = async () => {
await downloadFile(file);
@@ -388,7 +405,7 @@ export function FileViewer({
-
+
{filename}
@@ -399,17 +416,20 @@ export function FileViewer({
{filePath}
-
- {fileActions.showDownload && (
+
+ {showOpen && (
void handleDownload()}
+ onClick={() => {
+ openTaskFile(file);
+ }}
size="sm"
variant="ghost"
>
-
-
- Download
+
+
+ {openLabel}
)}
@@ -451,6 +471,18 @@ export function FileViewer({
Expand
)}
+ {showOpenWith && (
+
+ )}
+ {fileActions.showDownload && (
+
void handleDownload()}>
+
+ Download
+
+ )}
{fileActions.showReveal && (
@@ -506,6 +538,7 @@ export function FileViewer({
@@ -523,6 +556,7 @@ export function FileViewer({
diff --git a/apps/studio/src/client/components/open-target-icon.tsx b/apps/studio/src/client/components/open-target-icon.tsx
new file mode 100644
index 000000000..b0e1bf7bc
--- /dev/null
+++ b/apps/studio/src/client/components/open-target-icon.tsx
@@ -0,0 +1,32 @@
+import { type TaskFileViewerFile } from "@/client/atoms/task-file-viewer";
+import { useTaskFileOpenTarget } from "@/client/hooks/use-task-file-open-target";
+import { cn } from "@/client/lib/utils";
+
+import { FileIcon } from "./file-icon";
+
+// Icon of the default app for the file. While the app is still resolving it
+// renders an invisible box of the same size (so it appears only once ready,
+// with no jarring placeholder), and falls back to the file-type icon when the
+// platform can't resolve an app.
+export function OpenTargetIcon({
+ className,
+ file,
+}: {
+ className?: string;
+ file: Pick;
+}) {
+ const { iconDataUrl, isPending } = useTaskFileOpenTarget(file);
+
+ if (iconDataUrl) {
+ return (
+
+ );
+ }
+
+ if (isPending) {
+ return ;
+ }
+
+ const filename = file.filePath.split("/").pop() ?? file.filePath;
+ return ;
+}
diff --git a/apps/studio/src/client/components/open-with-menu.tsx b/apps/studio/src/client/components/open-with-menu.tsx
new file mode 100644
index 000000000..2f2e47696
--- /dev/null
+++ b/apps/studio/src/client/components/open-with-menu.tsx
@@ -0,0 +1,89 @@
+import { type TaskFileViewerFile } from "@/client/atoms/task-file-viewer";
+import { useOpenTaskFileWith } from "@/client/hooks/use-open-task-file";
+import { useTaskFileOpenCandidates } from "@/client/hooks/use-task-file-open-target";
+import { AppWindowIcon } from "@phosphor-icons/react";
+
+import { type MenuComponents } from "./ui/menu-components";
+import { Spinner } from "./ui/spinner";
+
+type FileRef = Pick;
+
+// "Open with" submenu listing every app that can open the file. Candidates are
+// fetched lazily: the query only runs once the submenu content mounts (opens).
+export function OpenWithMenu({
+ file,
+ menuComponents,
+}: {
+ file: FileRef;
+ menuComponents: MenuComponents;
+}) {
+ const { Sub, SubContent, SubTrigger } = menuComponents;
+
+ return (
+
+
+
+ Open with
+
+
+
+
+
+ );
+}
+
+function OpenWithCandidates({
+ file,
+ menuComponents,
+}: {
+ file: FileRef;
+ menuComponents: MenuComponents;
+}) {
+ const { Item } = menuComponents;
+ const { apps, isPending } = useTaskFileOpenCandidates(file, {
+ enabled: true,
+ });
+ const openWith = useOpenTaskFileWith();
+
+ if (isPending) {
+ return (
+ -
+
+ Loading apps…
+
+ );
+ }
+
+ if (apps.length === 0) {
+ return (
+ -
+ No apps available
+
+ );
+ }
+
+ return (
+ <>
+ {apps.map((candidate) => (
+ - {
+ openWith(file, candidate.appPath);
+ }}
+ >
+ {candidate.iconDataUrl ? (
+
+ ) : (
+
+ )}
+ {candidate.appName}
+
+ ))}
+ >
+ );
+}
diff --git a/apps/studio/src/client/hooks/use-file-action-visibility.ts b/apps/studio/src/client/hooks/use-file-action-visibility.ts
index 7b0feff17..1c9b1cfc8 100644
--- a/apps/studio/src/client/hooks/use-file-action-visibility.ts
+++ b/apps/studio/src/client/hooks/use-file-action-visibility.ts
@@ -25,6 +25,7 @@ export function useFileActionVisibility(file: TaskFileViewerFile) {
return {
showCopy: isCopyableByMime || (isTextLike && isDownloadable),
showDownload: isDownloadable,
+ showOpen: true,
showReveal: true,
};
}
diff --git a/apps/studio/src/client/hooks/use-open-task-file.ts b/apps/studio/src/client/hooks/use-open-task-file.ts
new file mode 100644
index 000000000..7a7fbe73f
--- /dev/null
+++ b/apps/studio/src/client/hooks/use-open-task-file.ts
@@ -0,0 +1,48 @@
+import { type TaskFileViewerFile } from "@/client/atoms/task-file-viewer";
+import { rpcClient } from "@/client/rpc/client";
+import { useMutation } from "@tanstack/react-query";
+import { toast } from "sonner";
+
+// Opens a task file with the OS-associated application.
+export function useOpenTaskFile() {
+ const openTaskFileMutation = useMutation(
+ rpcClient.utils.openTaskFile.mutationOptions({
+ onError: (error) => {
+ toast.error("Failed to open file", {
+ description: error.message,
+ });
+ },
+ }),
+ );
+
+ return (file: Pick) => {
+ openTaskFileMutation.mutate({
+ filePath: file.filePath,
+ id: file.taskId,
+ });
+ };
+}
+
+// Opens a task file with a specific chosen application ("Open with").
+export function useOpenTaskFileWith() {
+ const openTaskFileWithMutation = useMutation(
+ rpcClient.utils.openTaskFileWith.mutationOptions({
+ onError: (error) => {
+ toast.error("Failed to open file", {
+ description: error.message,
+ });
+ },
+ }),
+ );
+
+ return (
+ file: Pick,
+ appPath: string,
+ ) => {
+ openTaskFileWithMutation.mutate({
+ appPath,
+ filePath: file.filePath,
+ id: file.taskId,
+ });
+ };
+}
diff --git a/apps/studio/src/client/hooks/use-task-file-open-target.ts b/apps/studio/src/client/hooks/use-task-file-open-target.ts
new file mode 100644
index 000000000..00b4be64e
--- /dev/null
+++ b/apps/studio/src/client/hooks/use-task-file-open-target.ts
@@ -0,0 +1,63 @@
+import { type TaskFileViewerFile } from "@/client/atoms/task-file-viewer";
+import { rpcClient } from "@/client/rpc/client";
+import { skipToken, useQuery, useQueryClient } from "@tanstack/react-query";
+
+import { isMacOS } from "../lib/utils";
+
+type FileRef = Pick;
+
+const openTargetQueryOptions = (file: FileRef | undefined) =>
+ rpcClient.utils.getTaskFileOpenTarget.queryOptions({
+ input: file ? { filePath: file.filePath, id: file.taskId } : skipToken,
+ refetchOnMount: false,
+ refetchOnReconnect: false,
+ refetchOnWindowFocus: false,
+ staleTime: Number.POSITIVE_INFINITY,
+ });
+
+// Warms the open-target query (e.g. on hover) so menus and the file viewer
+// have the app name and icon ready by the time they render.
+export function usePrefetchTaskFileOpenTarget() {
+ const queryClient = useQueryClient();
+ return (file: FileRef) => {
+ void queryClient.prefetchQuery(openTargetQueryOptions(file));
+ };
+}
+
+// Every app that can open the file (default first). Lazily fetched, since it is
+// only needed when an "Open with" menu is opened.
+export function useTaskFileOpenCandidates(
+ file: FileRef,
+ { enabled }: { enabled: boolean },
+) {
+ const { data, isPending } = useQuery(
+ rpcClient.utils.getTaskFileOpenCandidates.queryOptions({
+ input: enabled ? { filePath: file.filePath, id: file.taskId } : skipToken,
+ refetchOnMount: false,
+ refetchOnReconnect: false,
+ refetchOnWindowFocus: false,
+ staleTime: Number.POSITIVE_INFINITY,
+ }),
+ );
+
+ return { apps: data?.apps ?? [], isPending: enabled && isPending };
+}
+
+// Default-app name and icon for a task file, for "Open in {app}" affordances.
+// Resolution is cached per file type in the main process (and persisted across
+// runs); the query is cached per file here.
+export function useTaskFileOpenTarget(file: FileRef | undefined) {
+ const { data, isPending } = useQuery(openTargetQueryOptions(file));
+
+ const appName = data?.appName ?? null;
+ const showOpen = file != null;
+
+ return {
+ appName,
+ iconDataUrl: data?.iconDataUrl ?? null,
+ isPending,
+ openLabel: appName ? `Open in ${appName}` : "Open",
+ showOpen,
+ showOpenWith: showOpen && appName != null && isMacOS(),
+ };
+}
diff --git a/apps/studio/src/electron-main/lib/file-open-target.ts b/apps/studio/src/electron-main/lib/file-open-target.ts
new file mode 100644
index 000000000..e591d714e
--- /dev/null
+++ b/apps/studio/src/electron-main/lib/file-open-target.ts
@@ -0,0 +1,480 @@
+import { app, nativeImage } from "electron";
+import { execFile } from "node:child_process";
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { promisify } from "node:util";
+import { z } from "zod";
+
+const execFileAsync = promisify(execFile);
+
+interface FileOpenCandidate {
+ appName: string;
+ appPath: string;
+ iconDataUrl: null | string;
+}
+
+interface FileOpenTarget {
+ appName: null | string;
+ iconDataUrl: null | string;
+}
+
+interface PersistedEntry extends FileOpenTarget {
+ resolvedAt: number;
+}
+
+const ICON_SIZE = 64;
+const MAX_CANDIDATES = 12;
+const LOOKUP_TIMEOUT_MS = 10_000;
+
+// Resolution spawns helper processes and only depends on the file type, so the
+// default target is cached per extension and persisted so the first open of a
+// type is instant on later runs. Candidate lists (with many icons) are heavier
+// and only needed on demand, so they stay in memory for the session.
+const CACHE_VERSION = 2;
+const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
+const MAX_PERSISTED = 256;
+const SAVE_DEBOUNCE_MS = 1000;
+
+let diskCache: Map | null = null;
+let diskCacheLoad: null | Promise