From dcb328ac5e7c20aef901cf568bc7fc278095c518 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 6 Sep 2026 09:41:21 +0000 Subject: [PATCH] feat(desktop): open a local file with a chosen application MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-clicking a local file now offers "Open with…". It opens a dialog with the system default preselected and the applications registered for that file type listed under it, so the ordinary case is Enter and picking a different one is there when you want it. The handlers come from `gio`, which is what the desktop itself consults: `gio info` for the content type, `gio mime TYPE` for the default and the registered set, and the entry's own Exec line via `gio launch` rather than us reconstructing its argument syntax. With no handler chosen it is `shell.openPath`, the plain double-click. Two details in the parsing that are wrong in the obvious implementation, both covered by tests against real /usr/share/applications entries: - A .desktop file carries localised keys. `Name[zh_TW]` comes after `Name`, so matching `Name` anywhere in the file names btop "系統監視器". - It also carries action groups (`[Desktop Action new-window]`) with their own `Name=`. Only the `[Desktop Entry]` group is read. `NoDisplay` and `Hidden` entries are skipped, because those are plumbing the desktop keeps out of menus and this is a menu. A `Terminal=true` application says "Runs in a terminal", since launching one from a file manager usually flashes and exits, and a choice that appears to do nothing is worse than one that is labelled. Scope, and why: the item is disabled for a pane pointed at a server (the path names a file on that machine, and there is nothing here to hand to a local application) and for directories. `handlerId` is validated as a bare `*.desktop` filename in the main process and resolved only against the XDG application directories, because a renderer that could pass a path there would be choosing which program runs. The list is a bounded flex column with the ScrollArea as `min-h-0 flex-1`, not `max-h` on the ScrollArea root. The root form does not clip: the viewport is `size-full`, so with no definite height it grows to fit and the last rows are cut off by the dialog with no way to scroll to them. That is the same trap fixed in the preview dialog earlier today, found the same way, by looking at it. Verified in headless Chromium under the app's real CSP, both themes: the menu item appears on a local file and is disabled on a directory, the dialog lists the handlers with the default marked, Open with nothing chosen sends a null handler, and choosing Visual Studio Code sends code.desktop. Handler enumeration itself could not be exercised end to end here: this box has gio but no registered desktop associations, so `gio mime` returns nothing for every type. The parsers are tested against real gio output and real desktop files; the live lookup is not. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VScug5VRbcTuhiAoieeQ52 --- apps/desktop/electron/main/ipc.ts | 13 + .../electron/main/services/open-with.test.ts | 114 ++++++++ .../electron/main/services/open-with.ts | 243 ++++++++++++++++++ apps/desktop/electron/preload/index.ts | 2 + apps/desktop/electron/shared/contract.ts | 20 ++ .../src/components/open-with-dialog.tsx | 191 ++++++++++++++ apps/desktop/src/components/pane.tsx | 34 +++ apps/desktop/src/lib/api.ts | 20 ++ 8 files changed, 637 insertions(+) create mode 100644 apps/desktop/electron/main/services/open-with.test.ts create mode 100644 apps/desktop/electron/main/services/open-with.ts create mode 100644 apps/desktop/src/components/open-with-dialog.tsx diff --git a/apps/desktop/electron/main/ipc.ts b/apps/desktop/electron/main/ipc.ts index bfcfc5c..7295bc5 100644 --- a/apps/desktop/electron/main/ipc.ts +++ b/apps/desktop/electron/main/ipc.ts @@ -17,6 +17,7 @@ import { FleetRunIdSchema, IPC, JobIdSchema, + OpenWithRequestSchema, PreviewRequestSchema, PathSchema, RemotePathRequestSchema, @@ -43,6 +44,7 @@ import { } from './services/fleet.js' import { browserFor, dropSession, sessionFor } from './services/sessions.js' import { store } from './services/store.js' +import { handlersFor, openWith } from './services/open-with.js' import { cancelPreview, cancelTransfer, previewTransfer, saveProfile, startTransfer } from './services/transfers.js' /** @@ -298,6 +300,17 @@ export function registerIpc(): void { }), ) + // --- open with ----------------------------------------------------------- + // Local only. A pane pointed at a server has no path this machine can open, + // and the renderer disables the item there rather than sending a remote one. + + handle(IPC.fsHandlers, z.object({ path: PathSchema }), async ({ path }) => handlersFor(resolveLocalPath(path))) + + handle(IPC.fsOpenWith, OpenWithRequestSchema, async ({ path, handlerId }) => { + await openWith(resolveLocalPath(path), handlerId) + return true + }) + // --- transfers ----------------------------------------------------------- handle(IPC.transfersPreview, PreviewRequestSchema, async (request, event) => previewTransfer(request, event.sender)) diff --git a/apps/desktop/electron/main/services/open-with.test.ts b/apps/desktop/electron/main/services/open-with.test.ts new file mode 100644 index 0000000..769cd62 --- /dev/null +++ b/apps/desktop/electron/main/services/open-with.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ shell: { openPath: async () => '' } })) + +const { parseDesktopEntry, parseGioMime } = await import('./open-with.js') + +describe('parseDesktopEntry', () => { + /* + * Taken from a real /usr/share/applications entry. The localised keys are + * the point: a parser that matches `Name` anywhere returns "系統監視器", + * because `Name[zh_TW]` comes later in the file than `Name`. + */ + const BTOP = `[Desktop Entry] +Type=Application +Version=1.0 +Name=btop++ +GenericName=System Monitor +GenericName[it]=Monitor di sistema +Name[zh_TW]=系統監視器 +Comment=Resource monitor +Icon=btop +Exec=btop +Terminal=true +Categories=System;Monitor;ConsoleOnly; +` + + it('reads the untranslated name', () => { + expect(parseDesktopEntry(BTOP).name).toBe('btop++') + }) + + it('notices a terminal application', () => { + expect(parseDesktopEntry(BTOP).terminal).toBe(true) + }) + + /* + * A desktop file can carry action groups with their own Name=. Reading the + * whole file rather than the [Desktop Entry] group names the app after + * whichever action happens to be last. + */ + it('ignores keys outside the [Desktop Entry] group', () => { + const withActions = `[Desktop Entry] +Type=Application +Name=Files +Terminal=false + +[Desktop Action new-window] +Name=Open a New Window +Exec=nautilus --new-window +` + const parsed = parseDesktopEntry(withActions) + expect(parsed.name).toBe('Files') + expect(parsed.terminal).toBe(false) + }) + + it('reads NoDisplay and Hidden, which keep plumbing out of a menu', () => { + const hidden = `[Desktop Entry] +Name=Session Agent +NoDisplay=true +Hidden=TRUE +` + const parsed = parseDesktopEntry(hidden) + expect(parsed.noDisplay).toBe(true) + expect(parsed.hidden).toBe(true) + }) + + it('survives comments, blank lines and a missing name', () => { + const parsed = parseDesktopEntry('# a comment\n\n[Desktop Entry]\nType=Application\n') + expect(parsed.name).toBeNull() + expect(parsed.terminal).toBe(false) + }) +}) + +describe('parseGioMime', () => { + // gio's real output, curly quotes and leading tabs included. + const FULL = `Default application for “text/plain”: org.gnome.gedit.desktop +Registered applications: +\torg.gnome.gedit.desktop +\tvim.desktop +\tcode.desktop +Recommended applications: +\torg.gnome.gedit.desktop +\tcode.desktop +` + + it('finds the default and every alternative', () => { + const parsed = parseGioMime(FULL) + expect(parsed.defaultId).toBe('org.gnome.gedit.desktop') + expect(parsed.ids).toEqual(['org.gnome.gedit.desktop', 'vim.desktop', 'code.desktop']) + }) + + it('puts the default first and never lists it twice', () => { + // It appears under both headings and as the default; the dialog shows one row. + const parsed = parseGioMime(FULL) + expect(parsed.ids.filter((id) => id === 'org.gnome.gedit.desktop')).toHaveLength(1) + expect(parsed.ids[0]).toBe('org.gnome.gedit.desktop') + }) + + it('handles a type nothing is registered for', () => { + const parsed = parseGioMime('No default applications for “text/markdown”\n') + expect(parsed.defaultId).toBeNull() + expect(parsed.ids).toEqual([]) + }) + + it('handles registrations with no default', () => { + const parsed = parseGioMime('No default applications for “text/markdown”\nRegistered applications:\n\tvim.desktop\n') + expect(parsed.defaultId).toBeNull() + expect(parsed.ids).toEqual(['vim.desktop']) + }) + + it('ignores anything that is not a desktop id', () => { + const parsed = parseGioMime('Registered applications:\n\tnot-an-app\n\tvim.desktop\n') + expect(parsed.ids).toEqual(['vim.desktop']) + }) +}) diff --git a/apps/desktop/electron/main/services/open-with.ts b/apps/desktop/electron/main/services/open-with.ts new file mode 100644 index 0000000..f51e112 --- /dev/null +++ b/apps/desktop/electron/main/services/open-with.ts @@ -0,0 +1,243 @@ +import { execFile } from 'node:child_process' +import { readFile, readdir } from 'node:fs/promises' +import { homedir } from 'node:os' +import { basename, join } from 'node:path' +import { promisify } from 'node:util' +import { shell } from 'electron' + +const execFileAsync = promisify(execFile) + +export type FileHandler = { + /** The desktop entry id, e.g. `org.gnome.gedit.desktop`. */ + id: string + name: string + /** True for the handler the system would use on a plain double-click. */ + isDefault: boolean + /** Runs in a terminal, so opening it from a GUI usually does nothing useful. */ + terminal: boolean +} + +export type HandlerList = { + /** The file's content type, for the dialog to show. Null when it could not be read. */ + contentType: string | null + handlers: FileHandler[] + /** + * Why the list is empty or short, when there is a reason worth showing. + * A dialog that offers nothing and explains nothing is a dead end. + */ + note: string | null +} + +/** + * The `[Desktop Entry]` group of a .desktop file. + * + * Only that group: a file can carry `[Desktop Action new-window]` sections + * with their own `Name=`, and reading the whole file with a naive key match + * picks up whichever came last. + * + * Localised keys are skipped. `Name[it]=Monitor di sistema` is not the name to + * show, and `Name` is not the last one in the file. + */ +export function parseDesktopEntry(text: string): { + name: string | null + noDisplay: boolean + terminal: boolean + hidden: boolean +} { + let inEntry = false + let name: string | null = null + let noDisplay = false + let terminal = false + let hidden = false + + for (const raw of text.split('\n')) { + const line = raw.trim() + if (line === '' || line.startsWith('#')) continue + if (line.startsWith('[')) { + inEntry = line === '[Desktop Entry]' + continue + } + if (!inEntry) continue + + const equals = line.indexOf('=') + if (equals === -1) continue + const key = line.slice(0, equals).trim() + const value = line.slice(equals + 1).trim() + + // `Name[it]` and friends are localisations of a key, not the key. + if (key.includes('[')) continue + + if (key === 'Name' && name === null) name = value + else if (key === 'NoDisplay') noDisplay = value.toLowerCase() === 'true' + else if (key === 'Terminal') terminal = value.toLowerCase() === 'true' + else if (key === 'Hidden') hidden = value.toLowerCase() === 'true' + } + + return { name, noDisplay, terminal, hidden } +} + +/** + * Reads `gio mime TYPE`. + * + * Its output looks like this, curly quotes and tabs included: + * + * Default application for “text/plain”: org.gnome.gedit.desktop + * Registered applications: + * org.gnome.gedit.desktop + * vim.desktop + * Recommended applications: + * org.gnome.gedit.desktop + * + * and like this when there is nothing: + * + * No default applications for “text/plain” + * + * The default is returned first and never duplicated into the rest. + */ +export function parseGioMime(stdout: string): { defaultId: string | null; ids: string[] } { + let defaultId: string | null = null + const ids: string[] = [] + + for (const raw of stdout.split('\n')) { + const line = raw.trim() + if (line === '') continue + + const isDefault = /^Default application for .*:\s*(\S+)$/.exec(line) + if (isDefault) { + defaultId = isDefault[1] ?? null + continue + } + // Section headings and the "nothing here" line carry no ids. + if (line.endsWith(':') || line.startsWith('No default applications')) continue + if (line.endsWith('.desktop')) ids.push(line) + } + + const seen = new Set() + const ordered = [defaultId, ...ids].filter((id): id is string => { + if (!id || seen.has(id)) return false + seen.add(id) + return true + }) + + return { defaultId, ids: ordered } +} + +/** Where .desktop files live, most specific first. */ +function applicationDirectories(): string[] { + const dataHome = process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share') + const dataDirs = (process.env.XDG_DATA_DIRS || '/usr/local/share:/usr/share').split(':').filter(Boolean) + return [dataHome, ...dataDirs].map((directory) => join(directory, 'applications')) +} + +/** + * Resolves a desktop id to a readable file. + * + * The id is treated as a bare filename. A renderer supplying + * `../../../etc/passwd.desktop` would otherwise pick the file to launch, and + * "which program opens this" is not a decision the renderer gets to make + * outside the installed set. + */ +async function findDesktopFile(id: string): Promise<{ path: string; text: string } | null> { + if (id !== basename(id) || !id.endsWith('.desktop')) return null + for (const directory of applicationDirectories()) { + const path = join(directory, id) + try { + return { path, text: await readFile(path, 'utf8') } + } catch { + // Not in this directory; try the next. + } + } + return null +} + +async function contentTypeOf(path: string): Promise { + try { + const { stdout } = await execFileAsync('gio', ['info', '-a', 'standard::content-type', path]) + return /standard::content-type:\s*(\S+)/.exec(stdout)?.[1] ?? null + } catch { + return null + } +} + +/** + * The applications that can open a file, with the system default marked. + * + * Linux only, in the sense that only Linux can enumerate them: `gio` is part + * of glib and is what the desktop itself consults. Elsewhere the list comes + * back empty and the dialog offers the system default alone, which is still + * the thing most people want. + */ +export async function handlersFor(path: string): Promise { + if (process.platform !== 'linux') { + return { + contentType: null, + handlers: [], + note: 'Choosing a different application is only supported on Linux. The system default is used.', + } + } + + const contentType = await contentTypeOf(path) + if (!contentType) { + return { contentType: null, handlers: [], note: 'Could not read this file’s type. The system default is used.' } + } + + let ids: string[] = [] + let defaultId: string | null = null + try { + const { stdout } = await execFileAsync('gio', ['mime', contentType]) + const parsed = parseGioMime(stdout) + ids = parsed.ids + defaultId = parsed.defaultId + } catch { + return { contentType, handlers: [], note: 'gio is not available, so the system default is used.' } + } + + const handlers: FileHandler[] = [] + for (const id of ids) { + const found = await findDesktopFile(id) + if (!found) continue + const entry = parseDesktopEntry(found.text) + // NoDisplay/Hidden entries are plumbing the desktop hides from menus, and + // this is a menu. + if (entry.noDisplay || entry.hidden) continue + handlers.push({ + id, + name: entry.name ?? id.replace(/\.desktop$/, ''), + isDefault: id === defaultId, + terminal: entry.terminal, + }) + } + + return { + contentType, + handlers, + note: + handlers.length === 0 + ? `Nothing is registered to open ${contentType} on this machine. The system default is used.` + : null, + } +} + +/** + * Opens a file, optionally with a chosen application. + * + * With no handler this is the plain double-click: whatever the system would + * do. With one, that application is launched through `gio launch`, which + * applies the desktop entry's own Exec line rather than us trying to + * reconstruct its argument syntax. + */ +export async function openWith(path: string, handlerId?: string | null): Promise { + if (!handlerId) { + const error = await shell.openPath(path) + // openPath resolves with a message rather than rejecting. + if (error) throw new Error(error) + return + } + + const found = await findDesktopFile(handlerId) + if (!found) throw new Error('That application is no longer installed.') + await execFileAsync('gio', ['launch', found.path, path]) +} + +/** Exported for the tests: the search path is environment-dependent. */ +export const _internals = { applicationDirectories, findDesktopFile } diff --git a/apps/desktop/electron/preload/index.ts b/apps/desktop/electron/preload/index.ts index 7869ac2..fd944d8 100644 --- a/apps/desktop/electron/preload/index.ts +++ b/apps/desktop/electron/preload/index.ts @@ -31,6 +31,8 @@ const api = { call(connectionId ? IPC.fsCreateFileRemote : IPC.fsCreateFileLocal, { connectionId, directory, name }), rename: (directory: string, from: string, to: string, connectionId?: string) => call(connectionId ? IPC.fsRenameRemote : IPC.fsRenameLocal, { connectionId, directory, from, to }), + handlers: (path: string) => call(IPC.fsHandlers, { path }), + openWith: (path: string, handlerId: string | null = null) => call(IPC.fsOpenWith, { path, handlerId }), remove: (directory: string, name: string, isDirectory: boolean, connectionId?: string) => call(connectionId ? IPC.fsDeleteRemote : IPC.fsDeleteLocal, { connectionId, diff --git a/apps/desktop/electron/shared/contract.ts b/apps/desktop/electron/shared/contract.ts index 0121e3e..287be18 100644 --- a/apps/desktop/electron/shared/contract.ts +++ b/apps/desktop/electron/shared/contract.ts @@ -28,6 +28,8 @@ export const IPC = { fsRenameLocal: 'fs:rename-local', fsDeleteLocal: 'fs:delete-local', fsCreateFileLocal: 'fs:create-file-local', + fsHandlers: 'fs:handlers', + fsOpenWith: 'fs:open-with', transfersPreview: 'transfers:preview', transfersPreviewCancel: 'transfers:preview-cancel', @@ -198,6 +200,24 @@ export const ProfileSaveSchema = z.object({ sourcePane: z.enum(['left', 'right']).default('left'), }) +/** + * Opening a local file, optionally with a chosen application. + * + * `handlerId` is a desktop entry id and is checked as a bare filename in the + * main process before anything is launched: a renderer that could pass a path + * here would be choosing which program runs. + */ +export const OpenWithRequestSchema = z.object({ + path: PathSchema, + handlerId: z + .string() + .min(1) + .max(255) + .regex(/^[^/\\\0]+\.desktop$/, 'That is not an application id.') + .nullable() + .default(null), +}) + export const RemotePathRequestSchema = z.object({ connectionId: ConnectionIdSchema, path: PathSchema, diff --git a/apps/desktop/src/components/open-with-dialog.tsx b/apps/desktop/src/components/open-with-dialog.tsx new file mode 100644 index 0000000..d8f9f79 --- /dev/null +++ b/apps/desktop/src/components/open-with-dialog.tsx @@ -0,0 +1,191 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { AppWindow, CircleAlert, Terminal } from 'lucide-react' +import { api, unwrap, type HandlerList } from '@/lib/api' +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { ScrollArea } from '@/components/ui/scroll-area' +import { cn } from '@/lib/utils' + +/** The system default, as a row. It is always offered, even with no list. */ +const SYSTEM_DEFAULT = '__system__' + +/** + * Open with. + * + * Double-clicking a file should just open it, and mostly the system default is + * right. This is for the times it is not: the default is preselected and Open + * is one keystroke away, so the common case costs nothing, but the list is + * right there when you want a different application. + */ +export function OpenWithDialog({ + open, + path, + name, + onClose, +}: { + open: boolean + path: string | null + name: string | null + onClose: () => void +}) { + const [list, setList] = useState(null) + const [chosen, setChosen] = useState(SYSTEM_DEFAULT) + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + const openRef = useRef(null) + + useEffect(() => { + if (!open || !path) return + setList(null) + setError(null) + setChosen(SYSTEM_DEFAULT) + let current = true + void (async () => { + try { + const result = await unwrap(api()?.fs.handlers(path)) + if (!current) return + setList(result) + // The system default row stays selected: it is what the machine would + // do, and preselecting a named application would quietly change the + // answer for anyone who just presses Enter. + } catch (caught) { + if (current) setError(caught instanceof Error ? caught.message : String(caught)) + } + })() + return () => { + current = false + } + }, [open, path]) + + const launch = async () => { + if (!path) return + setBusy(true) + setError(null) + try { + await unwrap(api()?.fs.openWith(path, chosen === SYSTEM_DEFAULT ? null : chosen)) + onClose() + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)) + } finally { + setBusy(false) + } + } + + const rows = list?.handlers ?? [] + + return ( + (next ? undefined : onClose())}> + + + Open with + + {name ?? path} + {list?.contentType ? · {list.contentType} : null} + + + +
+ {!list && !error ? ( +
+ + Looking for applications… +
+ ) : ( + /* + `max-h` on a ScrollArea root does not clip: its viewport is + `size-full`, so with no definite height it grows to fit and the + last rows are cut off by the dialog with no way to reach them. + The bounded flex column is what gives the viewport a height. + */ +
+ +
+ setChosen(SYSTEM_DEFAULT)} + /> + {rows.map((handler) => ( + setChosen(handler.id)} + /> + ))} +
+
+
+ )} + + {list?.note ?

{list.note}

: null} + {error ? ( +

+ + {error} +

+ ) : null} +
+ + + + + +
+
+ ) +} + +function HandlerRow({ + label, + detail, + terminal, + selected, + onSelect, +}: { + label: string + detail: string + terminal?: boolean + selected: boolean + onSelect: () => void +}) { + return ( + + ) +} diff --git a/apps/desktop/src/components/pane.tsx b/apps/desktop/src/components/pane.tsx index b049578..f0c7216 100644 --- a/apps/desktop/src/components/pane.tsx +++ b/apps/desktop/src/components/pane.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ChevronRight, CornerLeftUp, + ExternalLink, FilePlus2, FileText, Folder, @@ -22,6 +23,7 @@ import { isNavigable } from '@/lib/entries' import { formatBytes, formatDate, formatMode, joinPath, parentPath } from '@/lib/format' import { EndpointSelect, type PaneEndpoint } from '@/components/endpoint-select' import { DeleteDialog, NameDialog } from '@/components/entry-dialogs' +import { OpenWithDialog } from '@/components/open-with-dialog' import { Checkbox } from '@/components/ui/checkbox' import { ContextMenu, @@ -234,7 +236,14 @@ export function Pane({ // space, which is what distinguishes "new file here" from "rename this". const [target, setTarget] = useState(null) const [dialog, setDialog] = useState<'mkdir' | 'create-file' | 'rename' | 'delete' | null>(null) + /** The file the Open with dialog is about, or null while it is closed. */ + const [openWithTarget, setOpenWithTarget] = useState<{ path: string; name: string } | null>(null) const [busy, setBusy] = useState(false) + /* + * "Open with" needs a file this machine can actually open. A pane pointed at + * a server names a path on that server, and a directory is not a document. + */ + const canOpenWith = state.endpoint.kind === 'local' && target !== null && target.type !== 'directory' const [opError, setOpError] = useState(null) useEffect(() => { @@ -591,6 +600,24 @@ export function Pane({ + {/* + Local files only. A pane pointed at a server names a path on that + server, and there is nothing on this machine to hand to a local + application, so the item is disabled rather than failing after + the click. Directories are excluded for the same reason the rest + of this menu treats them differently: "open with" is a question + about a document. + */} + { + if (target) setOpenWithTarget({ path: target.path, name: target.name }) + }} + > + + Open with… + + onNavigate(state.path)}> Refresh @@ -650,6 +677,13 @@ export function Pane({ } /> + setOpenWithTarget(null)} + /> + > rename(directory: string, from: string, to: string, connectionId?: string): Promise> remove(directory: string, name: string, isDirectory: boolean, connectionId?: string): Promise> + /** The applications registered for a local file, system default marked. */ + handlers(path: string): Promise> + /** Opens a local file; a null handler means the system default. */ + openWith(path: string, handlerId?: string | null): Promise> } transfers: { preview(request: unknown): Promise>