Skip to main content

File dialogs

Open a file, save a file, pick a folder — on whatever the machine has.

import { useFileDialog } from 'react-x11';

function Toolbar() {
const { openFile } = useFileDialog();

return (
<Button
label="Open…"
onPress={async () => {
const files = await openFile({
filters: [{ name: 'Markdown', extensions: ['md'] }],
});
if (!files) return; // cancelled
load(files[0]);
}}
/>
);
}

Cancelling resolves to null. It is an ordinary outcome, not an exception, on every backend — so the whole error path for a file dialog is one if.

The ladder

There is no single answer to "show a file dialog" on a machine running X11, so this is a ladder and the rung is chosen for you:

1the portalorg.freedesktop.portal.FileChooser over D-Bus. The desktop's own dialog, drawn by GTK or KDE in another process, with the user's bookmarks and recent files.
2osascriptmacOS with no portal — which is every XQuartz install that has not gone out of its way. choose file is NSOpenPanel.
3the built-in dialogA browser react-x11 draws itself. ssh, a bare startx, a container: everywhere there is a display and nothing else.

fileDialogBackend() reports which one this machine lands on, without showing anything — useful for a diagnostics panel, and for a menu that wants to say so.

The ladder is the design, not a fallback chain bolted on. react-x11's flagship case is an app running on one machine and drawing to another, and a file dialog that only worked on a full GNOME session would be useless to exactly the person the renderer exists for.

useFileDialog()

const { openFile, saveFile, selectFolder } = useFileDialog(defaults?);

openFile(options?): Promise<string[] | null>; // absolute paths
saveFile(options?): Promise<string | null>; // one path, may not exist yet
selectFolder(options?): Promise<string[] | null>;

The hook is the surface to reach for, because it is the only one with a tree to draw in — so it never runs out of rungs and never rejects for lack of a dialog.

option
titlethe dialog's title
multipleseveral files at once; ignored when saving
filters{ name, extensions?, mimeTypes? }[]
defaultFolderwhere it opens
defaultName / defaultPathwhat a save dialog starts on
acceptLabelthe confirm button's text — 'Import', 'Attach', …
parentWindowoverride the owner window; inferred otherwise
signalan AbortSignal that closes the dialog
backendforce a rung; the seam for a kiosk, and for tests

Options given to the hook are defaults for every call; options given to a call win.

The dialog parents itself

The dialog is transientFor the window the component is in — parent_window for the portal — which is what makes the window manager treat it as belonging to your window: stacked above it, and not a second entry in the task switcher.

You do not pass anything for that. useFileDialog() resolves the owner window through useTopLevelWindow() at the moment a dialog opens, by which time the window is mounted and has an XID.

Why inference rather than a lookup: a hook has no position in the host tree. React context cannot come from a host element, the host context getChildHostContext builds reaches createInstance and not components, and a <window> has no XID until the commit phase. So it answers from what the renderer does know — the top-level windows this connection is rendering:

  • one top-level window is exact. The component is in it, because there is nowhere else to be. That is nearly every app.
  • several, one focused: that one. You clicked in it a moment ago, which is why a dialog is opening.
  • several with nothing to separate them: the most recently opened, plus a development warning naming parentWindow as the way to be exact.

So parentWindow survives as the override for multi-window apps, and takes a <window> ref, an XID, or anything windowIdOf() accepts:

const win = useRef(null);
const { openFile } = useFileDialog({ parentWindow: win });
return <window ref={win}></window>;

useTopLevelWindow() is exported for anything else that needs the owner window. It returns a ref-like object — the window is not realized on the first render, so a value read then would be null on the render that matters — which drops into parentWindow, transientFor, or windowIdOf().

The portal never embeds the dialog. It is a top-level window in another process, and logical parenting is all there is. There is no version of this where the file list appears inside your window.

The imperative functions

import { openFile, saveFile, selectFolder } from 'react-x11';

Same options, same results — but they can only reach the portal and osascript, because a function has nowhere to draw. Where there is neither they reject with NoFileDialogError, which is a typed rejection: the signal to show your own UI, not a crash.

