@opengeni/react 0.15.0 → 0.20.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 +110 -10
- package/dist/{chunk-TOJR776I.js → chunk-6BXBGJ3B.js} +45 -369
- package/dist/chunk-6BXBGJ3B.js.map +1 -0
- package/dist/chunk-6UT5OC3P.js +2133 -0
- package/dist/chunk-6UT5OC3P.js.map +1 -0
- package/dist/chunk-EBTT3MYN.js +92 -0
- package/dist/chunk-EBTT3MYN.js.map +1 -0
- package/dist/chunk-HHFA4AFX.js +425 -0
- package/dist/chunk-HHFA4AFX.js.map +1 -0
- package/dist/chunk-LAZ2A743.js +2393 -0
- package/dist/chunk-LAZ2A743.js.map +1 -0
- package/dist/chunk-MRSECXRP.js +563 -0
- package/dist/chunk-MRSECXRP.js.map +1 -0
- package/dist/chunk-TK7G6XLT.js +18 -0
- package/dist/chunk-TK7G6XLT.js.map +1 -0
- package/dist/composer-DvUaa5ki.d.ts +585 -0
- package/dist/composer.d.ts +6 -0
- package/dist/composer.js +56 -0
- package/dist/composer.js.map +1 -0
- package/dist/index.d.ts +1288 -1785
- package/dist/index.js +7015 -8806
- package/dist/index.js.map +1 -1
- package/dist/machines.d.ts +412 -3
- package/dist/machines.js +3 -1
- package/dist/noto-sans-arabic-loader-IA2T4X2A.js +21 -0
- package/dist/noto-sans-arabic-loader-IA2T4X2A.js.map +1 -0
- package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js +292 -0
- package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js.map +1 -0
- package/dist/queue-surface-implementation-NQMV2ML3.js +890 -0
- package/dist/queue-surface-implementation-NQMV2ML3.js.map +1 -0
- package/dist/session-Gd4iVPYw.d.ts +425 -0
- package/dist/session-context-DIFFlXKc.d.ts +52 -0
- package/dist/session.d.ts +4 -0
- package/dist/session.js +50 -0
- package/dist/session.js.map +1 -0
- package/dist/use-composer-BCfm4mzX.d.ts +112 -0
- package/package.json +20 -4
- package/src/approvals.ts +5 -3
- package/src/client.ts +40 -0
- package/src/components/approval-surface.tsx +198 -0
- package/src/components/chat-composer.tsx +94 -993
- package/src/components/code-editor.tsx +104 -26
- package/src/components/command-palette.tsx +8 -2
- package/src/components/composer-transcription-control.tsx +211 -0
- package/src/components/composer.tsx +1536 -0
- package/src/components/desktop-viewer.tsx +3 -3
- package/src/components/enrollment-consent.tsx +2 -2
- package/src/components/file-browser.tsx +367 -29
- package/src/components/human-input-form.tsx +417 -0
- package/src/components/machine-card.tsx +1 -1
- package/src/components/machines-dashboard.tsx +3 -3
- package/src/components/message-timeline.tsx +15 -7
- package/src/components/model-picker.tsx +12 -3
- package/src/components/pierre-diff.tsx +32 -6
- package/src/components/queue-surface-implementation.tsx +1041 -0
- package/src/components/queue-surface-state.tsx +94 -0
- package/src/components/queue-surface.tsx +41 -559
- package/src/components/sandbox-files.tsx +138 -14
- package/src/components/sandbox-terminal.tsx +8 -2
- package/src/components/sandbox-workspace.tsx +339 -119
- package/src/components/workbench-changes.tsx +84 -33
- package/src/components/workspace-dock.tsx +188 -18
- package/src/composer.ts +60 -0
- package/src/hooks/internal.ts +115 -34
- package/src/hooks/use-composer.ts +335 -104
- package/src/hooks/use-human-input.ts +131 -0
- package/src/hooks/use-machine-chip.ts +2 -2
- package/src/hooks/use-machines.ts +27 -11
- package/src/hooks/use-sandbox-files.ts +310 -62
- package/src/hooks/use-sandbox-git.ts +154 -40
- package/src/hooks/use-sandbox-terminal.ts +28 -6
- package/src/hooks/use-session-capabilities.ts +38 -9
- package/src/hooks/use-session-control.ts +37 -8
- package/src/hooks/use-session-events.ts +522 -48
- package/src/hooks/use-slash-commands.ts +38 -38
- package/src/hooks/use-transcription.ts +757 -0
- package/src/hooks/use-turn-queue.ts +134 -66
- package/src/hooks/use-windowed-sections.ts +2 -2
- package/src/hooks/use-workspace-capture.ts +146 -40
- package/src/hooks/use-workspace-edit.ts +50 -14
- package/src/hooks/use-workspace-sessions.ts +3 -0
- package/src/human-input.ts +72 -0
- package/src/index.ts +76 -6
- package/src/lib/noto-sans-arabic-loader.ts +21 -0
- package/src/lib/noto-sans-jp-loader.generated.ts +295 -0
- package/src/lib/use-portal-token-style.ts +51 -0
- package/src/lib/use-unicode-fonts.ts +57 -0
- package/src/provider.tsx +4 -69
- package/src/session-context.ts +135 -0
- package/src/session.ts +79 -0
- package/src/timeline/parsers.ts +35 -0
- package/src/timeline/projection.ts +12 -4
- package/src/timeline/tool-renderers.tsx +41 -0
- package/src/timeline/turn-summary.tsx +2 -2
- package/src/timeline/types.ts +16 -8
- package/styles/index.css +56 -0
- package/styles/index.d.ts +2 -0
- package/styles/tokens.css +11 -6
- package/styles/tokens.d.ts +2 -0
- package/dist/chunk-TOJR776I.js.map +0 -1
- package/dist/machines-BpdwuQcD.d.ts +0 -449
|
@@ -621,7 +621,7 @@ function WarmingNotice({ onRetry }: { onRetry?: (() => void) | undefined }) {
|
|
|
621
621
|
<button
|
|
622
622
|
type="button"
|
|
623
623
|
onClick={onRetry}
|
|
624
|
-
className="rounded-og-sm border border-og-border px-3 py-1.5 text-og-sm text-og-fg-muted transition-colors hover:text-og-fg pointer-coarse:min-h-
|
|
624
|
+
className="rounded-og-sm border border-og-border px-3 py-1.5 text-og-sm text-og-fg-muted transition-colors hover:text-og-fg pointer-coarse:min-h-11"
|
|
625
625
|
>
|
|
626
626
|
Taking too long? Retry
|
|
627
627
|
</button>
|
|
@@ -644,7 +644,7 @@ function DefaultConsentGate({ shared, onAccept }: { shared: boolean; onAccept: (
|
|
|
644
644
|
<button
|
|
645
645
|
type="button"
|
|
646
646
|
onClick={onAccept}
|
|
647
|
-
className="rounded-og-sm bg-og-accent px-3 py-1.5 text-og-sm font-medium text-og-accent-fg pointer-coarse:min-h-
|
|
647
|
+
className="rounded-og-sm bg-og-accent px-3 py-1.5 text-og-sm font-medium text-og-accent-fg pointer-coarse:min-h-11"
|
|
648
648
|
>
|
|
649
649
|
I understand — show the desktop
|
|
650
650
|
</button>
|
|
@@ -664,7 +664,7 @@ function defaultNotice(title: string, body: string, onRetry?: () => void): React
|
|
|
664
664
|
<button
|
|
665
665
|
type="button"
|
|
666
666
|
onClick={onRetry}
|
|
667
|
-
className="mt-3 rounded-og-sm border border-og-border px-3 py-1.5 text-og-sm transition-colors hover:border-og-accent pointer-coarse:min-h-
|
|
667
|
+
className="mt-3 rounded-og-sm border border-og-border px-3 py-1.5 text-og-sm transition-colors hover:border-og-accent pointer-coarse:min-h-11"
|
|
668
668
|
>
|
|
669
669
|
Reconnect
|
|
670
670
|
</button>
|
|
@@ -198,7 +198,7 @@ export function EnrollmentConsent({
|
|
|
198
198
|
data-deny
|
|
199
199
|
disabled={busy}
|
|
200
200
|
onClick={() => onDeny?.()}
|
|
201
|
-
className="flex-1 rounded-og-sm border border-og-border px-3 py-2 text-og-sm font-medium text-og-fg-muted transition-colors hover:border-og-border-strong hover:text-og-fg disabled:opacity-50 pointer-coarse:min-h-
|
|
201
|
+
className="flex-1 rounded-og-sm border border-og-border px-3 py-2 text-og-sm font-medium text-og-fg-muted transition-colors hover:border-og-border-strong hover:text-og-fg disabled:opacity-50 pointer-coarse:min-h-11"
|
|
202
202
|
>
|
|
203
203
|
Cancel
|
|
204
204
|
</button>
|
|
@@ -207,7 +207,7 @@ export function EnrollmentConsent({
|
|
|
207
207
|
data-approve
|
|
208
208
|
disabled={busy}
|
|
209
209
|
onClick={() => onApprove?.(allowScreenControl)}
|
|
210
|
-
className="flex-1 rounded-og-sm bg-og-status-failed px-3 py-2 text-og-sm font-semibold text-og-accent-fg transition-colors hover:opacity-90 disabled:opacity-50 pointer-coarse:min-h-
|
|
210
|
+
className="flex-1 rounded-og-sm bg-og-status-failed px-3 py-2 text-og-sm font-semibold text-og-accent-fg transition-colors hover:opacity-90 disabled:opacity-50 pointer-coarse:min-h-11"
|
|
211
211
|
>
|
|
212
212
|
{busy ? "Connecting…" : "Grant full access"}
|
|
213
213
|
</button>
|
|
@@ -6,20 +6,26 @@ import {
|
|
|
6
6
|
FolderPlusIcon,
|
|
7
7
|
PencilIcon,
|
|
8
8
|
RefreshCwIcon,
|
|
9
|
+
TriangleAlertIcon,
|
|
9
10
|
Trash2Icon,
|
|
10
11
|
} from "lucide-react";
|
|
11
12
|
import {
|
|
12
13
|
type KeyboardEvent as ReactKeyboardEvent,
|
|
14
|
+
type MouseEvent as ReactMouseEvent,
|
|
13
15
|
type ReactNode,
|
|
14
16
|
useCallback,
|
|
15
17
|
useEffect,
|
|
18
|
+
useId,
|
|
16
19
|
useLayoutEffect,
|
|
17
20
|
useMemo,
|
|
18
21
|
useRef,
|
|
19
22
|
useState,
|
|
20
23
|
} from "react";
|
|
24
|
+
import { AlertDialog } from "radix-ui";
|
|
21
25
|
import { VList, type VListHandle } from "virtua";
|
|
22
26
|
import { cn } from "../lib/cn";
|
|
27
|
+
import { type PortalTokenStyle, usePortalTokenStyle } from "../lib/use-portal-token-style";
|
|
28
|
+
import { useUnicodeFallbackFonts } from "../lib/use-unicode-fonts";
|
|
23
29
|
import type { FileTreeNode, UseSandboxFilesResult } from "../hooks/use-sandbox-files";
|
|
24
30
|
|
|
25
31
|
export type FileBrowserProps = {
|
|
@@ -45,7 +51,8 @@ export type FileBrowserProps = {
|
|
|
45
51
|
editable?: boolean | undefined;
|
|
46
52
|
/**
|
|
47
53
|
* Confirm a (recursive) delete before it runs. Return `false` to cancel.
|
|
48
|
-
*
|
|
54
|
+
* The default is an accessible, non-blocking confirmation dialog. Supply this
|
|
55
|
+
* callback to delegate confirmation to a host-owned dialog.
|
|
49
56
|
*/
|
|
50
57
|
confirmDelete?: ((node: FileTreeNode) => boolean | Promise<boolean>) | undefined;
|
|
51
58
|
className?: string | undefined;
|
|
@@ -144,10 +151,18 @@ export function FileBrowser({
|
|
|
144
151
|
const [draftRename, setDraftRename] = useState<DraftRename | null>(null);
|
|
145
152
|
const [dragOver, setDragOver] = useState<string | null>(null);
|
|
146
153
|
const [menu, setMenu] = useState<ContextMenuState | null>(null);
|
|
154
|
+
const [pendingDelete, setPendingDelete] = useState<FileTreeNode | null>(null);
|
|
147
155
|
const [busy, setBusy] = useState(false);
|
|
148
156
|
const [coarsePointer, setCoarsePointer] = useState(false);
|
|
157
|
+
const treeId = useId();
|
|
149
158
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
159
|
+
const deleteReturnFocusRef = useRef<HTMLElement | null>(null);
|
|
150
160
|
const vlistRef = useRef<VListHandle>(null);
|
|
161
|
+
const portalTokenStyle = usePortalTokenStyle(containerRef);
|
|
162
|
+
const getTreeElement = useCallback(
|
|
163
|
+
() => (typeof document === "undefined" ? null : document.getElementById(treeId)),
|
|
164
|
+
[treeId],
|
|
165
|
+
);
|
|
151
166
|
|
|
152
167
|
useBrowserLayoutEffect(() => {
|
|
153
168
|
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
|
|
@@ -248,6 +263,11 @@ export function FileBrowser({
|
|
|
248
263
|
walk(result.tree, 0);
|
|
249
264
|
return items;
|
|
250
265
|
}, [result.tree, expanded, draftCreate, loadingPaths, result.expandingPaths, cold]);
|
|
266
|
+
const unicodeFontPaths = useMemo(
|
|
267
|
+
() => renderItems.flatMap((item) => (item.type === "node" ? [item.node.path] : [])),
|
|
268
|
+
[renderItems],
|
|
269
|
+
);
|
|
270
|
+
useUnicodeFallbackFonts(unicodeFontPaths);
|
|
251
271
|
|
|
252
272
|
// The visible NODE rows in order — drives keyboard up/down navigation.
|
|
253
273
|
const flatRows = useMemo(
|
|
@@ -257,23 +277,34 @@ export function FileBrowser({
|
|
|
257
277
|
),
|
|
258
278
|
[renderItems],
|
|
259
279
|
);
|
|
280
|
+
const rowIndexByPath = useMemo(
|
|
281
|
+
() => new Map(flatRows.map((row, index) => [row.node.path, index] as const)),
|
|
282
|
+
[flatRows],
|
|
283
|
+
);
|
|
284
|
+
const treePositionByPath = useMemo(() => {
|
|
285
|
+
const positions = new Map<string, { position: number; setSize: number }>();
|
|
286
|
+
const visit = (nodes: FileTreeNode[]) => {
|
|
287
|
+
nodes.forEach((node, index) => {
|
|
288
|
+
positions.set(node.path, { position: index + 1, setSize: nodes.length });
|
|
289
|
+
if (node.children) visit(node.children);
|
|
290
|
+
});
|
|
291
|
+
};
|
|
292
|
+
visit(result.tree);
|
|
293
|
+
return positions;
|
|
294
|
+
}, [result.tree]);
|
|
295
|
+
const cursorIndex = cursor ? (rowIndexByPath.get(cursor) ?? -1) : -1;
|
|
296
|
+
const activeDescendantId = cursorIndex >= 0 ? `${treeId}-item-${cursorIndex}` : undefined;
|
|
260
297
|
|
|
261
|
-
|
|
298
|
+
// A capture is immutable review data even if a stale capability document still
|
|
299
|
+
// says the machine is writable. Mutations become available only after a live
|
|
300
|
+
// reconciliation has actually succeeded.
|
|
301
|
+
const supportsMutation =
|
|
302
|
+
editable && result.source === "live" && result.error === null && !result.loading;
|
|
262
303
|
|
|
263
|
-
const
|
|
304
|
+
const performDelete = useCallback(
|
|
264
305
|
async (node: FileTreeNode) => {
|
|
265
306
|
if (!supportsMutation) return;
|
|
266
307
|
const recursive = node.kind === "dir";
|
|
267
|
-
const confirmFn =
|
|
268
|
-
confirmDelete ??
|
|
269
|
-
((n: FileTreeNode) =>
|
|
270
|
-
typeof window !== "undefined" && typeof window.confirm === "function"
|
|
271
|
-
? window.confirm(
|
|
272
|
-
`Delete ${n.kind === "dir" ? "folder" : "file"} "${n.name}"${recursive ? " and its contents" : ""}?`,
|
|
273
|
-
)
|
|
274
|
-
: true);
|
|
275
|
-
const ok = await confirmFn(node);
|
|
276
|
-
if (!ok) return;
|
|
277
308
|
setBusy(true);
|
|
278
309
|
try {
|
|
279
310
|
await result.deleteEntry(node.path, recursive);
|
|
@@ -283,7 +314,40 @@ export function FileBrowser({
|
|
|
283
314
|
setBusy(false);
|
|
284
315
|
}
|
|
285
316
|
},
|
|
286
|
-
[supportsMutation,
|
|
317
|
+
[supportsMutation, result],
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
const runDelete = useCallback(
|
|
321
|
+
async (node: FileTreeNode, returnFocus?: HTMLElement) => {
|
|
322
|
+
if (!supportsMutation) return;
|
|
323
|
+
setMenu(null);
|
|
324
|
+
if (!confirmDelete) {
|
|
325
|
+
deleteReturnFocusRef.current =
|
|
326
|
+
returnFocus ??
|
|
327
|
+
(typeof document !== "undefined" && document.activeElement instanceof HTMLElement
|
|
328
|
+
? document.activeElement
|
|
329
|
+
: null);
|
|
330
|
+
setPendingDelete(node);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (await confirmDelete(node)) await performDelete(node);
|
|
334
|
+
},
|
|
335
|
+
[supportsMutation, confirmDelete, performDelete],
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
const closePendingDelete = useCallback(
|
|
339
|
+
(restoreTrigger: boolean) => {
|
|
340
|
+
const returnTarget = restoreTrigger ? deleteReturnFocusRef.current : getTreeElement();
|
|
341
|
+
deleteReturnFocusRef.current = null;
|
|
342
|
+
setPendingDelete(null);
|
|
343
|
+
if (typeof window !== "undefined") {
|
|
344
|
+
window.requestAnimationFrame(() => {
|
|
345
|
+
const target = returnTarget?.isConnected ? returnTarget : getTreeElement();
|
|
346
|
+
target?.focus();
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
},
|
|
350
|
+
[getTreeElement],
|
|
287
351
|
);
|
|
288
352
|
|
|
289
353
|
const commitCreate = useCallback(
|
|
@@ -410,6 +474,28 @@ export function FileBrowser({
|
|
|
410
474
|
case "ArrowUp":
|
|
411
475
|
move(idx < 0 ? 0 : idx - 1);
|
|
412
476
|
break;
|
|
477
|
+
case "Home":
|
|
478
|
+
move(0);
|
|
479
|
+
break;
|
|
480
|
+
case "End":
|
|
481
|
+
move(flatRows.length - 1);
|
|
482
|
+
break;
|
|
483
|
+
case "PageDown": {
|
|
484
|
+
const pageSize = Math.max(
|
|
485
|
+
1,
|
|
486
|
+
Math.floor((containerRef.current?.clientHeight || 280) / (coarsePointer ? 44 : 28)) - 1,
|
|
487
|
+
);
|
|
488
|
+
move((idx < 0 ? 0 : idx) + pageSize);
|
|
489
|
+
break;
|
|
490
|
+
}
|
|
491
|
+
case "PageUp": {
|
|
492
|
+
const pageSize = Math.max(
|
|
493
|
+
1,
|
|
494
|
+
Math.floor((containerRef.current?.clientHeight || 280) / (coarsePointer ? 44 : 28)) - 1,
|
|
495
|
+
);
|
|
496
|
+
move((idx < 0 ? 0 : idx) - pageSize);
|
|
497
|
+
break;
|
|
498
|
+
}
|
|
413
499
|
case "ArrowRight":
|
|
414
500
|
if (cur?.node.kind === "dir") {
|
|
415
501
|
if (!expanded.has(cur.node.path)) open(cur.node.path);
|
|
@@ -439,6 +525,22 @@ export function FileBrowser({
|
|
|
439
525
|
e.preventDefault();
|
|
440
526
|
}
|
|
441
527
|
break;
|
|
528
|
+
case "ContextMenu":
|
|
529
|
+
case "F10":
|
|
530
|
+
if (cur && supportsMutation && (e.key === "ContextMenu" || e.shiftKey)) {
|
|
531
|
+
const rowIndex = rowIndexByPath.get(cur.node.path) ?? 0;
|
|
532
|
+
const row = document.getElementById(`${treeId}-item-${rowIndex}`);
|
|
533
|
+
const rect = row?.getBoundingClientRect();
|
|
534
|
+
setMenu({
|
|
535
|
+
node: cur.node,
|
|
536
|
+
x: rect ? rect.left + Math.min(32, rect.width / 2) : 8,
|
|
537
|
+
y: rect ? rect.bottom : 8,
|
|
538
|
+
});
|
|
539
|
+
e.preventDefault();
|
|
540
|
+
} else if (e.key === "F10") {
|
|
541
|
+
break;
|
|
542
|
+
}
|
|
543
|
+
break;
|
|
442
544
|
case "F2":
|
|
443
545
|
if (cur) {
|
|
444
546
|
startRename(cur.node);
|
|
@@ -466,6 +568,9 @@ export function FileBrowser({
|
|
|
466
568
|
startRename,
|
|
467
569
|
runDelete,
|
|
468
570
|
supportsMutation,
|
|
571
|
+
coarsePointer,
|
|
572
|
+
rowIndexByPath,
|
|
573
|
+
treeId,
|
|
469
574
|
],
|
|
470
575
|
);
|
|
471
576
|
|
|
@@ -484,8 +589,12 @@ export function FileBrowser({
|
|
|
484
589
|
}, [menu]);
|
|
485
590
|
|
|
486
591
|
const showErrorEmpty = result.error && result.tree.length === 0 && !draftCreate;
|
|
592
|
+
const showDegradedTree = result.error && result.tree.length > 0;
|
|
487
593
|
const showEmpty = !result.loading && result.tree.length === 0 && !draftCreate;
|
|
488
|
-
|
|
594
|
+
// Retry lives next to the state it explains (the degraded/empty notice), so do
|
|
595
|
+
// not render a second toolbar retry for the same failure.
|
|
596
|
+
const showToolbar = supportsMutation || result.loading;
|
|
597
|
+
const usesVirtualTree = !showErrorEmpty && !showEmpty;
|
|
489
598
|
|
|
490
599
|
// Render a SINGLE node row (no recursion — children are separate flat items the
|
|
491
600
|
// virtualizer renders). Returns the row element; the caller assigns the key.
|
|
@@ -501,10 +610,15 @@ export function FileBrowser({
|
|
|
501
610
|
const isRenaming = draftRename?.path === node.path;
|
|
502
611
|
const isDropTarget = dragOver === (isDir ? node.path : parentOf(node.path));
|
|
503
612
|
const dropDir = isDir ? node.path : parentOf(node.path);
|
|
613
|
+
const treePosition = treePositionByPath.get(node.path);
|
|
504
614
|
|
|
505
615
|
return (
|
|
506
616
|
<div
|
|
617
|
+
id={`${treeId}-item-${rowIndexByPath.get(node.path) ?? 0}`}
|
|
507
618
|
role="treeitem"
|
|
619
|
+
aria-level={depth + 1}
|
|
620
|
+
aria-posinset={treePosition?.position}
|
|
621
|
+
aria-setsize={treePosition?.setSize}
|
|
508
622
|
aria-expanded={isDir ? isOpen : undefined}
|
|
509
623
|
aria-selected={isCursor || undefined}
|
|
510
624
|
aria-busy={isLoading || undefined}
|
|
@@ -547,6 +661,7 @@ export function FileBrowser({
|
|
|
547
661
|
) : (
|
|
548
662
|
<button
|
|
549
663
|
type="button"
|
|
664
|
+
tabIndex={-1}
|
|
550
665
|
draggable={supportsMutation || undefined}
|
|
551
666
|
onDragStart={
|
|
552
667
|
supportsMutation
|
|
@@ -676,7 +791,11 @@ export function FileBrowser({
|
|
|
676
791
|
return (
|
|
677
792
|
<div className={cn("flex min-h-0 min-w-0 flex-col", className)}>
|
|
678
793
|
{showToolbar && (
|
|
679
|
-
<div
|
|
794
|
+
<div
|
|
795
|
+
role="toolbar"
|
|
796
|
+
aria-label="File actions"
|
|
797
|
+
className="flex shrink-0 flex-wrap items-center gap-0.5 border-b border-og-border px-1 py-1"
|
|
798
|
+
>
|
|
680
799
|
{supportsMutation ? (
|
|
681
800
|
<>
|
|
682
801
|
<ToolbarButton label="New file" onClick={() => startCreate("file")} disabled={busy}>
|
|
@@ -697,9 +816,9 @@ export function FileBrowser({
|
|
|
697
816
|
</ToolbarButton>
|
|
698
817
|
<ToolbarButton
|
|
699
818
|
label="Delete"
|
|
700
|
-
onClick={() => {
|
|
819
|
+
onClick={(event) => {
|
|
701
820
|
const node = cursor ? findNode(result.tree, cursor) : undefined;
|
|
702
|
-
if (node) void runDelete(node);
|
|
821
|
+
if (node) void runDelete(node, event.currentTarget);
|
|
703
822
|
}}
|
|
704
823
|
disabled={busy || !cursor}
|
|
705
824
|
>
|
|
@@ -718,13 +837,50 @@ export function FileBrowser({
|
|
|
718
837
|
</div>
|
|
719
838
|
)}
|
|
720
839
|
|
|
840
|
+
{showDegradedTree ? (
|
|
841
|
+
<div
|
|
842
|
+
role="status"
|
|
843
|
+
aria-live="polite"
|
|
844
|
+
data-opengeni-files-degraded
|
|
845
|
+
className="flex shrink-0 items-center gap-2 border-b border-og-status-running/30 bg-og-status-running/10 px-2 py-1.5 text-og-xs text-og-fg-muted"
|
|
846
|
+
>
|
|
847
|
+
<TriangleAlertIcon className="size-3.5 shrink-0 text-og-status-running" aria-hidden />
|
|
848
|
+
<span className="min-w-0 flex-1">
|
|
849
|
+
{result.source === "capture"
|
|
850
|
+
? "Live files are temporarily unavailable. Showing the latest captured revision."
|
|
851
|
+
: "Live refresh failed. Showing the last loaded files."}
|
|
852
|
+
</span>
|
|
853
|
+
<button
|
|
854
|
+
type="button"
|
|
855
|
+
onClick={() => void result.refresh()}
|
|
856
|
+
disabled={result.loading}
|
|
857
|
+
className="inline-flex min-h-7 shrink-0 items-center gap-1 rounded-og-sm border border-og-border bg-og-surface-1 px-2 font-medium text-og-fg transition-colors hover:border-og-border-strong focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-og-accent disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:min-h-11"
|
|
858
|
+
>
|
|
859
|
+
<RefreshCwIcon
|
|
860
|
+
className={cn("size-3", result.loading && "animate-spin motion-reduce:animate-none")}
|
|
861
|
+
aria-hidden
|
|
862
|
+
/>
|
|
863
|
+
Retry
|
|
864
|
+
</button>
|
|
865
|
+
</div>
|
|
866
|
+
) : null}
|
|
867
|
+
|
|
721
868
|
{/* The tree itself. The root is a drop target so a node can be moved to "". */}
|
|
722
869
|
{/* biome-ignore lint/a11y/noNoninteractiveTabindex: the tree owns keyboard nav */}
|
|
723
870
|
<div
|
|
724
871
|
ref={containerRef}
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
aria-
|
|
872
|
+
id={usesVirtualTree ? undefined : treeId}
|
|
873
|
+
role={usesVirtualTree ? undefined : "tree"}
|
|
874
|
+
aria-label={usesVirtualTree ? undefined : "Workspace files"}
|
|
875
|
+
aria-activedescendant={usesVirtualTree ? undefined : activeDescendantId}
|
|
876
|
+
tabIndex={usesVirtualTree ? undefined : 0}
|
|
877
|
+
aria-multiselectable={usesVirtualTree ? undefined : false}
|
|
878
|
+
onFocus={(event) => {
|
|
879
|
+
if ((event.target as HTMLElement).id === treeId && cursorIndex < 0) {
|
|
880
|
+
const first = flatRows[0];
|
|
881
|
+
if (first) setActive(first.node.path);
|
|
882
|
+
}
|
|
883
|
+
}}
|
|
728
884
|
onKeyDown={onKeyDown}
|
|
729
885
|
onDragOver={
|
|
730
886
|
supportsMutation
|
|
@@ -745,7 +901,7 @@ export function FileBrowser({
|
|
|
745
901
|
: undefined
|
|
746
902
|
}
|
|
747
903
|
className={cn(
|
|
748
|
-
"flex min-h-0 min-w-0 flex-1 flex-col p-1 outline-none",
|
|
904
|
+
"flex min-h-0 min-w-0 flex-1 flex-col p-1 outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-og-accent",
|
|
749
905
|
dragOver === "" && "ring-1 ring-inset ring-og-accent",
|
|
750
906
|
)}
|
|
751
907
|
data-opengeni-file-tree
|
|
@@ -760,7 +916,7 @@ export function FileBrowser({
|
|
|
760
916
|
type="button"
|
|
761
917
|
onClick={() => void result.refresh()}
|
|
762
918
|
disabled={result.loading}
|
|
763
|
-
className="inline-flex items-center gap-1.5 rounded-og-sm border border-og-border px-2 py-1 text-og-xs font-medium text-og-fg-muted transition-colors hover:border-og-border-strong hover:text-og-fg disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:min-h-
|
|
919
|
+
className="inline-flex items-center gap-1.5 rounded-og-sm border border-og-border px-2 py-1 text-og-xs font-medium text-og-fg-muted transition-colors hover:border-og-border-strong hover:text-og-fg disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:min-h-11"
|
|
764
920
|
>
|
|
765
921
|
<RefreshCwIcon
|
|
766
922
|
className={cn("size-3.5", result.loading && "animate-spin")}
|
|
@@ -779,7 +935,13 @@ export function FileBrowser({
|
|
|
779
935
|
// expanded subtree + synthetic (create/skeleton/residue) rows.
|
|
780
936
|
<VList
|
|
781
937
|
ref={vlistRef}
|
|
782
|
-
|
|
938
|
+
id={treeId}
|
|
939
|
+
role="tree"
|
|
940
|
+
aria-label="Workspace files"
|
|
941
|
+
aria-activedescendant={activeDescendantId}
|
|
942
|
+
aria-multiselectable={false}
|
|
943
|
+
tabIndex={0}
|
|
944
|
+
className="min-h-0 flex-1 outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-og-accent"
|
|
783
945
|
itemSize={coarsePointer ? 44 : 28}
|
|
784
946
|
ssrCount={Math.min(32, renderItems.length)}
|
|
785
947
|
>
|
|
@@ -801,12 +963,154 @@ export function FileBrowser({
|
|
|
801
963
|
onDelete={() => menu.node && void runDelete(menu.node)}
|
|
802
964
|
onNewFile={() => startCreate("file")}
|
|
803
965
|
onNewFolder={() => startCreate("dir")}
|
|
966
|
+
onClose={() => {
|
|
967
|
+
setMenu(null);
|
|
968
|
+
window.requestAnimationFrame(() => getTreeElement()?.focus());
|
|
969
|
+
}}
|
|
804
970
|
/>
|
|
805
971
|
)}
|
|
972
|
+
{pendingDelete ? (
|
|
973
|
+
<DeleteDialog
|
|
974
|
+
node={pendingDelete}
|
|
975
|
+
busy={busy}
|
|
976
|
+
portalTokenStyle={portalTokenStyle}
|
|
977
|
+
onCancel={() => closePendingDelete(true)}
|
|
978
|
+
onConfirm={() => {
|
|
979
|
+
const node = pendingDelete;
|
|
980
|
+
closePendingDelete(false);
|
|
981
|
+
void performDelete(node);
|
|
982
|
+
}}
|
|
983
|
+
/>
|
|
984
|
+
) : null}
|
|
806
985
|
</div>
|
|
807
986
|
);
|
|
808
987
|
}
|
|
809
988
|
|
|
989
|
+
function DeleteDialog({
|
|
990
|
+
node,
|
|
991
|
+
busy,
|
|
992
|
+
portalTokenStyle,
|
|
993
|
+
onCancel,
|
|
994
|
+
onConfirm,
|
|
995
|
+
}: {
|
|
996
|
+
node: FileTreeNode;
|
|
997
|
+
busy: boolean;
|
|
998
|
+
portalTokenStyle: PortalTokenStyle;
|
|
999
|
+
onCancel: () => void;
|
|
1000
|
+
onConfirm: () => void;
|
|
1001
|
+
}) {
|
|
1002
|
+
const cancelRef = useRef<HTMLButtonElement | null>(null);
|
|
1003
|
+
const [open, setOpen] = useState(true);
|
|
1004
|
+
const closeIntentRef = useRef<"cancel" | "confirm">("cancel");
|
|
1005
|
+
const settledRef = useRef(false);
|
|
1006
|
+
const settleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
1007
|
+
|
|
1008
|
+
const settle = () => {
|
|
1009
|
+
if (settledRef.current) return;
|
|
1010
|
+
settledRef.current = true;
|
|
1011
|
+
if (closeIntentRef.current === "confirm") {
|
|
1012
|
+
onConfirm();
|
|
1013
|
+
} else {
|
|
1014
|
+
onCancel();
|
|
1015
|
+
}
|
|
1016
|
+
};
|
|
1017
|
+
|
|
1018
|
+
const scheduleSettle = () => {
|
|
1019
|
+
if (settleTimerRef.current !== null) return;
|
|
1020
|
+
settleTimerRef.current = setTimeout(() => {
|
|
1021
|
+
settleTimerRef.current = null;
|
|
1022
|
+
settle();
|
|
1023
|
+
}, 0);
|
|
1024
|
+
};
|
|
1025
|
+
|
|
1026
|
+
useEffect(
|
|
1027
|
+
() => () => {
|
|
1028
|
+
if (settleTimerRef.current !== null) clearTimeout(settleTimerRef.current);
|
|
1029
|
+
},
|
|
1030
|
+
[],
|
|
1031
|
+
);
|
|
1032
|
+
|
|
1033
|
+
return (
|
|
1034
|
+
<AlertDialog.Root
|
|
1035
|
+
open={open}
|
|
1036
|
+
onOpenChange={(nextOpen) => {
|
|
1037
|
+
if (!nextOpen && !busy) {
|
|
1038
|
+
setOpen(false);
|
|
1039
|
+
scheduleSettle();
|
|
1040
|
+
}
|
|
1041
|
+
}}
|
|
1042
|
+
>
|
|
1043
|
+
<AlertDialog.Portal container={typeof document === "undefined" ? null : document.body}>
|
|
1044
|
+
<AlertDialog.Overlay className="fixed inset-0 z-[100] bg-black/45 backdrop-blur-[2px]" />
|
|
1045
|
+
<AlertDialog.Content
|
|
1046
|
+
className="og-root fixed left-1/2 top-1/2 z-[101] max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-sm -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-og-lg border border-og-border-strong bg-og-surface-1 p-4 text-og-fg shadow-2xl outline-none"
|
|
1047
|
+
style={portalTokenStyle}
|
|
1048
|
+
onOpenAutoFocus={(event) => {
|
|
1049
|
+
event.preventDefault();
|
|
1050
|
+
cancelRef.current?.focus();
|
|
1051
|
+
}}
|
|
1052
|
+
onCloseAutoFocus={(event) => {
|
|
1053
|
+
event.preventDefault();
|
|
1054
|
+
scheduleSettle();
|
|
1055
|
+
}}
|
|
1056
|
+
onEscapeKeyDown={(event) => {
|
|
1057
|
+
event.stopPropagation();
|
|
1058
|
+
if (busy) {
|
|
1059
|
+
event.preventDefault();
|
|
1060
|
+
} else {
|
|
1061
|
+
closeIntentRef.current = "cancel";
|
|
1062
|
+
}
|
|
1063
|
+
}}
|
|
1064
|
+
>
|
|
1065
|
+
<div className="flex items-start gap-3">
|
|
1066
|
+
<span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-og-status-failed/12 text-og-status-failed">
|
|
1067
|
+
<Trash2Icon className="size-4" aria-hidden />
|
|
1068
|
+
</span>
|
|
1069
|
+
<div className="min-w-0">
|
|
1070
|
+
<AlertDialog.Title className="text-og-sm font-semibold">
|
|
1071
|
+
Delete {node.kind === "dir" ? "folder" : "file"}?
|
|
1072
|
+
</AlertDialog.Title>
|
|
1073
|
+
<AlertDialog.Description className="mt-1 text-og-sm leading-relaxed text-og-fg-muted">
|
|
1074
|
+
<span className="break-all font-og-mono text-og-fg">{node.path}</span>
|
|
1075
|
+
{node.kind === "dir"
|
|
1076
|
+
? " and everything inside it will be permanently removed."
|
|
1077
|
+
: " will be permanently removed."}
|
|
1078
|
+
</AlertDialog.Description>
|
|
1079
|
+
</div>
|
|
1080
|
+
</div>
|
|
1081
|
+
<div className="mt-4 flex justify-end gap-2">
|
|
1082
|
+
<AlertDialog.Cancel asChild>
|
|
1083
|
+
<button
|
|
1084
|
+
ref={cancelRef}
|
|
1085
|
+
type="button"
|
|
1086
|
+
onClick={() => {
|
|
1087
|
+
closeIntentRef.current = "cancel";
|
|
1088
|
+
}}
|
|
1089
|
+
disabled={busy}
|
|
1090
|
+
className="min-h-9 rounded-og-md border border-og-border px-3 text-og-sm font-medium text-og-fg hover:bg-og-surface-2 disabled:opacity-50 pointer-coarse:min-h-11"
|
|
1091
|
+
>
|
|
1092
|
+
Cancel
|
|
1093
|
+
</button>
|
|
1094
|
+
</AlertDialog.Cancel>
|
|
1095
|
+
<AlertDialog.Action asChild>
|
|
1096
|
+
<button
|
|
1097
|
+
type="button"
|
|
1098
|
+
onClick={() => {
|
|
1099
|
+
closeIntentRef.current = "confirm";
|
|
1100
|
+
}}
|
|
1101
|
+
disabled={busy}
|
|
1102
|
+
className="min-h-9 rounded-og-md bg-og-status-failed px-3 text-og-sm font-semibold text-white shadow-sm hover:brightness-110 disabled:opacity-50 pointer-coarse:min-h-11"
|
|
1103
|
+
>
|
|
1104
|
+
Delete permanently
|
|
1105
|
+
</button>
|
|
1106
|
+
</AlertDialog.Action>
|
|
1107
|
+
</div>
|
|
1108
|
+
</AlertDialog.Content>
|
|
1109
|
+
</AlertDialog.Portal>
|
|
1110
|
+
</AlertDialog.Root>
|
|
1111
|
+
);
|
|
1112
|
+
}
|
|
1113
|
+
|
|
810
1114
|
function ToolbarButton({
|
|
811
1115
|
label,
|
|
812
1116
|
onClick,
|
|
@@ -814,7 +1118,7 @@ function ToolbarButton({
|
|
|
814
1118
|
children,
|
|
815
1119
|
}: {
|
|
816
1120
|
label: string;
|
|
817
|
-
onClick: () => void;
|
|
1121
|
+
onClick: (event: ReactMouseEvent<HTMLButtonElement>) => void;
|
|
818
1122
|
disabled?: boolean;
|
|
819
1123
|
children: ReactNode;
|
|
820
1124
|
}) {
|
|
@@ -920,6 +1224,7 @@ function ContextMenu({
|
|
|
920
1224
|
onDelete,
|
|
921
1225
|
onNewFile,
|
|
922
1226
|
onNewFolder,
|
|
1227
|
+
onClose,
|
|
923
1228
|
}: {
|
|
924
1229
|
node: FileTreeNode;
|
|
925
1230
|
x: number;
|
|
@@ -928,23 +1233,53 @@ function ContextMenu({
|
|
|
928
1233
|
onDelete: () => void;
|
|
929
1234
|
onNewFile: () => void;
|
|
930
1235
|
onNewFolder: () => void;
|
|
1236
|
+
onClose: () => void;
|
|
931
1237
|
}) {
|
|
932
1238
|
const isDir = node.kind === "dir";
|
|
1239
|
+
const menuRef = useRef<HTMLDivElement | null>(null);
|
|
933
1240
|
// Keep the menu on-screen when opened near the viewport edge.
|
|
934
1241
|
const vw = typeof window !== "undefined" ? window.innerWidth : 9999;
|
|
935
1242
|
const vh = typeof window !== "undefined" ? window.innerHeight : 9999;
|
|
936
|
-
const left = Math.min(x, vw - 180);
|
|
937
|
-
const top = Math.min(y, vh - 160);
|
|
1243
|
+
const left = Math.max(4, Math.min(x, vw - 180));
|
|
1244
|
+
const top = Math.max(4, Math.min(y, vh - 160));
|
|
1245
|
+
|
|
1246
|
+
useEffect(() => {
|
|
1247
|
+
menuRef.current?.querySelector<HTMLButtonElement>('[role="menuitem"]')?.focus();
|
|
1248
|
+
}, []);
|
|
1249
|
+
|
|
1250
|
+
const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
1251
|
+
const items = Array.from(
|
|
1252
|
+
menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]') ?? [],
|
|
1253
|
+
);
|
|
1254
|
+
if (items.length === 0) return;
|
|
1255
|
+
const current = items.findIndex((item) => item === document.activeElement);
|
|
1256
|
+
let next: number | null = null;
|
|
1257
|
+
if (event.key === "ArrowDown") next = (current + 1 + items.length) % items.length;
|
|
1258
|
+
else if (event.key === "ArrowUp") next = (current - 1 + items.length) % items.length;
|
|
1259
|
+
else if (event.key === "Home") next = 0;
|
|
1260
|
+
else if (event.key === "End") next = items.length - 1;
|
|
1261
|
+
else if (event.key === "Escape") {
|
|
1262
|
+
event.preventDefault();
|
|
1263
|
+
event.stopPropagation();
|
|
1264
|
+
onClose();
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
if (next === null) return;
|
|
1268
|
+
event.preventDefault();
|
|
1269
|
+
event.stopPropagation();
|
|
1270
|
+
items[next]?.focus();
|
|
1271
|
+
};
|
|
938
1272
|
|
|
939
1273
|
const item = (label: string, icon: ReactNode, onClick: () => void, danger?: boolean) => (
|
|
940
1274
|
<button
|
|
941
1275
|
type="button"
|
|
1276
|
+
role="menuitem"
|
|
942
1277
|
onClick={(e) => {
|
|
943
1278
|
e.stopPropagation();
|
|
944
1279
|
onClick();
|
|
945
1280
|
}}
|
|
946
1281
|
className={cn(
|
|
947
|
-
"flex w-full items-center gap-2 px-2.5 py-1 text-left text-og-sm pointer-coarse:min-h-
|
|
1282
|
+
"flex w-full items-center gap-2 px-2.5 py-1 text-left text-og-sm pointer-coarse:min-h-11",
|
|
948
1283
|
"hover:bg-og-surface-2",
|
|
949
1284
|
danger ? "text-og-status-failed" : "text-og-fg",
|
|
950
1285
|
)}
|
|
@@ -956,7 +1291,10 @@ function ContextMenu({
|
|
|
956
1291
|
|
|
957
1292
|
return (
|
|
958
1293
|
<div
|
|
1294
|
+
ref={menuRef}
|
|
959
1295
|
role="menu"
|
|
1296
|
+
aria-label={`Actions for ${node.name}`}
|
|
1297
|
+
onKeyDown={onKeyDown}
|
|
960
1298
|
onClick={(e) => e.stopPropagation()}
|
|
961
1299
|
style={{ left, top }}
|
|
962
1300
|
className={cn(
|
|
@@ -969,7 +1307,7 @@ function ContextMenu({
|
|
|
969
1307
|
<>
|
|
970
1308
|
{item("New file", <FilePlusIcon className="size-3.5" />, onNewFile)}
|
|
971
1309
|
{item("New folder", <FolderPlusIcon className="size-3.5" />, onNewFolder)}
|
|
972
|
-
<div className="my-1 h-px bg-og-border" />
|
|
1310
|
+
<div role="separator" className="my-1 h-px bg-og-border" />
|
|
973
1311
|
</>
|
|
974
1312
|
)}
|
|
975
1313
|
{item("Rename", <PencilIcon className="size-3.5" />, onRename)}
|