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>