Skip to content

Commit dcb328a

Browse files
ralyodioclaude
andcommitted
feat(desktop): open a local file with a chosen application
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VScug5VRbcTuhiAoieeQ52
1 parent fa1df78 commit dcb328a

8 files changed

Lines changed: 637 additions & 0 deletions

File tree

apps/desktop/electron/main/ipc.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
FleetRunIdSchema,
1818
IPC,
1919
JobIdSchema,
20+
OpenWithRequestSchema,
2021
PreviewRequestSchema,
2122
PathSchema,
2223
RemotePathRequestSchema,
@@ -43,6 +44,7 @@ import {
4344
} from './services/fleet.js'
4445
import { browserFor, dropSession, sessionFor } from './services/sessions.js'
4546
import { store } from './services/store.js'
47+
import { handlersFor, openWith } from './services/open-with.js'
4648
import { cancelPreview, cancelTransfer, previewTransfer, saveProfile, startTransfer } from './services/transfers.js'
4749

4850
/**
@@ -298,6 +300,17 @@ export function registerIpc(): void {
298300
}),
299301
)
300302

303+
// --- open with -----------------------------------------------------------
304+
// Local only. A pane pointed at a server has no path this machine can open,
305+
// and the renderer disables the item there rather than sending a remote one.
306+
307+
handle(IPC.fsHandlers, z.object({ path: PathSchema }), async ({ path }) => handlersFor(resolveLocalPath(path)))
308+
309+
handle(IPC.fsOpenWith, OpenWithRequestSchema, async ({ path, handlerId }) => {
310+
await openWith(resolveLocalPath(path), handlerId)
311+
return true
312+
})
313+
301314
// --- transfers -----------------------------------------------------------
302315

303316
handle(IPC.transfersPreview, PreviewRequestSchema, async (request, event) => previewTransfer(request, event.sender))
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
3+
vi.mock('electron', () => ({ shell: { openPath: async () => '' } }))
4+
5+
const { parseDesktopEntry, parseGioMime } = await import('./open-with.js')
6+
7+
describe('parseDesktopEntry', () => {
8+
/*
9+
* Taken from a real /usr/share/applications entry. The localised keys are
10+
* the point: a parser that matches `Name` anywhere returns "系統監視器",
11+
* because `Name[zh_TW]` comes later in the file than `Name`.
12+
*/
13+
const BTOP = `[Desktop Entry]
14+
Type=Application
15+
Version=1.0
16+
Name=btop++
17+
GenericName=System Monitor
18+
GenericName[it]=Monitor di sistema
19+
Name[zh_TW]=系統監視器
20+
Comment=Resource monitor
21+
Icon=btop
22+
Exec=btop
23+
Terminal=true
24+
Categories=System;Monitor;ConsoleOnly;
25+
`
26+
27+
it('reads the untranslated name', () => {
28+
expect(parseDesktopEntry(BTOP).name).toBe('btop++')
29+
})
30+
31+
it('notices a terminal application', () => {
32+
expect(parseDesktopEntry(BTOP).terminal).toBe(true)
33+
})
34+
35+
/*
36+
* A desktop file can carry action groups with their own Name=. Reading the
37+
* whole file rather than the [Desktop Entry] group names the app after
38+
* whichever action happens to be last.
39+
*/
40+
it('ignores keys outside the [Desktop Entry] group', () => {
41+
const withActions = `[Desktop Entry]
42+
Type=Application
43+
Name=Files
44+
Terminal=false
45+
46+
[Desktop Action new-window]
47+
Name=Open a New Window
48+
Exec=nautilus --new-window
49+
`
50+
const parsed = parseDesktopEntry(withActions)
51+
expect(parsed.name).toBe('Files')
52+
expect(parsed.terminal).toBe(false)
53+
})
54+
55+
it('reads NoDisplay and Hidden, which keep plumbing out of a menu', () => {
56+
const hidden = `[Desktop Entry]
57+
Name=Session Agent
58+
NoDisplay=true
59+
Hidden=TRUE
60+
`
61+
const parsed = parseDesktopEntry(hidden)
62+
expect(parsed.noDisplay).toBe(true)
63+
expect(parsed.hidden).toBe(true)
64+
})
65+
66+
it('survives comments, blank lines and a missing name', () => {
67+
const parsed = parseDesktopEntry('# a comment\n\n[Desktop Entry]\nType=Application\n')
68+
expect(parsed.name).toBeNull()
69+
expect(parsed.terminal).toBe(false)
70+
})
71+
})
72+
73+
describe('parseGioMime', () => {
74+
// gio's real output, curly quotes and leading tabs included.
75+
const FULL = `Default application for “text/plain”: org.gnome.gedit.desktop
76+
Registered applications:
77+
\torg.gnome.gedit.desktop
78+
\tvim.desktop
79+
\tcode.desktop
80+
Recommended applications:
81+
\torg.gnome.gedit.desktop
82+
\tcode.desktop
83+
`
84+
85+
it('finds the default and every alternative', () => {
86+
const parsed = parseGioMime(FULL)
87+
expect(parsed.defaultId).toBe('org.gnome.gedit.desktop')
88+
expect(parsed.ids).toEqual(['org.gnome.gedit.desktop', 'vim.desktop', 'code.desktop'])
89+
})
90+
91+
it('puts the default first and never lists it twice', () => {
92+
// It appears under both headings and as the default; the dialog shows one row.
93+
const parsed = parseGioMime(FULL)
94+
expect(parsed.ids.filter((id) => id === 'org.gnome.gedit.desktop')).toHaveLength(1)
95+
expect(parsed.ids[0]).toBe('org.gnome.gedit.desktop')
96+
})
97+
98+
it('handles a type nothing is registered for', () => {
99+
const parsed = parseGioMime('No default applications for “text/markdown”\n')
100+
expect(parsed.defaultId).toBeNull()
101+
expect(parsed.ids).toEqual([])
102+
})
103+
104+
it('handles registrations with no default', () => {
105+
const parsed = parseGioMime('No default applications for “text/markdown”\nRegistered applications:\n\tvim.desktop\n')
106+
expect(parsed.defaultId).toBeNull()
107+
expect(parsed.ids).toEqual(['vim.desktop'])
108+
})
109+
110+
it('ignores anything that is not a desktop id', () => {
111+
const parsed = parseGioMime('Registered applications:\n\tnot-an-app\n\tvim.desktop\n')
112+
expect(parsed.ids).toEqual(['vim.desktop'])
113+
})
114+
})
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
import { execFile } from 'node:child_process'
2+
import { readFile, readdir } from 'node:fs/promises'
3+
import { homedir } from 'node:os'
4+
import { basename, join } from 'node:path'
5+
import { promisify } from 'node:util'
6+
import { shell } from 'electron'
7+
8+
const execFileAsync = promisify(execFile)
9+
10+
export type FileHandler = {
11+
/** The desktop entry id, e.g. `org.gnome.gedit.desktop`. */
12+
id: string
13+
name: string
14+
/** True for the handler the system would use on a plain double-click. */
15+
isDefault: boolean
16+
/** Runs in a terminal, so opening it from a GUI usually does nothing useful. */
17+
terminal: boolean
18+
}
19+
20+
export type HandlerList = {
21+
/** The file's content type, for the dialog to show. Null when it could not be read. */
22+
contentType: string | null
23+
handlers: FileHandler[]
24+
/**
25+
* Why the list is empty or short, when there is a reason worth showing.
26+
* A dialog that offers nothing and explains nothing is a dead end.
27+
*/
28+
note: string | null
29+
}
30+
31+
/**
32+
* The `[Desktop Entry]` group of a .desktop file.
33+
*
34+
* Only that group: a file can carry `[Desktop Action new-window]` sections
35+
* with their own `Name=`, and reading the whole file with a naive key match
36+
* picks up whichever came last.
37+
*
38+
* Localised keys are skipped. `Name[it]=Monitor di sistema` is not the name to
39+
* show, and `Name` is not the last one in the file.
40+
*/
41+
export function parseDesktopEntry(text: string): {
42+
name: string | null
43+
noDisplay: boolean
44+
terminal: boolean
45+
hidden: boolean
46+
} {
47+
let inEntry = false
48+
let name: string | null = null
49+
let noDisplay = false
50+
let terminal = false
51+
let hidden = false
52+
53+
for (const raw of text.split('\n')) {
54+
const line = raw.trim()
55+
if (line === '' || line.startsWith('#')) continue
56+
if (line.startsWith('[')) {
57+
inEntry = line === '[Desktop Entry]'
58+
continue
59+
}
60+
if (!inEntry) continue
61+
62+
const equals = line.indexOf('=')
63+
if (equals === -1) continue
64+
const key = line.slice(0, equals).trim()
65+
const value = line.slice(equals + 1).trim()
66+
67+
// `Name[it]` and friends are localisations of a key, not the key.
68+
if (key.includes('[')) continue
69+
70+
if (key === 'Name' && name === null) name = value
71+
else if (key === 'NoDisplay') noDisplay = value.toLowerCase() === 'true'
72+
else if (key === 'Terminal') terminal = value.toLowerCase() === 'true'
73+
else if (key === 'Hidden') hidden = value.toLowerCase() === 'true'
74+
}
75+
76+
return { name, noDisplay, terminal, hidden }
77+
}
78+
79+
/**
80+
* Reads `gio mime TYPE`.
81+
*
82+
* Its output looks like this, curly quotes and tabs included:
83+
*
84+
* Default application for “text/plain”: org.gnome.gedit.desktop
85+
* Registered applications:
86+
* org.gnome.gedit.desktop
87+
* vim.desktop
88+
* Recommended applications:
89+
* org.gnome.gedit.desktop
90+
*
91+
* and like this when there is nothing:
92+
*
93+
* No default applications for “text/plain”
94+
*
95+
* The default is returned first and never duplicated into the rest.
96+
*/
97+
export function parseGioMime(stdout: string): { defaultId: string | null; ids: string[] } {
98+
let defaultId: string | null = null
99+
const ids: string[] = []
100+
101+
for (const raw of stdout.split('\n')) {
102+
const line = raw.trim()
103+
if (line === '') continue
104+
105+
const isDefault = /^Default application for .*:\s*(\S+)$/.exec(line)
106+
if (isDefault) {
107+
defaultId = isDefault[1] ?? null
108+
continue
109+
}
110+
// Section headings and the "nothing here" line carry no ids.
111+
if (line.endsWith(':') || line.startsWith('No default applications')) continue
112+
if (line.endsWith('.desktop')) ids.push(line)
113+
}
114+
115+
const seen = new Set<string>()
116+
const ordered = [defaultId, ...ids].filter((id): id is string => {
117+
if (!id || seen.has(id)) return false
118+
seen.add(id)
119+
return true
120+
})
121+
122+
return { defaultId, ids: ordered }
123+
}
124+
125+
/** Where .desktop files live, most specific first. */
126+
function applicationDirectories(): string[] {
127+
const dataHome = process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share')
128+
const dataDirs = (process.env.XDG_DATA_DIRS || '/usr/local/share:/usr/share').split(':').filter(Boolean)
129+
return [dataHome, ...dataDirs].map((directory) => join(directory, 'applications'))
130+
}
131+
132+
/**
133+
* Resolves a desktop id to a readable file.
134+
*
135+
* The id is treated as a bare filename. A renderer supplying
136+
* `../../../etc/passwd.desktop` would otherwise pick the file to launch, and
137+
* "which program opens this" is not a decision the renderer gets to make
138+
* outside the installed set.
139+
*/
140+
async function findDesktopFile(id: string): Promise<{ path: string; text: string } | null> {
141+
if (id !== basename(id) || !id.endsWith('.desktop')) return null
142+
for (const directory of applicationDirectories()) {
143+
const path = join(directory, id)
144+
try {
145+
return { path, text: await readFile(path, 'utf8') }
146+
} catch {
147+
// Not in this directory; try the next.
148+
}
149+
}
150+
return null
151+
}
152+
153+
async function contentTypeOf(path: string): Promise<string | null> {
154+
try {
155+
const { stdout } = await execFileAsync('gio', ['info', '-a', 'standard::content-type', path])
156+
return /standard::content-type:\s*(\S+)/.exec(stdout)?.[1] ?? null
157+
} catch {
158+
return null
159+
}
160+
}
161+
162+
/**
163+
* The applications that can open a file, with the system default marked.
164+
*
165+
* Linux only, in the sense that only Linux can enumerate them: `gio` is part
166+
* of glib and is what the desktop itself consults. Elsewhere the list comes
167+
* back empty and the dialog offers the system default alone, which is still
168+
* the thing most people want.
169+
*/
170+
export async function handlersFor(path: string): Promise<HandlerList> {
171+
if (process.platform !== 'linux') {
172+
return {
173+
contentType: null,
174+
handlers: [],
175+
note: 'Choosing a different application is only supported on Linux. The system default is used.',
176+
}
177+
}
178+
179+
const contentType = await contentTypeOf(path)
180+
if (!contentType) {
181+
return { contentType: null, handlers: [], note: 'Could not read this file’s type. The system default is used.' }
182+
}
183+
184+
let ids: string[] = []
185+
let defaultId: string | null = null
186+
try {
187+
const { stdout } = await execFileAsync('gio', ['mime', contentType])
188+
const parsed = parseGioMime(stdout)
189+
ids = parsed.ids
190+
defaultId = parsed.defaultId
191+
} catch {
192+
return { contentType, handlers: [], note: 'gio is not available, so the system default is used.' }
193+
}
194+
195+
const handlers: FileHandler[] = []
196+
for (const id of ids) {
197+
const found = await findDesktopFile(id)
198+
if (!found) continue
199+
const entry = parseDesktopEntry(found.text)
200+
// NoDisplay/Hidden entries are plumbing the desktop hides from menus, and
201+
// this is a menu.
202+
if (entry.noDisplay || entry.hidden) continue
203+
handlers.push({
204+
id,
205+
name: entry.name ?? id.replace(/\.desktop$/, ''),
206+
isDefault: id === defaultId,
207+
terminal: entry.terminal,
208+
})
209+
}
210+
211+
return {
212+
contentType,
213+
handlers,
214+
note:
215+
handlers.length === 0
216+
? `Nothing is registered to open ${contentType} on this machine. The system default is used.`
217+
: null,
218+
}
219+
}
220+
221+
/**
222+
* Opens a file, optionally with a chosen application.
223+
*
224+
* With no handler this is the plain double-click: whatever the system would
225+
* do. With one, that application is launched through `gio launch`, which
226+
* applies the desktop entry's own Exec line rather than us trying to
227+
* reconstruct its argument syntax.
228+
*/
229+
export async function openWith(path: string, handlerId?: string | null): Promise<void> {
230+
if (!handlerId) {
231+
const error = await shell.openPath(path)
232+
// openPath resolves with a message rather than rejecting.
233+
if (error) throw new Error(error)
234+
return
235+
}
236+
237+
const found = await findDesktopFile(handlerId)
238+
if (!found) throw new Error('That application is no longer installed.')
239+
await execFileAsync('gio', ['launch', found.path, path])
240+
}
241+
242+
/** Exported for the tests: the search path is environment-dependent. */
243+
export const _internals = { applicationDirectories, findDesktopFile }

0 commit comments

Comments
 (0)