diff --git a/apps/studio/src/client/components/file-actions-menu.tsx b/apps/studio/src/client/components/file-actions-menu.tsx index bde1aae03..897ae9a87 100644 --- a/apps/studio/src/client/components/file-actions-menu.tsx +++ b/apps/studio/src/client/components/file-actions-menu.tsx @@ -12,9 +12,13 @@ import { import { useMutation } from "@tanstack/react-query"; import { toast } from "sonner"; +import { useOpenTaskFile } from "../hooks/use-open-task-file"; +import { useTaskFileOpenTarget } from "../hooks/use-task-file-open-target"; import { useTimedFlag } from "../hooks/use-timed-flag"; import { getRevealInFolderLabel } from "../lib/utils"; import { RevealInFolderIcon } from "./icons/reveal-in-folder"; +import { OpenTargetIcon } from "./open-target-icon"; +import { OpenWithMenu } from "./open-with-menu"; import { Button, type ButtonVariant } from "./ui/button"; import { DropdownMenu, @@ -36,11 +40,13 @@ export function FileActionsMenu({ variant?: ButtonVariant; }) { const fileActions = useFileActionVisibility(file); + const { showOpen } = useTaskFileOpenTarget(file); if ( !onAddToChat && !fileActions.showCopy && !fileActions.showDownload && + !showOpen && !fileActions.showReveal ) { return null; @@ -75,6 +81,8 @@ export function FileActionsMenuItems({ }) { const { Item, Separator } = menuComponents; const fileActions = useFileActionVisibility(file); + const openTaskFile = useOpenTaskFile(); + const { openLabel, showOpen, showOpenWith } = useTaskFileOpenTarget(file); const showTaskFileInFolderMutation = useMutation( rpcClient.utils.showTaskFileInFolder.mutationOptions({ @@ -115,7 +123,10 @@ export function FileActionsMenuItems({ }; const hasFileActions = - fileActions.showCopy || fileActions.showDownload || fileActions.showReveal; + showOpen || + fileActions.showCopy || + fileActions.showDownload || + fileActions.showReveal; if (!onAddToChat && !hasFileActions) { return null; @@ -123,6 +134,22 @@ export function FileActionsMenuItems({ return ( <> + {showOpen && ( + <> + { + openTaskFile(file); + }} + > + + {openLabel} + + {showOpenWith && ( + + )} + {(onAddToChat != null || hasFileActions) && } + + )} {onAddToChat && ( <> diff --git a/apps/studio/src/client/components/file-preview-card.tsx b/apps/studio/src/client/components/file-preview-card.tsx index bfad3390b..2650a2610 100644 --- a/apps/studio/src/client/components/file-preview-card.tsx +++ b/apps/studio/src/client/components/file-preview-card.tsx @@ -8,8 +8,7 @@ import { import { useFileActionVisibility } from "@/client/hooks/use-file-action-visibility"; import { copyFileToClipboard, downloadFile } from "@/client/lib/file-actions"; import { fileKindLabel, getFileType } from "@/client/lib/get-file-type"; -import { cn, getRevealInFolderLabel } from "@/client/lib/utils"; -import { rpcClient } from "@/client/rpc/client"; +import { cn } from "@/client/lib/utils"; import { ArrowLineDownIcon, CheckIcon, @@ -17,17 +16,20 @@ import { ImageBrokenIcon, PlayIcon, } from "@phosphor-icons/react"; -import { useMutation } from "@tanstack/react-query"; import { useRef, useState } from "react"; -import { toast } from "sonner"; +import { useOpenTaskFile } from "../hooks/use-open-task-file"; +import { + usePrefetchTaskFileOpenTarget, + useTaskFileOpenTarget, +} from "../hooks/use-task-file-open-target"; import { useTimedFlag } from "../hooks/use-timed-flag"; import { FileActionsMenu, FileActionsMenuItems } from "./file-actions-menu"; import { FileThumbnail } from "./file-thumbnail"; -import { RevealInFolderIcon } from "./icons/reveal-in-folder"; import { ImageWithFallback } from "./image-with-fallback"; import { MediaCardShell } from "./media-card-shell"; import { MediaOverlayButton } from "./media-overlay-button"; +import { OpenTargetIcon } from "./open-target-icon"; import { ContextMenu, ContextMenuContent, @@ -155,7 +157,11 @@ function FileRowCard({ const isMissing = useTaskFileReferenceStatus(file) === "missing"; const fileActions = useFileActionVisibility(file); const hasFileActions = - fileActions.showCopy || fileActions.showDownload || fileActions.showReveal; + fileActions.showCopy || + fileActions.showDownload || + fileActions.showOpen || + fileActions.showReveal; + const prefetchOpenTarget = usePrefetchTaskFileOpenTarget(); const row = (
{ + prefetchOpenTarget(file); + }} > { @@ -255,9 +265,7 @@ function ImagePreviewCard({ const hasActions = !hideActionsMenu && - (fileActions.showCopy || - fileActions.showDownload || - fileActions.showReveal); + (fileActions.showCopy || fileActions.showDownload || actions.showOpen); return ( { + setResolveOpenTarget(true); + }} overlayActions={ hasActions ? ( <> @@ -295,13 +306,15 @@ function ImagePreviewCard({ }} /> )} - {fileActions.showReveal && ( + {actions.showOpen && ( } - label={getRevealInFolderLabel()} + icon={ + + } + label="Open" onClick={(e) => { e.stopPropagation(); - actions.revealInFolder(); + actions.open(); }} /> )} @@ -365,27 +378,23 @@ function MissingMediaCard({ ); } -function useFileActions(file: TaskFileViewerFile) { - const showTaskFileInFolderMutation = useMutation( - rpcClient.utils.showTaskFileInFolder.mutationOptions({ - onError: (error) => { - const label = getRevealInFolderLabel(); - const lower = label.charAt(0).toLowerCase() + label.slice(1); - toast.error(`Failed to ${lower}`, { description: error.message }); - }, - }), +function useFileActions( + file: TaskFileViewerFile, + { resolveOpenTarget }: { resolveOpenTarget: boolean }, +) { + const openTaskFile = useOpenTaskFile(); + const { showOpen } = useTaskFileOpenTarget( + resolveOpenTarget ? file : undefined, ); return { download: async () => { await downloadFile(file); }, - revealInFolder: () => { - showTaskFileInFolderMutation.mutate({ - filePath: file.filePath, - id: file.taskId, - }); + open: () => { + openTaskFile(file); }, + showOpen, }; } @@ -420,10 +429,11 @@ function VideoPreviewCard({ }) { const url = useLiveAssetUrl(file); const fileActions = useFileActionVisibility(file); - const actions = useFileActions(file); + const [resolveOpenTarget, setResolveOpenTarget] = useState(false); + const actions = useFileActions(file, { resolveOpenTarget }); const hasActions = - !hideActionsMenu && (fileActions.showDownload || fileActions.showReveal); + !hideActionsMenu && (fileActions.showDownload || actions.showOpen); const displayTime = isPlaying && timeRemaining !== null ? timeRemaining : videoDuration; @@ -450,7 +460,10 @@ function VideoPreviewCard({ hideActionsMenu={hideActionsMenu} isSelected={isSelected} onClick={onClick} - onMouseEnter={handleMouseEnter} + onMouseEnter={() => { + setResolveOpenTarget(true); + handleMouseEnter(); + }} onMouseLeave={handleMouseLeave} overlayActions={ hasActions ? ( @@ -465,13 +478,15 @@ function VideoPreviewCard({ }} /> )} - {fileActions.showReveal && ( + {actions.showOpen && ( } - label={getRevealInFolderLabel()} + icon={ + + } + label="Open" onClick={(e) => { e.stopPropagation(); - actions.revealInFolder(); + actions.open(); }} /> )} diff --git a/apps/studio/src/client/components/file-preview-fallback.tsx b/apps/studio/src/client/components/file-preview-fallback.tsx index 7ef41fc8c..d32d3a0e7 100644 --- a/apps/studio/src/client/components/file-preview-fallback.tsx +++ b/apps/studio/src/client/components/file-preview-fallback.tsx @@ -1,17 +1,29 @@ +import { type TaskFileViewerFile } from "@/client/atoms/task-file-viewer"; +import { useOpenTaskFile } from "@/client/hooks/use-open-task-file"; +import { useTaskFileOpenTarget } from "@/client/hooks/use-task-file-open-target"; import { ArrowLineDownIcon } from "@phosphor-icons/react"; import { FileIcon } from "./file-icon"; +import { OpenTargetIcon } from "./open-target-icon"; import { Button } from "./ui/button"; export function FilePreviewFallback({ fallbackExtension, + file, filename, onDownload, }: { fallbackExtension?: string; + file?: Pick; filename: string; onDownload?: () => void; }) { + const openTaskFile = useOpenTaskFile(); + const { appName, openLabel } = useTaskFileOpenTarget(file); + // Without a resolved app association, opening could dead-end in an OS + // error, so only promote open over download when an app is known. + const canOpen = file != null && appName != null; + return (
@@ -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 && ( - + ) : ( + 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 && ( )} @@ -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> = null; +let saveTimer: null | ReturnType = null; +const inFlightTargets = new Map>(); +const sessionTargets = new Map>(); +const candidatesCache = new Map>(); + +const PersistedEntrySchema = z.object({ + appName: z.string().nullable(), + iconDataUrl: z.string().nullable(), + resolvedAt: z.number(), +}); + +const PersistedCacheSchema = z.object({ + entries: z.record(z.string(), PersistedEntrySchema), + version: z.number(), +}); + +const DarwinResultSchema = z.object({ + appName: z.string(), + iconBase64: z.string(), +}); + +const Win32ResultSchema = z.object({ + appName: z.string(), + exePath: z.string(), +}); + +const DarwinCandidatesSchema = z.object({ + apps: z.array( + z.object({ + appName: z.string(), + appPath: z.string(), + iconBase64: z.string(), + }), + ), +}); + +// Resolves the default app via NSWorkspace and returns its real icon +// (works for asset-catalog-only apps where reading the .icns would fail). +const DARWIN_RESOLVE_SCRIPT = ` +ObjC.import("AppKit"); +function run(argv) { + const ws = $.NSWorkspace.sharedWorkspace; + const result = { appName: "", iconBase64: "" }; + try { + const url = ws.URLForApplicationToOpenURL($.NSURL.fileURLWithPath(argv[0])); + const appPath = url.path.js; + if (!appPath) { + return JSON.stringify(result); + } + result.appName = + $.NSFileManager.defaultManager.displayNameAtPath(appPath).js ?? ""; + const rep = $.NSBitmapImageRep.imageRepWithData( + ws.iconForFile(appPath).TIFFRepresentation, + ); + const png = rep.representationUsingTypeProperties( + $.NSBitmapImageFileTypePNG, + $.NSDictionary.dictionary, + ); + result.iconBase64 = png.base64EncodedStringWithOptions(0).js ?? ""; + } catch { + // fall through with whatever resolved so far + } + return JSON.stringify(result); +} +`; + +// Enumerates every app that can open the file (default first), deduped by name +// and capped, with each survivor's icon rendered to PNG. +const DARWIN_CANDIDATES_SCRIPT = ` +ObjC.import("AppKit"); +function run(argv) { + const ws = $.NSWorkspace.sharedWorkspace; + const fm = $.NSFileManager.defaultManager; + const cap = parseInt(argv[1], 10) || 12; + const out = { apps: [] }; + try { + const urls = ws.URLsForApplicationsToOpenURL($.NSURL.fileURLWithPath(argv[0])); + const count = urls.count; + const seen = {}; + for (let i = 0; i < count && out.apps.length < cap; i++) { + const appPath = urls.objectAtIndex(i).path.js; + if (!appPath) continue; + const name = (fm.displayNameAtPath(appPath).js ?? "").replace(/\\.app$/, ""); + if (!name || seen[name]) continue; + seen[name] = true; + let iconBase64 = ""; + try { + const rep = $.NSBitmapImageRep.imageRepWithData( + ws.iconForFile(appPath).TIFFRepresentation, + ); + const png = rep.representationUsingTypeProperties( + $.NSBitmapImageFileTypePNG, + $.NSDictionary.dictionary, + ); + iconBase64 = png.base64EncodedStringWithOptions(0).js ?? ""; + } catch { + // an app without a resolvable icon still opens the file + } + out.apps.push({ appName: name, appPath: appPath, iconBase64: iconBase64 }); + } + } catch { + // no apps available for this type + } + return JSON.stringify(out); +} +`; + +export async function getFileOpenCandidates( + fullPath: string, +): Promise { + const key = path.extname(fullPath).toLowerCase() || fullPath; + const existing = candidatesCache.get(key); + if (existing) { + return existing; + } + const pending = resolveCandidates(fullPath); + candidatesCache.set(key, pending); + void pending.catch(() => { + if (candidatesCache.get(key) === pending) { + candidatesCache.delete(key); + } + }); + return pending; +} + +export async function getFileOpenTarget( + fullPath: string, +): Promise { + const ext = path.extname(fullPath).toLowerCase(); + + // Extension-less files can't share a cache key across files, so resolve them + // per session without persisting per-path entries. + if (!ext) { + const existing = sessionTargets.get(fullPath); + if (existing) { + return existing; + } + const pending = resolveTarget(fullPath); + sessionTargets.set(fullPath, pending); + void pending.catch(() => { + if (sessionTargets.get(fullPath) === pending) { + sessionTargets.delete(fullPath); + } + }); + return pending; + } + + const cache = await loadDiskCache(); + const entry = cache.get(ext); + if (entry) { + if (Date.now() - entry.resolvedAt >= CACHE_TTL_MS) { + // Serve the cached value immediately but refresh in the background so a + // changed default app is picked up without ever blocking the caller. + refreshTargetInBackground(ext, fullPath); + } + return { appName: entry.appName, iconDataUrl: entry.iconDataUrl }; + } + + const inFlight = inFlightTargets.get(ext); + if (inFlight) { + return inFlight; + } + const pending = resolveAndStore(ext, fullPath); + inFlightTargets.set(ext, pending); + return pending; +} + +function cacheFilePath() { + return path.join(app.getPath("userData"), "file-open-targets.json"); +} + +// `app.getFileIcon` only yields the file-type icon (a generic icon for .app +// bundles), so app icons come from the per-platform resolvers instead. +async function getFileTypeIconDataUrl(fullPath: string) { + try { + const icon = await app.getFileIcon(fullPath, { size: "normal" }); + return icon.isEmpty() ? null : icon.toDataURL(); + } catch { + return null; + } +} + +async function loadDiskCache(): Promise> { + if (diskCache) { + return diskCache; + } + diskCacheLoad ??= (async () => { + const loaded = new Map(); + try { + const raw = await fs.readFile(cacheFilePath(), "utf8"); + const parsed = PersistedCacheSchema.parse(JSON.parse(raw)); + if (parsed.version === CACHE_VERSION) { + for (const [ext, entry] of Object.entries(parsed.entries)) { + loaded.set(ext, entry); + } + } + } catch { + // Missing or unreadable cache is fine; it repopulates on demand. + } + diskCache ??= loaded; + return diskCache; + })().finally(() => { + diskCacheLoad = null; + }); + return diskCacheLoad; +} + +function pngBase64ToDataUrl(base64: string) { + if (!base64) { + return null; + } + const image = nativeImage.createFromBuffer(Buffer.from(base64, "base64")); + if (image.isEmpty()) { + return null; + } + return image.resize({ height: ICON_SIZE, width: ICON_SIZE }).toDataURL(); +} + +async function readDesktopEntryName(desktopId: string) { + const dataDirs = [ + path.join(os.homedir(), ".local/share"), + // eslint-disable-next-line turbo/no-undeclared-env-vars + ...(process.env.XDG_DATA_DIRS ?? "/usr/local/share:/usr/share").split(":"), + ]; + for (const dir of dataDirs) { + if (!dir) { + continue; + } + try { + const content = await fs.readFile( + path.join(dir, "applications", desktopId), + "utf8", + ); + const name = /^Name=(.+)$/m.exec(content)?.[1]?.trim(); + if (name) { + return name; + } + } catch { + // not in this data dir; try the next one + } + } + return null; +} + +function refreshTargetInBackground(ext: string, fullPath: string) { + if (inFlightTargets.has(ext)) { + return; + } + const pending = resolveAndStore(ext, fullPath); + inFlightTargets.set(ext, pending); + // Background refresh failures are non-fatal; the stale value stays cached. + void pending.catch(() => null); +} + +async function resolveAndStore( + ext: string, + fullPath: string, +): Promise { + try { + const target = await resolveTarget(fullPath); + const cache = await loadDiskCache(); + cache.set(ext, { ...target, resolvedAt: Date.now() }); + scheduleSave(); + return target; + } finally { + inFlightTargets.delete(ext); + } +} + +async function resolveAssociatedApp( + fullPath: string, +): Promise { + switch (process.platform) { + case "darwin": { + return resolveDarwin(fullPath); + } + case "linux": { + return resolveLinux(fullPath); + } + case "win32": { + return resolveWin32(fullPath); + } + default: { + return null; + } + } +} + +async function resolveCandidates( + fullPath: string, +): Promise { + if (process.platform !== "darwin") { + // Only macOS has a portable enumeration of every app that can open a file. + return []; + } + const { stdout } = await execFileAsync( + "osascript", + [ + "-l", + "JavaScript", + "-e", + DARWIN_CANDIDATES_SCRIPT, + fullPath, + String(MAX_CANDIDATES), + ], + { maxBuffer: 64 * 1024 * 1024, timeout: LOOKUP_TIMEOUT_MS }, + ); + const parsed = DarwinCandidatesSchema.parse(JSON.parse(stdout)); + return parsed.apps.map((candidate) => ({ + appName: candidate.appName, + appPath: candidate.appPath, + iconDataUrl: pngBase64ToDataUrl(candidate.iconBase64), + })); +} + +async function resolveDarwin(fullPath: string) { + const { stdout } = await execFileAsync( + "osascript", + ["-l", "JavaScript", "-e", DARWIN_RESOLVE_SCRIPT, fullPath], + // The icon PNG can be ~1MB of base64 (icons ship at 1024px). + { maxBuffer: 16 * 1024 * 1024, timeout: LOOKUP_TIMEOUT_MS }, + ); + const result = DarwinResultSchema.parse(JSON.parse(stdout)); + if (!result.appName) { + return null; + } + return { + appName: result.appName.replace(/\.app$/, ""), + iconDataUrl: pngBase64ToDataUrl(result.iconBase64), + }; +} + +async function resolveLinux(fullPath: string) { + const { stdout: mimeOut } = await execFileAsync( + "xdg-mime", + ["query", "filetype", fullPath], + { timeout: LOOKUP_TIMEOUT_MS }, + ); + const mime = mimeOut.trim(); + if (!mime) { + return null; + } + const { stdout: desktopOut } = await execFileAsync( + "xdg-mime", + ["query", "default", mime], + { timeout: LOOKUP_TIMEOUT_MS }, + ); + const desktopId = desktopOut.trim(); + if (!desktopId || desktopId.includes("/")) { + return null; + } + const appName = await readDesktopEntryName(desktopId); + if (!appName) { + return null; + } + // No portable icon-theme lookup; callers get the file-type icon instead. + return { appName, iconDataUrl: null }; +} + +async function resolveTarget(fullPath: string): Promise { + const resolved = await resolveAssociatedApp(fullPath); + const iconDataUrl = + resolved?.iconDataUrl ?? (await getFileTypeIconDataUrl(fullPath)); + return { appName: resolved?.appName ?? null, iconDataUrl }; +} + +async function resolveWin32(fullPath: string) { + const ext = path.extname(fullPath).toLowerCase(); + // The extension is interpolated into the script; only allow simple ones. + if (!/^\.[a-z0-9]+$/.test(ext)) { + return null; + } + // UserChoice is how Windows 10+ records the user's "always open with" pick; + // the HKCR default is the pre-UserChoice fallback. + const script = ` +$ErrorActionPreference = 'SilentlyContinue' +$ext = '${ext}' +$progId = (Get-ItemProperty -Path "HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\FileExts\\$ext\\UserChoice").ProgId +if (-not $progId) { $progId = (Get-ItemProperty -Path "Registry::HKEY_CLASSES_ROOT\\$ext").'(default)' } +if (-not $progId) { exit } +$command = (Get-ItemProperty -Path "Registry::HKEY_CLASSES_ROOT\\$progId\\shell\\open\\command").'(default)' +if (-not $command) { exit } +$exe = if ($command -match '^"([^"]+)"') { $Matches[1] } else { ($command -split ' ')[0] } +$exe = [Environment]::ExpandEnvironmentVariables($exe) +if (-not (Test-Path -LiteralPath $exe)) { exit } +$name = (Get-Item -LiteralPath $exe).VersionInfo.FileDescription +if (-not $name) { $name = [IO.Path]::GetFileNameWithoutExtension($exe) } +@{ appName = $name; exePath = $exe } | ConvertTo-Json -Compress +`; + const { stdout } = await execFileAsync( + "powershell", + ["-NoProfile", "-NonInteractive", "-Command", script], + { timeout: LOOKUP_TIMEOUT_MS }, + ); + const trimmed = stdout.trim(); + if (!trimmed) { + return null; + } + const result = Win32ResultSchema.parse(JSON.parse(trimmed)); + // On Windows getFileIcon on the .exe does return the app icon. + const icon = await app + .getFileIcon(result.exePath, { size: "normal" }) + .catch(() => null); + return { + appName: result.appName, + iconDataUrl: icon && !icon.isEmpty() ? icon.toDataURL() : null, + }; +} + +async function saveDiskCache() { + if (!diskCache) { + return; + } + let entries = [...diskCache.entries()]; + if (entries.length > MAX_PERSISTED) { + entries = entries + .sort((a, b) => b[1].resolvedAt - a[1].resolvedAt) + .slice(0, MAX_PERSISTED); + diskCache = new Map(entries); + } + const payload = { + entries: Object.fromEntries(entries), + version: CACHE_VERSION, + }; + try { + await fs.writeFile(cacheFilePath(), JSON.stringify(payload), "utf8"); + } catch { + // Best effort; a failed write just means we re-resolve next run. + } +} + +function scheduleSave() { + if (saveTimer) { + return; + } + saveTimer = setTimeout(() => { + saveTimer = null; + void saveDiskCache(); + }, SAVE_DEBOUNCE_MS); +} diff --git a/apps/studio/src/electron-main/rpc/initialize.ts b/apps/studio/src/electron-main/rpc/initialize.ts index b3d1a7090..d9db59170 100644 --- a/apps/studio/src/electron-main/rpc/initialize.ts +++ b/apps/studio/src/electron-main/rpc/initialize.ts @@ -29,12 +29,23 @@ function isHandledNotFound(error: unknown): boolean { return error instanceof ORPCError && error.code === "NOT_FOUND"; } +// Opening a file can fail for user-environment reasons (no app associated with +// the type, app removed) rather than an app bug. Like NOT_FOUND we still +// rethrow so the UI can toast, but skip the exception capture to avoid noise. +function isHandledOpenError(error: unknown): boolean { + return error instanceof ORPCError && error.code === "ERROR_OPENING_FILE"; +} + // Offline / unreachable-server failures (fetch failed, connection timeouts, DNS // errors) reflect the user's network rather than an app bug. Like NOT_FOUND we // still rethrow them to the client so the UI can show a retry, but skip the // exception capture so telemetry isn't flooded with non-actionable noise. function shouldSkipCapture(error: unknown): boolean { - return isHandledNotFound(error) || isExpectedNetworkError(error); + return ( + isHandledNotFound(error) || + isHandledOpenError(error) || + isExpectedNetworkError(error) + ); } const handler = new RPCHandler(router, { diff --git a/apps/studio/src/electron-main/rpc/routes/utils.ts b/apps/studio/src/electron-main/rpc/routes/utils.ts index 6ac564c5f..8e11434f9 100644 --- a/apps/studio/src/electron-main/rpc/routes/utils.ts +++ b/apps/studio/src/electron-main/rpc/routes/utils.ts @@ -6,6 +6,10 @@ import type { import { captureServerEvent } from "@/electron-main/lib/capture-server-event"; import { captureServerException } from "@/electron-main/lib/capture-server-exception"; +import { + getFileOpenCandidates, + getFileOpenTarget, +} from "@/electron-main/lib/file-open-target"; import { openExternal } from "@/electron-main/lib/open-external"; import { clearServerExceptions, @@ -38,7 +42,7 @@ import { import { call, eventIterator } from "@orpc/server"; import { app, clipboard, dialog, nativeImage, shell } from "electron"; import { isBinaryFile } from "isbinaryfile"; -import { exec } from "node:child_process"; +import { exec, execFile } from "node:child_process"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -52,6 +56,7 @@ interface EditorConfig { } const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); const EDITORS_BY_PLATFORM: Record = { darwin: [ @@ -269,6 +274,162 @@ const openTaskIn = base } }); +const openTaskFile = base + .errors({ + ERROR_OPENING_FILE: { + message: "Error opening file", + }, + FILE_NOT_FOUND: { + message: "File not found", + }, + INVALID_PATH: { + message: "Invalid file path", + }, + }) + .input( + z.object({ + filePath: RelativeTaskPathSchema, + id: TaskIdSchema, + }), + ) + .handler(async ({ errors, input }) => { + const fullPath = resolvePathWithinTaskDir({ + dir: taskDir(input.id), + filePath: input.filePath, + }); + if (!fullPath) { + throw errors.INVALID_PATH(); + } + + try { + await fs.access(fullPath); + } catch { + throw errors.FILE_NOT_FOUND(); + } + + // openPath resolves with "" on success and an error string on failure + // (e.g. no app is associated with the type). That's an expected + // user-environment outcome, not an app bug, so it's surfaced to the client + // as a typed error and skipped by the RPC exception capture. + const errorMessage = await shell.openPath(fullPath); + if (errorMessage) { + throw errors.ERROR_OPENING_FILE({ message: errorMessage }); + } + }); + +const openTaskFileWith = base + .errors({ + ERROR_OPENING_FILE: { + message: "Error opening file", + }, + FILE_NOT_FOUND: { + message: "File not found", + }, + INVALID_PATH: { + message: "Invalid file path", + }, + UNSUPPORTED_PLATFORM: { + message: "Choosing an app is only supported on macOS", + }, + }) + .input( + z.object({ + appPath: z.string().refine((val) => path.isAbsolute(val)), + filePath: RelativeTaskPathSchema, + id: TaskIdSchema, + }), + ) + .handler(async ({ errors, input }) => { + if (os.platform() !== "darwin") { + throw errors.UNSUPPORTED_PLATFORM(); + } + + const fullPath = resolvePathWithinTaskDir({ + dir: taskDir(input.id), + filePath: input.filePath, + }); + if (!fullPath) { + throw errors.INVALID_PATH(); + } + + try { + await fs.access(fullPath); + } catch { + throw errors.FILE_NOT_FOUND(); + } + + try { + const candidates = await getFileOpenCandidates(fullPath); + if (!candidates.some(({ appPath }) => appPath === input.appPath)) { + throw errors.ERROR_OPENING_FILE(); + } + // execFile (not a shell) so the app path and file path can't be + // interpreted as shell syntax. + await execFileAsync("open", ["-a", input.appPath, fullPath]); + } catch (error) { + throw errors.ERROR_OPENING_FILE({ + message: error instanceof Error ? error.message : undefined, + }); + } + }); + +// Default-app name and icon for "Open in {app}" affordances. Fields are null +// when the platform can't resolve them; callers fall back to generic ones. +const getTaskFileOpenTarget = base + .input( + z.object({ + filePath: RelativeTaskPathSchema, + id: TaskIdSchema, + }), + ) + .output( + z.object({ + appName: z.string().nullable(), + iconDataUrl: z.string().nullable(), + }), + ) + .handler(async ({ input }) => { + const fullPath = resolvePathWithinTaskDir({ + dir: taskDir(input.id), + filePath: input.filePath, + }); + if (!fullPath) { + return { appName: null, iconDataUrl: null }; + } + return await getFileOpenTarget(fullPath); + }); + +// Every app that can open the file (default first), for an "Open with" picker. +// Empty on non-macOS platforms, which lack a portable enumeration. +const getTaskFileOpenCandidates = base + .input( + z.object({ + filePath: RelativeTaskPathSchema, + id: TaskIdSchema, + }), + ) + .output( + z.object({ + apps: z.array( + z.object({ + appName: z.string(), + appPath: z.string(), + iconDataUrl: z.string().nullable(), + }), + ), + }), + ) + .handler(async ({ input }) => { + const fullPath = resolvePathWithinTaskDir({ + dir: taskDir(input.id), + filePath: input.filePath, + }); + if (!fullPath) { + return { apps: [] }; + } + return { apps: await getFileOpenCandidates(fullPath) }; + }); + const showFileInFolder = base .errors({ FILE_NOT_FOUND: { @@ -558,10 +719,14 @@ export const utils = { copyTaskPathToClipboard, exportZip, getSupportedEditors, + getTaskFileOpenCandidates, + getTaskFileOpenTarget, live, minimizeWindow, openExternalLink, openFolder, + openTaskFile, + openTaskFileWith, openTaskIn, showFileInFolder, showFolderPicker, diff --git a/apps/studio/src/vite-env.d.ts b/apps/studio/src/vite-env.d.ts index 8aedfc65a..40c17512e 100644 --- a/apps/studio/src/vite-env.d.ts +++ b/apps/studio/src/vite-env.d.ts @@ -48,6 +48,7 @@ declare namespace NodeJS { WIN_GCP_KMS_KEY_VERSION: string | undefined; WIN_TIMESTAMP_URL: string | undefined; XDG_CURRENT_DESKTOP: string | undefined; + XDG_DATA_DIRS: string | undefined; }; } }