Reach for these from host-side code and event-loop glue that has no component to hang off. In a component, use the hook.

What differs between the rungs

Most of the API is the same everywhere. These are the places it genuinely is not, stated here rather than discovered:

  • MIME-type filters do not reach macOS. extensions translate exactly to all three; mimeTypes reach the portal and are dropped by osascript, because AppleScript's of type wants extensions or UTIs and guessing a UTI wrong hides the user's file with no way to get at it. Give extensions where you can.
  • macOS ignores parentWindow entirely. XQuartz windows are NSWindows owned by X11.app, and macOS has no cross-process transient-for — addChildWindow is same-process only. The panel appears over the app but is not attached to it. Application modality still works, because your code is awaiting the promise.
  • The built-in dialog has never seen the user's bookmarks. It offers the filesystem, a filter, hidden files, and a path you can type into. It is deliberately not a re-creation of GTK's chooser.
  • multiple in the built-in dialog is a tick column, not ctrl-click.

In the built-in dialog a double click opens a directory or picks a file, Enter does the same to the selected row, Up goes to the parent, and Escape cancels — and a selected directory is drawn in the selection's own text colour rather than the accent it wears at rest, since the selected row is filled with that accent in both schemes.

Running it where there is no bus

The interesting configurations, and what each one does:

whererung
GNOME / KDE / any desktop with xdg-desktop-portalportal
macOS + XQuartz, app running on the Macosascript
macOS + XQuartz, app running on a remote Linux boxbuilt-in — osascript would run on the wrong machine, and the paths it returned would not exist where the app is
ssh, startx, a container, CIbuilt-in
Node 20, where npm skips dbus-nativebuilt-in on Linux, osascript on a Mac

The macOS rung is reached by not finding a portal, so it also covers a Mac that has a session bus for other reasons. If you do run a portal shim on a Mac (see issue #111), it wins, which is what you asked for by running it.

The portal machinery, if you need it directly

portalRequest() is exported for building other portals on. Every portal method that shows UI returns an object path immediately and answers later with a Request.Response signal — and the path is predictable by design, so the client subscribes before it calls and cannot lose the answer:

const { response, results } = await portalRequest(busRef, {
iface: 'org.freedesktop.portal.FileChooser',
member: 'OpenFile',
parentWindow: 'x11:1a00007',
title: 'Open',
options: { multiple: true },
});

It owns the three things every portal caller otherwise re-solves: subscribing first, Request.Close() on abort (which emits no Response, so the promise has to be settled locally), and no timeout on the answer — a dialog can legitimately be open for an hour, and only the initial call is deadlined.

The default argument shape is FileChooser's, (s parent_window, s title, a{sv} options) — and that shape is FileChooser's alone. Every other Request-shaped portal has its own leading arguments, so a caller states them, as signature and args together; the eyedropper's PickColor, which has no title, is the worked example:

await portalRequest(busRef, {
iface: 'org.freedesktop.portal.Screenshot',
member: 'PickColor',
signature: 'sa{sv}',
args: [parentWindow],
});

The signature must end in a{sv} — the options dict is where handle_token rides, so a method without one does not answer through a Request and wants a plain bus.invoke instead.

hasService(name) answers whether a service is reachable — owned now or activatable on demand. NameHasOwner alone is the wrong question: org.freedesktop.portal.Desktop is D-Bus-activatable, so on a healthy desktop where no app has touched a portal yet it has no owner, and a feature gated on that takes the fallback path forever.

portalVersion(iface) answers the question hasService() cannot: whether the portal's backends provide one interface, and at what version — 0 when it is not there at all. Capability lives in the interface's own version property, and gating on the service alone breaks exactly on the desktops that provide some portals and not others (XFCE has FileChooser and no Screenshot).

See dbus.md for the connection these sit on.

Seeing it

  • npm run examples:menu — a File menu whose Open/Save entries are real, with the rung this machine landed on shown in the window. Force the bottom rung anywhere with backend: 'builtin', which is also how the tests exercise it.