react-x11 1.2.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/README.md +330 -81
  2. package/package.json +132 -14
  3. package/src/ClickToComponent.js +9 -2
  4. package/src/DevToolsIntegration.js +440 -32
  5. package/src/Reconciler.js +463 -165
  6. package/src/a11y.js +1299 -0
  7. package/src/acceleratorhooks.js +86 -0
  8. package/src/accelerators.js +128 -0
  9. package/src/activate.js +168 -0
  10. package/src/anchor.js +360 -0
  11. package/src/appcontext.js +161 -0
  12. package/src/appearance.js +748 -0
  13. package/src/appearancehooks.js +96 -0
  14. package/src/apphooks.js +73 -0
  15. package/src/application.js +748 -0
  16. package/src/atspi.js +1953 -0
  17. package/src/bus.js +545 -0
  18. package/src/bushooks.js +136 -0
  19. package/src/clientmessage.js +140 -0
  20. package/src/clipboard.js +121 -0
  21. package/src/components/Button.js +148 -33
  22. package/src/components/Checkbox.js +66 -32
  23. package/src/components/Dialog.js +96 -29
  24. package/src/components/FileDialog.js +491 -0
  25. package/src/components/Icon.js +329 -0
  26. package/src/components/Menu.js +797 -133
  27. package/src/components/PasswordInput.js +434 -0
  28. package/src/components/ProgressBar.js +91 -12
  29. package/src/components/Radio.js +78 -35
  30. package/src/components/Select.js +255 -65
  31. package/src/components/Slider.js +102 -41
  32. package/src/components/SplitPane.js +206 -0
  33. package/src/components/Switch.js +100 -22
  34. package/src/components/Table.js +529 -0
  35. package/src/components/Tabs.js +211 -0
  36. package/src/components/Tooltip.js +528 -54
  37. package/src/components/anchor.js +154 -107
  38. package/src/components/change.js +34 -0
  39. package/src/components/dnd.js +103 -0
  40. package/src/components/index.js +16 -3
  41. package/src/components/keys.js +17 -20
  42. package/src/components/scribble.js +247 -0
  43. package/src/components/theme.js +350 -42
  44. package/src/components/typeahead.js +12 -2
  45. package/src/compose.js +868 -0
  46. package/src/compositing.js +224 -0
  47. package/src/dbusmenu.js +384 -0
  48. package/src/debug.d.ts +63 -0
  49. package/src/debug.js +642 -0
  50. package/src/decorations.js +486 -0
  51. package/src/desktopsettings.js +209 -0
  52. package/src/desktopsettingshooks.js +62 -0
  53. package/src/dnd.js +1725 -0
  54. package/src/editmenu.js +272 -0
  55. package/src/errors.js +98 -0
  56. package/src/events.js +1406 -162
  57. package/src/extensions.js +45 -0
  58. package/src/filedialog.js +375 -0
  59. package/src/filedialoghooks.js +132 -0
  60. package/src/fonthooks.js +64 -0
  61. package/src/fonts.js +301 -0
  62. package/src/foreignnodes.js +519 -0
  63. package/src/frame/child.js +33 -0
  64. package/src/frame/childmain.js +274 -0
  65. package/src/frame/env.js +140 -0
  66. package/src/frame/index.js +444 -0
  67. package/src/frame/lifecycle.js +67 -0
  68. package/src/frame/protocol.js +179 -0
  69. package/src/frames.js +73 -0
  70. package/src/glbackend.js +114 -0
  71. package/src/glnodes.js +125 -67
  72. package/src/globalmenu.js +683 -0
  73. package/src/host.d.ts +88 -0
  74. package/src/host.js +42 -0
  75. package/src/idle.js +490 -0
  76. package/src/idlehooks.js +100 -0
  77. package/src/imagesource.js +349 -0
  78. package/src/index.d.ts +475 -0
  79. package/src/index.js +83 -9
  80. package/src/inputtime.js +206 -0
  81. package/src/jsx-dev-runtime.d.ts +21 -0
  82. package/src/jsx-dev-runtime.js +2 -0
  83. package/src/jsx-runtime.d.ts +39 -0
  84. package/src/jsx-runtime.js +6 -0
  85. package/src/keyboard.js +256 -0
  86. package/src/keyboardstate.js +278 -0
  87. package/src/keyboardstatehooks.js +58 -0
  88. package/src/keysyms.d.ts +149 -0
  89. package/src/keysyms.js +270 -0
  90. package/src/locale.js +170 -0
  91. package/src/localehooks.js +47 -0
  92. package/src/menuitem.js +223 -0
  93. package/src/node.d.ts +597 -0
  94. package/src/node.js +44 -0
  95. package/src/nodes.js +9546 -690
  96. package/src/ntk.d.ts +44 -0
  97. package/src/ntk.js +25 -0
  98. package/src/paintcache.js +366 -0
  99. package/src/palette.js +380 -0
  100. package/src/pastestate.js +66 -0
  101. package/src/portal.js +461 -0
  102. package/src/priority.js +26 -0
  103. package/src/refresh/index.d.ts +40 -0
  104. package/src/refresh/index.js +122 -0
  105. package/src/refresh/loader.d.ts +37 -0
  106. package/src/refresh/loader.js +401 -0
  107. package/src/refresh/register.d.ts +5 -0
  108. package/src/refresh/register.js +13 -0
  109. package/src/registry.js +232 -0
  110. package/src/scale.js +626 -0
  111. package/src/scalehooks.js +27 -0
  112. package/src/screencolor.js +640 -0
  113. package/src/screencolorhooks.js +101 -0
  114. package/src/screens.js +754 -0
  115. package/src/screenshooks.js +137 -0
  116. package/src/startup.js +302 -0
  117. package/src/style.d.ts +126 -0
  118. package/src/style.js +33 -0
  119. package/src/styles.js +1482 -6
  120. package/src/svgnodes.js +306 -0
  121. package/src/testing/a11y.js +484 -0
  122. package/src/testing/components.js +414 -0
  123. package/src/testing/events.js +407 -0
  124. package/src/testing/harness.js +455 -0
  125. package/src/testing/index.d.ts +558 -0
  126. package/src/testing/index.js +85 -0
  127. package/src/testing/mock-app.js +463 -0
  128. package/src/testing/pixels.js +152 -0
  129. package/src/testing/queries.js +224 -0
  130. package/src/textrange.js +83 -0
  131. package/src/textselection.js +439 -0
  132. package/src/trace-registry.js +63 -0
  133. package/src/transfer.js +93 -0
  134. package/src/types/appearance.d.ts +84 -0
  135. package/src/types/application.d.ts +173 -0
  136. package/src/types/components.d.ts +820 -0
  137. package/src/types/dbus.d.ts +177 -0
  138. package/src/types/elements.d.ts +897 -0
  139. package/src/types/events.d.ts +549 -0
  140. package/src/types/filedialog.d.ts +260 -0
  141. package/src/types/fonts.d.ts +124 -0
  142. package/src/types/frame.d.ts +146 -0
  143. package/src/types/globalmenu.d.ts +42 -0
  144. package/src/types/nodes.d.ts +199 -0
  145. package/src/types/screencolor.d.ts +84 -0
  146. package/src/types/style.d.ts +434 -0
  147. package/src/types/system.d.ts +287 -0
  148. package/src/windowid.js +151 -0
  149. package/src/windowstate.js +393 -0
  150. package/src/xsettings.js +336 -0
  151. package/src/yoga.d.ts +55 -0
  152. package/src/yoga.js +135 -0
  153. package/src/components/Canvas3D.js +0 -28
  154. package/src/geometry3d.js +0 -223
  155. package/src/pointer3d.js +0 -158
  156. package/src/raycast3d.js +0 -146
  157. package/src/richnodes.js +0 -436
  158. package/src/scene3d.js +0 -683
