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.
- package/README.md +330 -81
- package/package.json +132 -14
- package/src/ClickToComponent.js +9 -2
- package/src/DevToolsIntegration.js +440 -32
- package/src/Reconciler.js +463 -165
- package/src/a11y.js +1299 -0
- package/src/acceleratorhooks.js +86 -0
- package/src/accelerators.js +128 -0
- package/src/activate.js +168 -0
- package/src/anchor.js +360 -0
- package/src/appcontext.js +161 -0
- package/src/appearance.js +748 -0
- package/src/appearancehooks.js +96 -0
- package/src/apphooks.js +73 -0
- package/src/application.js +748 -0
- package/src/atspi.js +1953 -0
- package/src/bus.js +545 -0
- package/src/bushooks.js +136 -0
- package/src/clientmessage.js +140 -0
- package/src/clipboard.js +121 -0
- package/src/components/Button.js +148 -33
- package/src/components/Checkbox.js +66 -32
- package/src/components/Dialog.js +96 -29
- package/src/components/FileDialog.js +491 -0
- package/src/components/Icon.js +329 -0
- package/src/components/Menu.js +797 -133
- package/src/components/PasswordInput.js +434 -0
- package/src/components/ProgressBar.js +91 -12
- package/src/components/Radio.js +78 -35
- package/src/components/Select.js +255 -65
- package/src/components/Slider.js +102 -41
- package/src/components/SplitPane.js +206 -0
- package/src/components/Switch.js +100 -22
- package/src/components/Table.js +529 -0
- package/src/components/Tabs.js +211 -0
- package/src/components/Tooltip.js +528 -54
- package/src/components/anchor.js +154 -107
- package/src/components/change.js +34 -0
- package/src/components/dnd.js +103 -0
- package/src/components/index.js +16 -3
- package/src/components/keys.js +17 -20
- package/src/components/scribble.js +247 -0
- package/src/components/theme.js +350 -42
- package/src/components/typeahead.js +12 -2
- package/src/compose.js +868 -0
- package/src/compositing.js +224 -0
- package/src/dbusmenu.js +384 -0
- package/src/debug.d.ts +63 -0
- package/src/debug.js +642 -0
- package/src/decorations.js +486 -0
- package/src/desktopsettings.js +209 -0
- package/src/desktopsettingshooks.js +62 -0
- package/src/dnd.js +1725 -0
- package/src/editmenu.js +272 -0
- package/src/errors.js +98 -0
- package/src/events.js +1406 -162
- package/src/extensions.js +45 -0
- package/src/filedialog.js +375 -0
- package/src/filedialoghooks.js +132 -0
- package/src/fonthooks.js +64 -0
- package/src/fonts.js +301 -0
- package/src/foreignnodes.js +519 -0
- package/src/frame/child.js +33 -0
- package/src/frame/childmain.js +274 -0
- package/src/frame/env.js +140 -0
- package/src/frame/index.js +444 -0
- package/src/frame/lifecycle.js +67 -0
- package/src/frame/protocol.js +179 -0
- package/src/frames.js +73 -0
- package/src/glbackend.js +114 -0
- package/src/glnodes.js +125 -67
- package/src/globalmenu.js +683 -0
- package/src/host.d.ts +88 -0
- package/src/host.js +42 -0
- package/src/idle.js +490 -0
- package/src/idlehooks.js +100 -0
- package/src/imagesource.js +349 -0
- package/src/index.d.ts +475 -0
- package/src/index.js +83 -9
- package/src/inputtime.js +206 -0
- package/src/jsx-dev-runtime.d.ts +21 -0
- package/src/jsx-dev-runtime.js +2 -0
- package/src/jsx-runtime.d.ts +39 -0
- package/src/jsx-runtime.js +6 -0
- package/src/keyboard.js +256 -0
- package/src/keyboardstate.js +278 -0
- package/src/keyboardstatehooks.js +58 -0
- package/src/keysyms.d.ts +149 -0
- package/src/keysyms.js +270 -0
- package/src/locale.js +170 -0
- package/src/localehooks.js +47 -0
- package/src/menuitem.js +223 -0
- package/src/node.d.ts +597 -0
- package/src/node.js +44 -0
- package/src/nodes.js +9546 -690
- package/src/ntk.d.ts +44 -0
- package/src/ntk.js +25 -0
- package/src/paintcache.js +366 -0
- package/src/palette.js +380 -0
- package/src/pastestate.js +66 -0
- package/src/portal.js +461 -0
- package/src/priority.js +26 -0
- package/src/refresh/index.d.ts +40 -0
- package/src/refresh/index.js +122 -0
- package/src/refresh/loader.d.ts +37 -0
- package/src/refresh/loader.js +401 -0
- package/src/refresh/register.d.ts +5 -0
- package/src/refresh/register.js +13 -0
- package/src/registry.js +232 -0
- package/src/scale.js +626 -0
- package/src/scalehooks.js +27 -0
- package/src/screencolor.js +640 -0
- package/src/screencolorhooks.js +101 -0
- package/src/screens.js +754 -0
- package/src/screenshooks.js +137 -0
- package/src/startup.js +302 -0
- package/src/style.d.ts +126 -0
- package/src/style.js +33 -0
- package/src/styles.js +1482 -6
- package/src/svgnodes.js +306 -0
- package/src/testing/a11y.js +484 -0
- package/src/testing/components.js +414 -0
- package/src/testing/events.js +407 -0
- package/src/testing/harness.js +455 -0
- package/src/testing/index.d.ts +558 -0
- package/src/testing/index.js +85 -0
- package/src/testing/mock-app.js +463 -0
- package/src/testing/pixels.js +152 -0
- package/src/testing/queries.js +224 -0
- package/src/textrange.js +83 -0
- package/src/textselection.js +439 -0
- package/src/trace-registry.js +63 -0
- package/src/transfer.js +93 -0
- package/src/types/appearance.d.ts +84 -0
- package/src/types/application.d.ts +173 -0
- package/src/types/components.d.ts +820 -0
- package/src/types/dbus.d.ts +177 -0
- package/src/types/elements.d.ts +897 -0
- package/src/types/events.d.ts +549 -0
- package/src/types/filedialog.d.ts +260 -0
- package/src/types/fonts.d.ts +124 -0
- package/src/types/frame.d.ts +146 -0
- package/src/types/globalmenu.d.ts +42 -0
- package/src/types/nodes.d.ts +199 -0
- package/src/types/screencolor.d.ts +84 -0
- package/src/types/style.d.ts +434 -0
- package/src/types/system.d.ts +287 -0
- package/src/windowid.js +151 -0
- package/src/windowstate.js +393 -0
- package/src/xsettings.js +336 -0
- package/src/yoga.d.ts +55 -0
- package/src/yoga.js +135 -0
- package/src/components/Canvas3D.js +0 -28
- package/src/geometry3d.js +0 -223
- package/src/pointer3d.js +0 -158
- package/src/raycast3d.js +0 -146
- package/src/richnodes.js +0 -436
- package/src/scene3d.js +0 -683
package/src/components/Dialog.js
CHANGED
|
@@ -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
|
|
18
|
-
*
|
|
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
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
-
*
|
|
34
|
-
* `
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
+
}
|