@@ -3,7 +3,7 @@
3
3
  // build-step-free for consumers.
4
4
 
5
5
  import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
6
- import { labelContent, useTheme } from './theme.js';
6
+ import { capTrim, labelContent, useTheme } from './theme.js';
7
7
  import { centerRect } from './anchor.js';
8
8
  import { XK_ESCAPE } from './keys.js';
9
9
 
@@ -14,8 +14,36 @@ const DEFAULT_WIDTH = 360;
14
14
  const DEFAULT_HEIGHT = 170;
15
15
 
16
16
  /**
17
- * <Dialog open title onClose actions>…</Dialog> — a modal dialog in a
18
- * `<popup trapFocus grab>`, centred over the owner window.
17
+ * <Dialog open title onClose actions>…</Dialog> — a dialog in a `<popup>`,
18
+ * centred over the owner window.
19
+ *
20
+ * **The window manager knows it is a dialog** (issue #130). The popup is a
21
+ * managed window — `overrideRedirect={false}` — with `WM_TRANSIENT_FOR`
22
+ * pointing at the window that owns it, resolved from the anchor this
23
+ * component already keeps for placement. Measured against quartz-wm, the
24
+ * thinnest EWMH implementation in the stack: the dialog is reparented into a
25
+ * real frame, so it has a titlebar and can be moved and closed the way every
26
+ * other window can; it loses maximize and fullscreen; and the WM puts
27
+ * SKIP_TASKBAR and SKIP_PAGER on it, so it is not a second entry in the
28
+ * taskbar or the alt-tab list. A fuller WM also stacks it above its owner
29
+ * and iconifies it alongside.
30
+ *
31
+ * `managed={false}` goes back to the override-redirect popup 1.x shipped: no
32
+ * frame, not movable, dismissed by a press anywhere outside (a pointer
33
+ * grab). That is the right shape for a transient confirmation on a display
34
+ * with no window manager at all, and the wrong one for anything the user
35
+ * might want to move.
36
+ *
37
+ * `title` is drawn exactly once (issue #370). Managed, it is the frame's
38
+ * caption and the window manager draws it, so the content starts with the
39
+ * body; unmanaged there is no frame, so it is drawn as a bold heading at the
40
+ * top of the content instead. Either way it names the dialog surface for
41
+ * accessibility (`aria-label`).
42
+ *
43
+ * The two differ in how a press outside behaves, and it is not a bug either
44
+ * way: a managed dialog stays open, because a real dialog does. Escape and
45
+ * the WM close button both call `onClose`; so does a press outside when
46
+ * `managed={false}`.
19
47
  *
20
48
  * The focus behaviour is the renderer's, not this component's: `trapFocus`
21
49
  * keeps Tab inside the dialog, stops presses elsewhere from moving focus,
@@ -24,14 +52,19 @@ const DEFAULT_HEIGHT = 170;
24
52
  * the first stop; otherwise the dialog surface itself takes focus, so
25
53
  * Escape and Tab work immediately (`docs/events.md`, "Focus scopes").
26
54
  *
27
- * Escape closes: keys go to the focused node inside the popup and bubble out
28
- * through the popup's place in the JSX tree. `grab` makes a press anywhere
29
- * else in the session close it too. Pointer modality is *not* enforced —
30
- * widgets in the owner window stay clickable behind the dialog, so use it
31
- * for confirmations rather than as a security boundary.
55
+ * Pointer modality is *not* enforced widgets in the owner window stay
56
+ * clickable behind the dialog, so use it for confirmations rather than as a
57
+ * security boundary. `_NET_WM_STATE_MODAL` is the mechanism for that, and it
58
+ * belongs to the `states` work; it means nothing without the
59
+ * `WM_TRANSIENT_FOR` this now sets, which is why that had to come first.
32
60
  *
33
- * A `<popup>` is a real X window and needs its size up front, hence explicit
34
- * `width`/`height` rather than sizing to content.
61
+ * `width`/`height` are explicit rather than sized to content, and now only
62
+ * because of where the dialog is *placed*: `centeredRect` needs the size to
63
+ * work out the offset that centres it over its owner, and that is computed
64
+ * during the render that opens the popup, before anything has been laid out.
65
+ * The popup itself could size to its content — see "Natural size" in
66
+ * docs/elements.md — so this is the anchor math's constraint, not the X
67
+ * window's.
35
68
  */
36
69
  export function Dialog({
37
70
  open,
@@ -39,8 +72,10 @@ export function Dialog({
39
72
  children,
40
73
  onClose,
41
74
  actions,
75
+ managed = true,
42
76
  width = DEFAULT_WIDTH,
43
77
  height = DEFAULT_HEIGHT,
78
+ style,
44
79
  ...boxProps
45
80
  }) {
46
81
  const theme = useTheme();
@@ -79,7 +114,11 @@ export function Dialog({
79
114
  return h(
80
115
  'box',
81
116
  // out of flow: an anchor for the window geometry, never part of layout
82
- { ref: anchor, position: 'absolute', width: 0, height: 0 },
117
+ {
118
+ theme,
119
+ ref: anchor,
120
+ style: { position: 'absolute', width: 0, height: 0 },
121
+ },
83
122
  rect &&
84
123
  h(
85
124
  'popup',
@@ -89,46 +128,74 @@ export function Dialog({
89
128
  width: rect.width,
90
129
  height: rect.height,
91
130
  trapFocus: true,
92
- grab: true,
131
+ // A managed window is the window manager's to move, and a
132
+ // client-side pointer grab over one is a fight nobody wins — the
133
+ // press that would start a titlebar drag is swallowed instead. So
134
+ // the grab and the frame are the same choice, made once.
135
+ overrideRedirect: !managed,
136
+ grab: !managed,
93
137
  windowType: 'dialog',
94
- backgroundColor: theme.background,
95
- onDismiss: () => onClose?.(),
138
+ // inert on an override-redirect window, and ntk warns if asked to
139
+ // write it there, so only the managed dialog claims an owner
140
+ transientFor: managed ? anchor : undefined,
141
+ title: managed ? title : undefined,
142
+ onDismiss: managed ? undefined : () => onClose?.(),
143
+ // the WM's close button, which a managed dialog now has
144
+ onCloseRequest: managed ? () => onClose?.() : undefined,
96
145
  onKeyDown,
146
+ style: { backgroundColor: theme.background },
97
147
  },
98
148
  h(
99
149
  'box',
100
150
  {
101
151
  ref: surface,
102
- // focusable but not tabbable: a fallback focus holder, never a
103
- // stop in the dialog's own tab order
152
+ role: 'dialog',
153
+ 'aria-modal': true,
154
+ 'aria-label': typeof title === 'string' ? title : undefined,
104
155
  tabIndex: -1,
105
- flexGrow: 1,
106
- padding: 16,
107
- gap: 12,
108
- borderWidth: 1,
109
- borderColor: theme.border,
110
- backgroundColor: theme.background,
111
156
  ...boxProps,
157
+ style: [
158
+ {
159
+ flexGrow: 1,
160
+ padding: 16,
161
+ gap: 12,
162
+ borderWidth: theme.borderWidth,
163
+ borderColor: theme.border,
164
+ backgroundColor: theme.surface,
165
+ },
166
+ style,
167
+ ],
112
168
  },
113
- title &&
169
+ // a managed dialog's title is already in the WM frame's caption;
170
+ // drawing it again here showed it twice (issue #370). Only the
171
+ // unmanaged popup, which has no frame, needs the in-content heading.
172
+ !managed &&
173
+ title &&
114
174
  h(
115
175
  'text',
116
- { fontSize: 15, fontWeight: 'bold', color: theme.text },
176
+ {
177
+ style: [
178
+ capTrim,
179
+ { fontSize: 15, fontWeight: 'bold', color: theme.text },
180
+ ],
181
+ },
117
182
  title,
118
183
  ),
119
184
  h(
120
185
  'box',
121
- { flexGrow: 1, gap: 8 },
186
+ { style: { flexGrow: 1, gap: 8 } },
122
187
  labelContent(children, { color: theme.text }),
123
188
  ),
124
189
  actions &&
125
190
  h(
126
191
  'box',
127
192
  {
128
- flexDirection: 'row',
129
- justifyContent: 'flex-end',
130
- alignItems: 'center',
131
- gap: 8,
193
+ style: {
194
+ flexDirection: 'row',
195
+ justifyContent: 'flex-end',
196
+ alignItems: 'center',
197
+ gap: 8,
198
+ },
132
199
  },
133
200
  actions,
134
201
  ),
@@ -0,0 +1,491 @@
1
+ // The bottom rung of the file-dialog ladder: a browser react-x11 draws
2
+ // itself, for every machine that has a display and nothing else — ssh, a bare
3
+ // `startx`, a container, a Mac with neither a portal nor `osascript`.
4
+ //
5
+ // It is a **real top-level window**, not a `<popup>`: a file dialog is
6
+ // something the window manager should place, stack and decorate, and
7
+ // `transientFor` is what makes it behave like a dialog rather than a second
8
+ // application. That is also the shape the portal would have given, so an app
9
+ // that moves between machines does not change how its dialog behaves.
10
+ //
11
+ // Deliberately not a re-creation of GTK's chooser. What it owes the user is:
12
+ // get anywhere on the filesystem, see what is there, filter it, type a path
13
+ // when the list is the slow way round, and never trap them — which is the set
14
+ // below and nothing else.
15
+
16
+ import React, {
17
+ useCallback,
18
+ useEffect,
19
+ useMemo,
20
+ useRef,
21
+ useState,
22
+ } from 'react';
23
+
24
+ import { createStyles } from '../styles.js';
25
+ import { Button } from './Button.js';
26
+ import { Checkbox } from './Checkbox.js';
27
+ import { Select } from './Select.js';
28
+ import { Table } from './Table.js';
29
+ import { capTrim, ThemeProvider, useTheme } from './theme.js';
30
+
31
+ const h = React.createElement;
32
+
33
+ const ALL_FILES = '__all__';
34
+
35
+ // A row is one line high, so a name longer than its column ends at the
36
+ // column's edge rather than wrapping into a line and a half of name — and
37
+ // ends in a `…`, the same as the cells `<Table>` renders itself, so a cut
38
+ // name and a short one do not read alike.
39
+ const NOWRAP = Object.freeze({ textWrap: 'nowrap', textOverflow: 'ellipsis' });
40
+
41
+ // Stat-ing every entry is one syscall each, which is nothing on a local
42
+ // filesystem and very much something on a network mount with ten thousand
43
+ // files in one directory. Past this, sizes and dates are left blank rather
44
+ // than making the dialog take a visible pause to open.
45
+ const STAT_LIMIT = 5000;
46
+
47
+ const s = createStyles({
48
+ window: { flexDirection: 'column', padding: 10, gap: 8 },
49
+ bar: { flexDirection: 'row', gap: 6, alignItems: 'center', flexShrink: 0 },
50
+ path: { flexGrow: 1, flexBasis: 0 },
51
+ list: { flexGrow: 1, minHeight: 0 },
52
+ footer: { flexDirection: 'row', gap: 8, alignItems: 'center', flexShrink: 0 },
53
+ spacer: { flexGrow: 1, flexBasis: 0 },
54
+ name: { flexGrow: 1, flexBasis: 0 },
55
+ error: { fontSize: 12 },
56
+ hint: { fontSize: 11 },
57
+ });
58
+
59
+ const KIND_LABEL = { open: 'Open', save: 'Save', folder: 'Select' };
60
+
61
+ /** `1234567` → `1.2 MB`. Blank for directories and unstat'd entries. */
62
+ function humanSize(bytes) {
63
+ if (bytes == null) return '';
64
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
65
+ let n = bytes;
66
+ let i = 0;
67
+ while (n >= 1024 && i < units.length - 1) {
68
+ n /= 1024;
69
+ i++;
70
+ }
71
+ return `${i === 0 ? n : n.toFixed(1)} ${units[i]}`;
72
+ }
73
+
74
+ /** `{ name, extensions, mimeTypes }` → a predicate over file names. */
75
+ function matcher(filter) {
76
+ if (!filter || !filter.extensions?.length) return () => true;
77
+ const exts = filter.extensions.map((e) =>
78
+ String(e)
79
+ .replace(/^[.*]*\.?/, '')
80
+ .toLowerCase(),
81
+ );
82
+ return (name) => {
83
+ const dot = name.lastIndexOf('.');
84
+ if (dot < 0) return false;
85
+ return exts.includes(name.slice(dot + 1).toLowerCase());
86
+ };
87
+ }
88
+
89
+ /**
90
+ * One directory's contents, reloaded whenever the path changes.
91
+ *
92
+ * Errors are state rather than throws: a directory you cannot read is an
93
+ * ordinary thing to click on, and the answer is a message in the dialog, not
94
+ * an error boundary.
95
+ */
96
+ function useDirectory(dir) {
97
+ const [state, setState] = useState({ status: 'loading', entries: [] });
98
+
99
+ useEffect(() => {
100
+ let live = true;
101
+ setState((prev) => ({ ...prev, status: 'loading' }));
102
+ (async () => {
103
+ const fs = await import('node:fs/promises');
104
+ const path = await import('node:path');
105
+ // The parent comes back with the listing rather than from a `dirname`
106
+ // of its own: it is the same answer, and one place that knows how a
107
+ // path is taken apart is one place to be wrong.
108
+ const above = path.dirname(dir);
109
+ const parent = above === dir ? null : above;
110
+ let names;
111
+ try {
112
+ names = await fs.readdir(dir, { withFileTypes: true });
113
+ } catch (err) {
114
+ if (live)
115
+ setState({
116
+ status: 'error',
117
+ message: err.message,
118
+ entries: [],
119
+ parent,
120
+ });
121
+ return;
122
+ }
123
+ const withStats = names.length <= STAT_LIMIT;
124
+ const entries = await Promise.all(
125
+ names.map(async (entry) => {
126
+ const full = path.join(dir, entry.name);
127
+ // A symlink's own dirent says 'link'; what matters to the user is
128
+ // what it points at, so follow it and fall back to the link itself
129
+ // when it dangles.
130
+ let directory = entry.isDirectory();
131
+ let size = null;
132
+ let modified = 0;
133
+ if (withStats) {
134
+ try {
135
+ const st = await fs.stat(full);
136
+ directory = st.isDirectory();
137
+ size = directory ? null : st.size;
138
+ modified = st.mtimeMs;
139
+ } catch {
140
+ directory = entry.isDirectory();
141
+ }
142
+ }
143
+ return {
144
+ id: full,
145
+ name: entry.name,
146
+ path: full,
147
+ directory,
148
+ size,
149
+ modified,
150
+ };
151
+ }),
152
+ );
153
+ if (live) setState({ status: 'ready', entries, parent });
154
+ })();
155
+ return () => {
156
+ live = false;
157
+ };
158
+ }, [dir]);
159
+
160
+ return state;
161
+ }
162
+
163
+ /**
164
+ * `<FileDialog>` — the built-in file browser.
165
+ *
166
+ * Rendered for you by `useFileDialog()` when there is no portal and no
167
+ * `osascript`; exported because an app that wants this dialog *always* (a
168
+ * kiosk, a test, somewhere the desktop's own dialog is wrong) should not have
169
+ * to break the machine to get it.
170
+ *
171
+ * `onDone(paths | null)` is called exactly once — `null` for cancel.
172
+ */
173
+ export function FileDialog({
174
+ kind = 'open',
175
+ title,
176
+ startFolder,
177
+ defaultName = '',
178
+ filters = [],
179
+ multiple = false,
180
+ acceptLabel,
181
+ parentWindow,
182
+ onDone,
183
+ theme: themeProp,
184
+ width = 640,
185
+ height = 460,
186
+ }) {
187
+ // `theme` as a prop, because the dialog is often rendered on a **root of its
188
+ // own** (see filedialoghooks.js) where there is no provider above it to
189
+ // inherit from — the palette has to be carried across rather than read.
190
+ const contextTheme = useTheme();
191
+ const theme = themeProp ?? contextTheme;
192
+ const [dir, setDir] = useState(startFolder || process.cwd());
193
+ const [typedPath, setTypedPath] = useState(dir);
194
+ const [name, setName] = useState(defaultName);
195
+ const [showHidden, setShowHidden] = useState(false);
196
+ const [filterId, setFilterId] = useState(filters.length ? '0' : ALL_FILES);
197
+ // the whole row, not its path: "is this a directory" is the difference
198
+ // between opening a file and going into a folder, and the button that has
199
+ // to tell them apart only ever sees what is in here
200
+ const [cursor, setCursor] = useState(null);
201
+ const [picked, setPicked] = useState(() => new Set());
202
+ const settled = useRef(false);
203
+
204
+ const { status, entries, message, parent } = useDirectory(dir);
205
+
206
+ // `onDone` exactly once, however the dialog ends — a caller is awaiting a
207
+ // promise, and settling it twice or not at all are both worse than any
208
+ // rendering bug in here.
209
+ const finish = useCallback(
210
+ (result) => {
211
+ if (settled.current) return;
212
+ settled.current = true;
213
+ onDone?.(result);
214
+ },
215
+ [onDone],
216
+ );
217
+
218
+ const activeFilter =
219
+ filterId === ALL_FILES ? null : (filters[Number(filterId)] ?? null);
220
+ const matches = useMemo(() => matcher(activeFilter), [activeFilter]);
221
+
222
+ const rows = useMemo(() => {
223
+ const visible = entries.filter((e) => {
224
+ if (!showHidden && e.name.startsWith('.')) return false;
225
+ if (kind === 'folder') return e.directory;
226
+ return e.directory || matches(e.name);
227
+ });
228
+ // Directories first, then by name — the order every file browser uses,
229
+ // and the reason `sort` is passed to Table already applied.
230
+ visible.sort((a, b) =>
231
+ a.directory !== b.directory
232
+ ? a.directory
233
+ ? -1
234
+ : 1
235
+ : a.name.localeCompare(b.name),
236
+ );
237
+ // `..` first, and only where there is somewhere to go: at the root the
238
+ // parent is the root, and a row that does nothing is worse than no row.
239
+ // It is a real entry rather than a special case in the table, so it
240
+ // opens on the same double click and the same Enter as any directory.
241
+ return parent
242
+ ? [
243
+ {
244
+ id: parent,
245
+ name: '..',
246
+ path: parent,
247
+ directory: true,
248
+ parent: true,
249
+ size: null,
250
+ modified: 0,
251
+ },
252
+ ...visible,
253
+ ]
254
+ : visible;
255
+ }, [entries, showHidden, matches, kind, parent]);
256
+
257
+ // A new directory is a new list; a stale cursor would point at a row that
258
+ // is not there and a stale tick-set would return paths the user cannot see.
259
+ useEffect(() => {
260
+ setCursor(null);
261
+ setPicked(new Set());
262
+ setTypedPath(dir);
263
+ }, [dir]);
264
+
265
+ const goUp = useCallback(() => {
266
+ if (parent) setDir(parent);
267
+ }, [parent]);
268
+
269
+ const toggle = useCallback((row) => {
270
+ setPicked((prev) => {
271
+ const next = new Set(prev);
272
+ if (next.has(row.path)) next.delete(row.path);
273
+ else next.add(row.path);
274
+ return next;
275
+ });
276
+ }, []);
277
+
278
+ const activate = useCallback(
279
+ (id, row) => {
280
+ if (!row) return;
281
+ if (row.directory && kind !== 'folder') return void setDir(row.path);
282
+ if (row.directory && kind === 'folder') return void setDir(row.path);
283
+ if (kind === 'save') return setName(row.name);
284
+ finish([row.path]);
285
+ },
286
+ [kind, finish],
287
+ );
288
+
289
+ const accept = useCallback(async () => {
290
+ const path = await import('node:path');
291
+ if (kind === 'save') {
292
+ if (!name.trim()) return;
293
+ return finish([path.resolve(dir, name.trim())]);
294
+ }
295
+ if (kind === 'folder') {
296
+ // no selection means "this one", which is what the path bar shows
297
+ const chosen = picked.size ? [...picked] : cursor ? [cursor.path] : [dir];
298
+ return finish(chosen);
299
+ }
300
+ // Opening a *file*, with a directory under the cursor: go in. Returning
301
+ // the directory as the answer is what it used to do, which handed the
302
+ // caller a path that is not a file — and `..` made that one click away.
303
+ if (cursor?.directory) return void setDir(cursor.path);
304
+ const chosen =
305
+ multiple && picked.size ? [...picked] : cursor ? [cursor.path] : [];
306
+ if (!chosen.length) return;
307
+ finish(chosen);
308
+ }, [kind, name, dir, picked, cursor, multiple, finish]);
309
+
310
+ const columns = useMemo(() => {
311
+ const cols = [];
312
+ if (multiple && kind !== 'save') {
313
+ cols.push({
314
+ id: 'pick',
315
+ label: '',
316
+ width: 28,
317
+ render: (row) =>
318
+ row.directory
319
+ ? null
320
+ : h(Checkbox, {
321
+ checked: picked.has(row.path),
322
+ onChange: () => toggle(row),
323
+ }),
324
+ });
325
+ }
326
+ cols.push({
327
+ id: 'name',
328
+ label: 'Name',
329
+ width: 300,
330
+ value: (row) => row.name,
331
+ // The accent that marks a directory is the *same value* as the fill the
332
+ // selected row gets — `accent` and `hoverBackground` are one colour in
333
+ // both built-in palettes — so a selected directory was accent on accent
334
+ // and could not be read at all. On the bar the selection's own text
335
+ // colour is the readable one in either scheme, and the trailing `/` is
336
+ // what still says "directory".
337
+ render: (row, { selected }) =>
338
+ h(
339
+ 'text',
340
+ {
341
+ style: [
342
+ capTrim,
343
+ NOWRAP,
344
+ {
345
+ color: selected
346
+ ? theme.hoverText
347
+ : row.directory
348
+ ? theme.accent
349
+ : theme.text,
350
+ },
351
+ ],
352
+ },
353
+ // `..` is already the name of a place, not a name that needs one:
354
+ // `../` reads as a path fragment where every other row reads as a
355
+ // thing in this directory.
356
+ row.directory && !row.parent ? `${row.name}/` : row.name,
357
+ ),
358
+ });
359
+ cols.push({
360
+ id: 'size',
361
+ label: 'Size',
362
+ width: 90,
363
+ align: 'right',
364
+ value: (row) => humanSize(row.size),
365
+ });
366
+ cols.push({
367
+ id: 'modified',
368
+ // A locale date *and* time: 23 characters at the widest — "12/31/2026,
369
+ // 12:30:05 PM" — and a column that cannot hold one is the column that
370
+ // showed a sliced two-line date before cells stopped wrapping. Sized
371
+ // for the long form rather than for the short one.
372
+ label: 'Modified',
373
+ width: 200,
374
+ value: (row) =>
375
+ row.modified ? new Date(row.modified).toLocaleString() : '',
376
+ });
377
+ return cols;
378
+ }, [multiple, kind, picked, toggle, theme]);
379
+
380
+ const filterOptions = [
381
+ ...filters.map((f, i) => ({
382
+ value: String(i),
383
+ label: f.name ?? `Filter ${i + 1}`,
384
+ })),
385
+ { value: ALL_FILES, label: 'All files' },
386
+ ];
387
+
388
+ const acceptText =
389
+ acceptLabel ??
390
+ (multiple && picked.size
391
+ ? `${KIND_LABEL[kind]} (${picked.size})`
392
+ : KIND_LABEL[kind]);
393
+
394
+ return h(
395
+ 'window',
396
+ {
397
+ title: title ?? `${KIND_LABEL[kind]} — react-x11`,
398
+ width,
399
+ height,
400
+ minWidth: 420,
401
+ minHeight: 300,
402
+ transientFor: parentWindow ?? undefined,
403
+ // The palette goes on the window as a **prop** as well as on the context
404
+ // below: a `$token` in a style resolves against the nearest `theme` prop
405
+ // by walking the node tree, which knows nothing about React context.
406
+ theme,
407
+ style: { flexDirection: 'column', backgroundColor: theme.background },
408
+ onCloseRequest: () => finish(null),
409
+ onKeyDown: (e) => {
410
+ if (e.key === 'Escape') finish(null);
411
+ },
412
+ },
413
+ h(
414
+ ThemeProvider,
415
+ { value: theme, style: s.window },
416
+
417
+ // Where you are, and the escape hatch for everywhere the list is the slow
418
+ // way round: type a path, press Enter.
419
+ h(
420
+ 'box',
421
+ { style: s.bar },
422
+ h(Button, { onPress: goUp }, 'Up'),
423
+ h('textinput', {
424
+ value: typedPath,
425
+ onChange: (e) => setTypedPath(e.value),
426
+ onKeyDown: (e) => {
427
+ if (e.key === 'Enter') setDir(typedPath.trim() || dir);
428
+ },
429
+ style: s.path,
430
+ }),
431
+ ),
432
+
433
+ status === 'error'
434
+ ? h(
435
+ 'text',
436
+ { style: [s.error, { color: '#dc2626' }] },
437
+ `Cannot read ${dir} — ${message}`,
438
+ )
439
+ : h(Table, {
440
+ columns,
441
+ rows,
442
+ selected: cursor?.id ?? null,
443
+ onSelect: (id, row) => {
444
+ setCursor(row ?? null);
445
+ if (multiple && kind !== 'save' && row && !row.directory)
446
+ toggle(row);
447
+ if (kind === 'save' && row && !row.directory) setName(row.name);
448
+ },
449
+ onActivate: activate,
450
+ style: s.list,
451
+ }),
452
+
453
+ kind === 'save'
454
+ ? h(
455
+ 'box',
456
+ { style: s.bar },
457
+ h('text', null, 'Name'),
458
+ h('textinput', {
459
+ value: name,
460
+ onChange: (e) => setName(e.value),
461
+ onKeyDown: (e) => {
462
+ if (e.key === 'Enter') accept();
463
+ },
464
+ style: s.name,
465
+ }),
466
+ )
467
+ : null,
468
+
469
+ h(
470
+ 'box',
471
+ { style: s.footer },
472
+ h(Checkbox, {
473
+ checked: showHidden,
474
+ onChange: (e) => setShowHidden(Boolean(e.value)),
475
+ label: 'Show hidden',
476
+ }),
477
+ filters.length
478
+ ? h(Select, {
479
+ options: filterOptions,
480
+ value: filterId,
481
+ onChange: (e) => setFilterId(e.value),
482
+ style: { width: 180 },
483
+ })
484
+ : null,
485
+ h('box', { style: s.spacer }),
486
+ h(Button, { onPress: () => finish(null) }, 'Cancel'),
487
+ h(Button, { primary: true, onPress: accept }, acceptText),
488
+ ),
489
+ ),
490
+ );
491
+ }