@opengeni/react 0.3.1 → 0.5.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 (42) hide show
  1. package/dist/index.d.ts +1055 -27
  2. package/dist/index.js +6993 -1954
  3. package/dist/index.js.map +1 -1
  4. package/package.json +65 -2
  5. package/src/client.ts +22 -0
  6. package/src/components/code-editor.tsx +398 -0
  7. package/src/components/desktop-viewer.tsx +647 -0
  8. package/src/components/diff-view.tsx +230 -0
  9. package/src/components/file-browser.tsx +838 -0
  10. package/src/components/fleet-tile.tsx +5 -0
  11. package/src/components/message-timeline.tsx +70 -196
  12. package/src/components/pierre-diff.tsx +140 -0
  13. package/src/components/pierre-file.tsx +142 -0
  14. package/src/components/sandbox-files.tsx +509 -0
  15. package/src/components/sandbox-terminal.tsx +425 -0
  16. package/src/components/workspace-dock.tsx +247 -0
  17. package/src/hooks/use-desktop-stream.ts +214 -0
  18. package/src/hooks/use-sandbox-files.ts +670 -0
  19. package/src/hooks/use-sandbox-git.ts +105 -0
  20. package/src/hooks/use-sandbox-terminal.ts +226 -0
  21. package/src/hooks/use-session-capabilities.ts +415 -0
  22. package/src/hooks/use-session.ts +80 -12
  23. package/src/hooks/use-terminal-stream.ts +207 -0
  24. package/src/index.ts +112 -3
  25. package/src/lib/cn.ts +20 -1
  26. package/src/lib/git-patch.ts +43 -0
  27. package/src/lib/use-theme-type.ts +40 -0
  28. package/src/lib/xterm-theme.ts +34 -0
  29. package/src/timeline/activity-rail.tsx +207 -0
  30. package/src/timeline/disclosure-context.tsx +34 -0
  31. package/src/timeline/index.ts +85 -0
  32. package/src/timeline/parsers.ts +253 -0
  33. package/src/{timeline.ts → timeline/projection.ts} +59 -134
  34. package/src/timeline/registry.ts +96 -0
  35. package/src/timeline/screenshot-lightbox.tsx +152 -0
  36. package/src/timeline/shared.tsx +481 -0
  37. package/src/timeline/tool-diff.tsx +91 -0
  38. package/src/timeline/tool-renderers.tsx +882 -0
  39. package/src/timeline/turn-summary.tsx +125 -0
  40. package/src/timeline/types.ts +131 -0
  41. package/src/types/external.d.ts +7 -0
  42. package/styles/index.css +72 -0
@@ -0,0 +1,838 @@
1
+ import {
2
+ ChevronRightIcon,
3
+ FileIcon,
4
+ FilePlusIcon,
5
+ FolderIcon,
6
+ FolderPlusIcon,
7
+ Loader2Icon,
8
+ PencilIcon,
9
+ RefreshCwIcon,
10
+ Trash2Icon,
11
+ } from "lucide-react";
12
+ import {
13
+ type KeyboardEvent as ReactKeyboardEvent,
14
+ type ReactNode,
15
+ useCallback,
16
+ useEffect,
17
+ useMemo,
18
+ useRef,
19
+ useState,
20
+ } from "react";
21
+ import { cn } from "../lib/cn";
22
+ import type { FileTreeNode, UseSandboxFilesResult } from "../hooks/use-sandbox-files";
23
+
24
+ export type FileBrowserProps = {
25
+ /** From `useSandboxFiles(...)`. */
26
+ result: UseSandboxFilesResult;
27
+ /**
28
+ * Rendered instead of the built-in tree when the file surface is unavailable
29
+ * (e.g. a `FileSystem.available === false` capability). Default: a quiet notice.
30
+ */
31
+ fallback?: ReactNode | undefined;
32
+ /** Selection callback for the preview pane. */
33
+ onSelectFile?: ((path: string) => void) | undefined;
34
+ selectedPath?: string | undefined;
35
+ /** Render-prop to theme/replace a row entirely (the Pierre-swap escape hatch). */
36
+ renderNode?: ((node: FileTreeNode, depth: number, expanded: boolean) => ReactNode) | undefined;
37
+ /** Shown when the tree is empty (no files / not loaded yet). */
38
+ emptyState?: ReactNode | undefined;
39
+ /**
40
+ * Enable the file-manager affordances (toolbar, context menu, drag-drop move,
41
+ * inline rename, delete, new file/folder). Defaults to `true` when the hook
42
+ * exposes the mutation methods; pass `false` for a strictly read-only tree.
43
+ */
44
+ editable?: boolean | undefined;
45
+ /**
46
+ * Confirm a (recursive) delete before it runs. Return `false` to cancel.
47
+ * Defaults to `window.confirm`. Pass a no-op returning `true` to skip.
48
+ */
49
+ confirmDelete?: ((node: FileTreeNode) => boolean | Promise<boolean>) | undefined;
50
+ className?: string | undefined;
51
+ };
52
+
53
+ const STATUS_TINT: Record<NonNullable<FileTreeNode["status"]>, string> = {
54
+ added: "text-[color:var(--og-color-status-idle,var(--color-success,#3fb950))]",
55
+ modified: "text-[color:var(--og-color-status-running,var(--color-warning,#d29922))]",
56
+ deleted: "text-[color:var(--og-color-danger,var(--color-danger,#f85149))] line-through",
57
+ renamed: "text-[color:var(--og-color-accent,var(--color-info,#58a6ff))]",
58
+ untracked: "text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]",
59
+ };
60
+
61
+ /** Parent dir of a workspace-relative POSIX path ("" for a root entry). */
62
+ function parentOf(path: string): string {
63
+ const i = path.lastIndexOf("/");
64
+ return i <= 0 ? "" : path.slice(0, i);
65
+ }
66
+
67
+ /** Join a parent dir with a leaf name (handles the root "" parent). */
68
+ function joinPath(parent: string, name: string): string {
69
+ return parent ? `${parent}/${name}` : name;
70
+ }
71
+
72
+ /** Walk the tree to find a node by path. */
73
+ function findNode(nodes: FileTreeNode[], path: string): FileTreeNode | undefined {
74
+ for (const node of nodes) {
75
+ if (node.path === path) return node;
76
+ if (node.kind === "dir" && node.children && path.startsWith(`${node.path}/`)) {
77
+ const hit = findNode(node.children, path);
78
+ if (hit) return hit;
79
+ }
80
+ }
81
+ return undefined;
82
+ }
83
+
84
+ /** A pending inline-create: a phantom input row under `parent`. */
85
+ type DraftCreate = { parent: string; kind: "file" | "dir" };
86
+ /** A pending inline-rename of an existing node. */
87
+ type DraftRename = { path: string };
88
+ type ContextMenuState = { node: FileTreeNode | null; x: number; y: number };
89
+
90
+ /**
91
+ * The file MANAGER, fed by the FileSystem service via `useSandboxFiles`. This is
92
+ * a first-class editable tree (not a render-only view): lazy-expand with a
93
+ * spinner on the in-flight node, git-status tinting, full keyboard navigation,
94
+ * selection, and the mutating affordances wired straight to the hook —
95
+ *
96
+ * • drag-and-drop MOVE → `moveEntry(from, to)`
97
+ * • inline RENAME → `moveEntry(path, newPath)` (F2 / double-click / menu)
98
+ * • DELETE → `deleteEntry(path, recursive)` (Del / menu, confirmed)
99
+ * • NEW FILE / FOLDER → `createFile` / `createDir` (toolbar + menu), then open
100
+ * • right-click CONTEXT MENU
101
+ *
102
+ * `renderNode` is still honoured as the Pierre-swap escape hatch for the row
103
+ * chrome; the manager scaffolding (toolbar, dnd, menu, inline inputs) wraps it.
104
+ */
105
+ export function FileBrowser({
106
+ result,
107
+ fallback,
108
+ onSelectFile,
109
+ selectedPath,
110
+ renderNode,
111
+ emptyState,
112
+ editable = true,
113
+ confirmDelete,
114
+ className,
115
+ }: FileBrowserProps) {
116
+ const [expanded, setExpanded] = useState<Set<string>>(new Set());
117
+ const [loadingPaths, setLoadingPaths] = useState<Set<string>>(new Set());
118
+ const [active, setActive] = useState<string | null>(null);
119
+ const [draftCreate, setDraftCreate] = useState<DraftCreate | null>(null);
120
+ const [draftRename, setDraftRename] = useState<DraftRename | null>(null);
121
+ const [dragOver, setDragOver] = useState<string | null>(null);
122
+ const [menu, setMenu] = useState<ContextMenuState | null>(null);
123
+ const [busy, setBusy] = useState(false);
124
+ const containerRef = useRef<HTMLDivElement | null>(null);
125
+
126
+ // The keyboard cursor: an explicitly-navigated node falls back to the
127
+ // externally-selected file so arrow keys pick up where the preview pane is.
128
+ const cursor = active ?? selectedPath ?? null;
129
+
130
+ const expand = useCallback(
131
+ async (path: string) => {
132
+ const node = findNode(result.tree, path);
133
+ if (node && node.kind === "dir" && node.children === undefined) {
134
+ setLoadingPaths((prev) => new Set(prev).add(path));
135
+ try {
136
+ await result.expand(path);
137
+ } finally {
138
+ setLoadingPaths((prev) => {
139
+ const next = new Set(prev);
140
+ next.delete(path);
141
+ return next;
142
+ });
143
+ }
144
+ }
145
+ },
146
+ [result],
147
+ );
148
+
149
+ const open = useCallback(
150
+ (path: string) => {
151
+ setExpanded((prev) => new Set(prev).add(path));
152
+ void expand(path);
153
+ },
154
+ [expand],
155
+ );
156
+
157
+ const toggle = useCallback(
158
+ async (node: FileTreeNode) => {
159
+ if (node.kind !== "dir") return;
160
+ const isOpen = expanded.has(node.path);
161
+ setExpanded((prev) => {
162
+ const next = new Set(prev);
163
+ if (isOpen) next.delete(node.path);
164
+ else next.add(node.path);
165
+ return next;
166
+ });
167
+ if (!isOpen) await expand(node.path);
168
+ },
169
+ [expanded, expand],
170
+ );
171
+
172
+ // The visible rows in DOM order — drives keyboard up/down navigation.
173
+ const flatRows = useMemo(() => {
174
+ const rows: { node: FileTreeNode; depth: number }[] = [];
175
+ const walk = (nodes: FileTreeNode[], depth: number) => {
176
+ for (const node of nodes) {
177
+ rows.push({ node, depth });
178
+ if (node.kind === "dir" && expanded.has(node.path) && node.children?.length) {
179
+ walk(node.children, depth + 1);
180
+ }
181
+ }
182
+ };
183
+ walk(result.tree, 0);
184
+ return rows;
185
+ }, [result.tree, expanded]);
186
+
187
+ const supportsMutation = editable;
188
+
189
+ const runDelete = useCallback(
190
+ async (node: FileTreeNode) => {
191
+ if (!supportsMutation) return;
192
+ const recursive = node.kind === "dir";
193
+ const confirmFn =
194
+ confirmDelete ??
195
+ ((n: FileTreeNode) =>
196
+ typeof window !== "undefined" && typeof window.confirm === "function"
197
+ ? window.confirm(
198
+ `Delete ${n.kind === "dir" ? "folder" : "file"} "${n.name}"${recursive ? " and its contents" : ""}?`,
199
+ )
200
+ : true);
201
+ const ok = await confirmFn(node);
202
+ if (!ok) return;
203
+ setBusy(true);
204
+ try {
205
+ await result.deleteEntry(node.path, recursive);
206
+ } catch {
207
+ // The hook surfaces the error on `result.error`; the panel renders it.
208
+ } finally {
209
+ setBusy(false);
210
+ }
211
+ },
212
+ [supportsMutation, confirmDelete, result],
213
+ );
214
+
215
+ const commitCreate = useCallback(
216
+ async (name: string) => {
217
+ const draft = draftCreate;
218
+ setDraftCreate(null);
219
+ if (!draft || !supportsMutation) return;
220
+ const trimmed = name.trim().replace(/\/+$/, "");
221
+ if (!trimmed) return;
222
+ const path = joinPath(draft.parent, trimmed);
223
+ setBusy(true);
224
+ try {
225
+ if (draft.kind === "dir") {
226
+ await result.createDir(path);
227
+ open(path);
228
+ } else {
229
+ await result.createFile(path);
230
+ // Open the freshly-created file in the preview/editor pane.
231
+ setActive(path);
232
+ onSelectFile?.(path);
233
+ }
234
+ } catch {
235
+ /* error surfaced via result.error */
236
+ } finally {
237
+ setBusy(false);
238
+ }
239
+ },
240
+ [draftCreate, supportsMutation, result, open, onSelectFile],
241
+ );
242
+
243
+ const commitRename = useCallback(
244
+ async (name: string) => {
245
+ const draft = draftRename;
246
+ setDraftRename(null);
247
+ if (!draft || !supportsMutation) return;
248
+ const node = findNode(result.tree, draft.path);
249
+ const trimmed = name.trim().replace(/\/+$/, "");
250
+ if (!node || !trimmed || trimmed === node.name) return;
251
+ const newPath = joinPath(parentOf(draft.path), trimmed);
252
+ setBusy(true);
253
+ try {
254
+ await result.moveEntry(draft.path, newPath);
255
+ if (node.kind === "file" && (selectedPath === draft.path || active === draft.path)) {
256
+ setActive(newPath);
257
+ onSelectFile?.(newPath);
258
+ }
259
+ } catch {
260
+ /* error surfaced via result.error */
261
+ } finally {
262
+ setBusy(false);
263
+ }
264
+ },
265
+ [draftRename, supportsMutation, result, selectedPath, active, onSelectFile],
266
+ );
267
+
268
+ // Begin a new file/folder under the active node's directory (or root).
269
+ const startCreate = useCallback(
270
+ (kind: "file" | "dir") => {
271
+ if (!supportsMutation) return;
272
+ const anchor = cursor ? findNode(result.tree, cursor) : undefined;
273
+ const parent = anchor ? (anchor.kind === "dir" ? anchor.path : parentOf(anchor.path)) : "";
274
+ if (parent) open(parent);
275
+ setDraftRename(null);
276
+ setMenu(null);
277
+ setDraftCreate({ parent, kind });
278
+ },
279
+ [supportsMutation, cursor, result.tree, open],
280
+ );
281
+
282
+ const startRename = useCallback(
283
+ (node: FileTreeNode) => {
284
+ if (!supportsMutation) return;
285
+ setDraftCreate(null);
286
+ setMenu(null);
287
+ setDraftRename({ path: node.path });
288
+ },
289
+ [supportsMutation],
290
+ );
291
+
292
+ // Drag-drop move: drop a node onto a directory (or the root) → moveEntry.
293
+ const onDropOnto = useCallback(
294
+ async (targetDir: string, sourcePath: string) => {
295
+ setDragOver(null);
296
+ if (!supportsMutation || !sourcePath) return;
297
+ const src = findNode(result.tree, sourcePath);
298
+ if (!src) return;
299
+ // No-op drops: onto its own current parent, onto itself, or into its own subtree.
300
+ if (parentOf(sourcePath) === targetDir) return;
301
+ if (targetDir === sourcePath || targetDir.startsWith(`${sourcePath}/`)) return;
302
+ const newPath = joinPath(targetDir, src.name);
303
+ if (newPath === sourcePath) return;
304
+ setBusy(true);
305
+ try {
306
+ await result.moveEntry(sourcePath, newPath);
307
+ if (src.kind === "file" && (selectedPath === sourcePath || active === sourcePath)) {
308
+ setActive(newPath);
309
+ onSelectFile?.(newPath);
310
+ }
311
+ } catch {
312
+ /* 409/collision etc. surfaced via result.error */
313
+ } finally {
314
+ setBusy(false);
315
+ }
316
+ },
317
+ [supportsMutation, result, selectedPath, active, onSelectFile],
318
+ );
319
+
320
+ const onKeyDown = useCallback(
321
+ (e: ReactKeyboardEvent<HTMLDivElement>) => {
322
+ if (draftCreate || draftRename) return; // inline input owns the keyboard
323
+ if (flatRows.length === 0) return;
324
+ const idx = flatRows.findIndex((r) => r.node.path === cursor);
325
+ const move = (next: number) => {
326
+ const clamped = Math.max(0, Math.min(flatRows.length - 1, next));
327
+ const row = flatRows[clamped];
328
+ if (row) setActive(row.node.path);
329
+ e.preventDefault();
330
+ };
331
+ const cur = idx >= 0 ? flatRows[idx] : undefined;
332
+ switch (e.key) {
333
+ case "ArrowDown":
334
+ move(idx < 0 ? 0 : idx + 1);
335
+ break;
336
+ case "ArrowUp":
337
+ move(idx < 0 ? 0 : idx - 1);
338
+ break;
339
+ case "ArrowRight":
340
+ if (cur?.node.kind === "dir") {
341
+ if (!expanded.has(cur.node.path)) open(cur.node.path);
342
+ else move(idx + 1);
343
+ e.preventDefault();
344
+ }
345
+ break;
346
+ case "ArrowLeft":
347
+ if (cur) {
348
+ if (cur.node.kind === "dir" && expanded.has(cur.node.path)) {
349
+ setExpanded((prev) => {
350
+ const n = new Set(prev);
351
+ n.delete(cur.node.path);
352
+ return n;
353
+ });
354
+ } else {
355
+ const parent = parentOf(cur.node.path);
356
+ if (parent) setActive(parent);
357
+ }
358
+ e.preventDefault();
359
+ }
360
+ break;
361
+ case "Enter":
362
+ if (cur) {
363
+ if (cur.node.kind === "dir") void toggle(cur.node);
364
+ else onSelectFile?.(cur.node.path);
365
+ e.preventDefault();
366
+ }
367
+ break;
368
+ case "F2":
369
+ if (cur) {
370
+ startRename(cur.node);
371
+ e.preventDefault();
372
+ }
373
+ break;
374
+ case "Delete":
375
+ case "Backspace":
376
+ if (cur && supportsMutation) {
377
+ void runDelete(cur.node);
378
+ e.preventDefault();
379
+ }
380
+ break;
381
+ }
382
+ },
383
+ [draftCreate, draftRename, flatRows, cursor, expanded, open, toggle, onSelectFile, startRename, runDelete, supportsMutation],
384
+ );
385
+
386
+ // Close the context menu on any outside interaction.
387
+ useEffect(() => {
388
+ if (!menu) return;
389
+ const close = () => setMenu(null);
390
+ window.addEventListener("click", close);
391
+ window.addEventListener("scroll", close, true);
392
+ window.addEventListener("resize", close);
393
+ return () => {
394
+ window.removeEventListener("click", close);
395
+ window.removeEventListener("scroll", close, true);
396
+ window.removeEventListener("resize", close);
397
+ };
398
+ }, [menu]);
399
+
400
+ if (result.error && result.tree.length === 0) {
401
+ return (
402
+ <div className={cn("p-3 text-xs text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]", className)}>
403
+ {fallback ?? `Files unavailable: ${result.error.message}`}
404
+ </div>
405
+ );
406
+ }
407
+
408
+ const showEmpty = !result.loading && result.tree.length === 0 && !draftCreate;
409
+
410
+ const renderRow = (node: FileTreeNode, depth: number): ReactNode => {
411
+ const isOpen = expanded.has(node.path);
412
+ if (renderNode) {
413
+ return <div key={node.path}>{renderNode(node, depth, isOpen)}</div>;
414
+ }
415
+ const isDir = node.kind === "dir";
416
+ const isSelected = node.path === selectedPath || node.path === active;
417
+ const isCursor = node.path === cursor;
418
+ const isLoading = loadingPaths.has(node.path) || result.expandingPaths.has(node.path);
419
+ const isRenaming = draftRename?.path === node.path;
420
+ const isDropTarget = dragOver === (isDir ? node.path : parentOf(node.path));
421
+ const showSkeleton = isDir && isOpen && isLoading && (node.children === undefined || node.children.length === 0);
422
+ const dropDir = isDir ? node.path : parentOf(node.path);
423
+
424
+ return (
425
+ <div
426
+ key={node.path}
427
+ role="treeitem"
428
+ aria-expanded={isDir ? isOpen : undefined}
429
+ aria-selected={isCursor || undefined}
430
+ aria-busy={isLoading || undefined}
431
+ onDragOver={
432
+ supportsMutation
433
+ ? (e) => {
434
+ e.preventDefault();
435
+ e.dataTransfer.dropEffect = "move";
436
+ setDragOver(dropDir);
437
+ }
438
+ : undefined
439
+ }
440
+ onDragLeave={
441
+ supportsMutation
442
+ ? (e) => {
443
+ e.stopPropagation();
444
+ setDragOver((cur) => (cur === dropDir ? null : cur));
445
+ }
446
+ : undefined
447
+ }
448
+ onDrop={
449
+ supportsMutation
450
+ ? (e) => {
451
+ e.preventDefault();
452
+ e.stopPropagation();
453
+ const src = e.dataTransfer.getData("text/og-path");
454
+ void onDropOnto(dropDir, src);
455
+ }
456
+ : undefined
457
+ }
458
+ >
459
+ {isRenaming ? (
460
+ <InlineInput
461
+ depth={depth}
462
+ kind={node.kind}
463
+ initialValue={node.name}
464
+ onCommit={(name) => void commitRename(name)}
465
+ onCancel={() => setDraftRename(null)}
466
+ />
467
+ ) : (
468
+ <button
469
+ type="button"
470
+ draggable={supportsMutation || undefined}
471
+ onDragStart={
472
+ supportsMutation
473
+ ? (e) => {
474
+ e.dataTransfer.setData("text/og-path", node.path);
475
+ e.dataTransfer.effectAllowed = "move";
476
+ }
477
+ : undefined
478
+ }
479
+ onClick={() => {
480
+ setActive(node.path);
481
+ if (isDir) void toggle(node);
482
+ else onSelectFile?.(node.path);
483
+ }}
484
+ onDoubleClick={() => supportsMutation && startRename(node)}
485
+ onContextMenu={(e) => {
486
+ if (!supportsMutation) return;
487
+ e.preventDefault();
488
+ setActive(node.path);
489
+ setMenu({ node, x: e.clientX, y: e.clientY });
490
+ }}
491
+ className={cn(
492
+ "group flex w-full items-center gap-1 truncate rounded px-1 py-0.5 text-left text-xs",
493
+ "hover:bg-[color:var(--og-color-surface-2,var(--color-bg-subtle,#1c1c1c))]",
494
+ isSelected && "bg-[color:var(--og-color-surface-2,var(--color-bg-subtle,#1c1c1c))]",
495
+ isCursor &&
496
+ "outline outline-1 -outline-offset-1 outline-[color:var(--og-color-accent,var(--color-info,#58a6ff))]",
497
+ isDropTarget &&
498
+ "ring-1 ring-inset ring-[color:var(--og-color-accent,var(--color-info,#58a6ff))] bg-[color:var(--og-color-accent-soft,rgba(88,166,255,0.12))]",
499
+ node.status ? STATUS_TINT[node.status] : undefined,
500
+ )}
501
+ style={{ paddingLeft: `${depth * 12 + 4}px` }}
502
+ >
503
+ {isDir ? (
504
+ isLoading ? (
505
+ <Loader2Icon
506
+ className="size-3 shrink-0 animate-spin text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]"
507
+ aria-label="Loading"
508
+ />
509
+ ) : (
510
+ <ChevronRightIcon className={cn("size-3 shrink-0 transition-transform", isOpen && "rotate-90")} />
511
+ )
512
+ ) : (
513
+ <span className="inline-block w-3 shrink-0" />
514
+ )}
515
+ {isDir ? <FolderIcon className="size-3.5 shrink-0" /> : <FileIcon className="size-3.5 shrink-0" />}
516
+ <span className="truncate">{node.name}</span>
517
+ {supportsMutation && (
518
+ <span
519
+ role="button"
520
+ tabIndex={-1}
521
+ aria-label="More actions"
522
+ onClick={(e) => {
523
+ e.preventDefault();
524
+ e.stopPropagation();
525
+ setActive(node.path);
526
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
527
+ setMenu({ node, x: rect.right, y: rect.bottom });
528
+ }}
529
+ className="ml-auto hidden shrink-0 px-1 text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))] hover:text-[color:var(--og-color-fg,var(--color-fg,#e6e6e6))] group-hover:inline"
530
+ >
531
+
532
+ </span>
533
+ )}
534
+ </button>
535
+ )}
536
+
537
+ {/* Inline create input nested directly under an expanded directory. */}
538
+ {draftCreate?.parent === node.path && isDir && isOpen && (
539
+ <InlineInput
540
+ depth={depth + 1}
541
+ kind={draftCreate.kind}
542
+ initialValue=""
543
+ onCommit={(name) => void commitCreate(name)}
544
+ onCancel={() => setDraftCreate(null)}
545
+ />
546
+ )}
547
+
548
+ {showSkeleton && (
549
+ <div
550
+ role="group"
551
+ aria-hidden
552
+ style={{ paddingLeft: `${(depth + 1) * 12 + 4}px` }}
553
+ className="space-y-1 py-1"
554
+ >
555
+ {[0, 1, 2].map((i) => (
556
+ <div
557
+ key={i}
558
+ className="h-2.5 animate-pulse rounded bg-[color:var(--og-color-surface-2,var(--color-bg-subtle,#1c1c1c))]"
559
+ style={{ width: `${70 - i * 12}%` }}
560
+ />
561
+ ))}
562
+ </div>
563
+ )}
564
+ {isDir && isOpen && node.children && node.children.length > 0 && (
565
+ <div role="group">{node.children.map((child) => renderRow(child, depth + 1))}</div>
566
+ )}
567
+ </div>
568
+ );
569
+ };
570
+
571
+ return (
572
+ <div className={cn("flex min-w-0 flex-col", className)}>
573
+ {supportsMutation && (
574
+ <div className="flex shrink-0 items-center gap-0.5 border-b border-[color:var(--og-color-border,var(--color-border,#2a2a2a))] px-1 py-1">
575
+ <ToolbarButton label="New file" onClick={() => startCreate("file")} disabled={busy}>
576
+ <FilePlusIcon className="size-3.5" />
577
+ </ToolbarButton>
578
+ <ToolbarButton label="New folder" onClick={() => startCreate("dir")} disabled={busy}>
579
+ <FolderPlusIcon className="size-3.5" />
580
+ </ToolbarButton>
581
+ <ToolbarButton
582
+ label="Rename"
583
+ onClick={() => {
584
+ const node = cursor ? findNode(result.tree, cursor) : undefined;
585
+ if (node) startRename(node);
586
+ }}
587
+ disabled={busy || !cursor}
588
+ >
589
+ <PencilIcon className="size-3.5" />
590
+ </ToolbarButton>
591
+ <ToolbarButton
592
+ label="Delete"
593
+ onClick={() => {
594
+ const node = cursor ? findNode(result.tree, cursor) : undefined;
595
+ if (node) void runDelete(node);
596
+ }}
597
+ disabled={busy || !cursor}
598
+ >
599
+ <Trash2Icon className="size-3.5" />
600
+ </ToolbarButton>
601
+ <span className="ml-auto" />
602
+ <ToolbarButton label="Refresh" onClick={() => void result.refresh()} disabled={busy || result.loading}>
603
+ <RefreshCwIcon className={cn("size-3.5", result.loading && "animate-spin")} />
604
+ </ToolbarButton>
605
+ </div>
606
+ )}
607
+
608
+ {/* The tree itself. The root is a drop target so a node can be moved to "". */}
609
+ {/* biome-ignore lint/a11y/noNoninteractiveTabindex: the tree owns keyboard nav */}
610
+ <div
611
+ ref={containerRef}
612
+ role="tree"
613
+ tabIndex={0}
614
+ aria-multiselectable={false}
615
+ onKeyDown={onKeyDown}
616
+ onDragOver={
617
+ supportsMutation
618
+ ? (e) => {
619
+ e.preventDefault();
620
+ e.dataTransfer.dropEffect = "move";
621
+ setDragOver("");
622
+ }
623
+ : undefined
624
+ }
625
+ onDrop={
626
+ supportsMutation
627
+ ? (e) => {
628
+ e.preventDefault();
629
+ const src = e.dataTransfer.getData("text/og-path");
630
+ void onDropOnto("", src);
631
+ }
632
+ : undefined
633
+ }
634
+ className={cn(
635
+ "min-w-0 flex-1 overflow-auto p-1 outline-none",
636
+ dragOver === "" && "ring-1 ring-inset ring-[color:var(--og-color-accent,var(--color-info,#58a6ff))]",
637
+ )}
638
+ data-opengeni-file-tree
639
+ >
640
+ {/* A root-level create input sits above the rows. */}
641
+ {draftCreate?.parent === "" && (
642
+ <InlineInput
643
+ depth={0}
644
+ kind={draftCreate.kind}
645
+ initialValue=""
646
+ onCommit={(name) => void commitCreate(name)}
647
+ onCancel={() => setDraftCreate(null)}
648
+ />
649
+ )}
650
+ {showEmpty ? (
651
+ <div className="p-2 text-xs text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]">
652
+ {emptyState ?? "No files."}
653
+ </div>
654
+ ) : (
655
+ result.tree.map((node) => renderRow(node, 0))
656
+ )}
657
+ </div>
658
+
659
+ {menu && menu.node && (
660
+ <ContextMenu
661
+ node={menu.node}
662
+ x={menu.x}
663
+ y={menu.y}
664
+ onRename={() => menu.node && startRename(menu.node)}
665
+ onDelete={() => menu.node && void runDelete(menu.node)}
666
+ onNewFile={() => startCreate("file")}
667
+ onNewFolder={() => startCreate("dir")}
668
+ />
669
+ )}
670
+ </div>
671
+ );
672
+ }
673
+
674
+ function ToolbarButton({
675
+ label,
676
+ onClick,
677
+ disabled,
678
+ children,
679
+ }: {
680
+ label: string;
681
+ onClick: () => void;
682
+ disabled?: boolean;
683
+ children: ReactNode;
684
+ }) {
685
+ return (
686
+ <button
687
+ type="button"
688
+ title={label}
689
+ aria-label={label}
690
+ onClick={onClick}
691
+ disabled={disabled}
692
+ className={cn(
693
+ "inline-flex items-center justify-center rounded p-1 text-[color:var(--og-color-fg-muted,var(--color-fg-muted,#aaa))]",
694
+ "hover:bg-[color:var(--og-color-surface-2,var(--color-bg-subtle,#1c1c1c))] hover:text-[color:var(--og-color-fg,var(--color-fg,#e6e6e6))]",
695
+ "disabled:cursor-not-allowed disabled:opacity-40",
696
+ )}
697
+ >
698
+ {children}
699
+ </button>
700
+ );
701
+ }
702
+
703
+ /** An inline text input for create / rename, indented to match its row depth. */
704
+ function InlineInput({
705
+ depth,
706
+ kind,
707
+ initialValue,
708
+ onCommit,
709
+ onCancel,
710
+ }: {
711
+ depth: number;
712
+ kind: "file" | "dir";
713
+ initialValue: string;
714
+ onCommit: (value: string) => void;
715
+ onCancel: () => void;
716
+ }) {
717
+ const [value, setValue] = useState(initialValue);
718
+ const ref = useRef<HTMLInputElement | null>(null);
719
+ // Track whether we've already resolved so a blur after Enter/Escape is a no-op.
720
+ const doneRef = useRef(false);
721
+
722
+ useEffect(() => {
723
+ const el = ref.current;
724
+ if (!el) return;
725
+ el.focus();
726
+ // Select the basename (excluding any extension) for a rename, like an IDE.
727
+ const dot = initialValue.lastIndexOf(".");
728
+ if (initialValue && dot > 0) el.setSelectionRange(0, dot);
729
+ else el.select();
730
+ }, [initialValue]);
731
+
732
+ const finish = (commit: boolean) => {
733
+ if (doneRef.current) return;
734
+ doneRef.current = true;
735
+ if (commit) onCommit(value);
736
+ else onCancel();
737
+ };
738
+
739
+ return (
740
+ <div className="flex items-center gap-1 px-1 py-0.5" style={{ paddingLeft: `${depth * 12 + 4}px` }}>
741
+ <span className="inline-block w-3 shrink-0" />
742
+ {kind === "dir" ? <FolderIcon className="size-3.5 shrink-0" /> : <FileIcon className="size-3.5 shrink-0" />}
743
+ <input
744
+ ref={ref}
745
+ value={value}
746
+ spellCheck={false}
747
+ autoComplete="off"
748
+ aria-label={kind === "dir" ? "Folder name" : "File name"}
749
+ onChange={(e) => setValue(e.target.value)}
750
+ onBlur={() => finish(true)}
751
+ onKeyDown={(e) => {
752
+ if (e.key === "Enter") {
753
+ e.preventDefault();
754
+ finish(true);
755
+ } else if (e.key === "Escape") {
756
+ e.preventDefault();
757
+ finish(false);
758
+ }
759
+ e.stopPropagation();
760
+ }}
761
+ className={cn(
762
+ "min-w-0 flex-1 rounded border bg-[color:var(--og-color-bg,var(--color-bg,#0d0d0d))] px-1 py-0 text-xs",
763
+ "border-[color:var(--og-color-accent,var(--color-info,#58a6ff))] text-[color:var(--og-color-fg,var(--color-fg,#e6e6e6))]",
764
+ "outline-none",
765
+ )}
766
+ />
767
+ </div>
768
+ );
769
+ }
770
+
771
+ /** The right-click / kebab context menu, positioned at the click point. */
772
+ function ContextMenu({
773
+ node,
774
+ x,
775
+ y,
776
+ onRename,
777
+ onDelete,
778
+ onNewFile,
779
+ onNewFolder,
780
+ }: {
781
+ node: FileTreeNode;
782
+ x: number;
783
+ y: number;
784
+ onRename: () => void;
785
+ onDelete: () => void;
786
+ onNewFile: () => void;
787
+ onNewFolder: () => void;
788
+ }) {
789
+ const isDir = node.kind === "dir";
790
+ // Keep the menu on-screen when opened near the viewport edge.
791
+ const vw = typeof window !== "undefined" ? window.innerWidth : 9999;
792
+ const vh = typeof window !== "undefined" ? window.innerHeight : 9999;
793
+ const left = Math.min(x, vw - 180);
794
+ const top = Math.min(y, vh - 160);
795
+
796
+ const item = (label: string, icon: ReactNode, onClick: () => void, danger?: boolean) => (
797
+ <button
798
+ type="button"
799
+ onClick={(e) => {
800
+ e.stopPropagation();
801
+ onClick();
802
+ }}
803
+ className={cn(
804
+ "flex w-full items-center gap-2 px-2.5 py-1 text-left text-xs",
805
+ "hover:bg-[color:var(--og-color-surface-2,var(--color-bg-subtle,#1c1c1c))]",
806
+ danger
807
+ ? "text-[color:var(--og-color-danger,var(--color-danger,#f85149))]"
808
+ : "text-[color:var(--og-color-fg,var(--color-fg,#e6e6e6))]",
809
+ )}
810
+ >
811
+ {icon}
812
+ {label}
813
+ </button>
814
+ );
815
+
816
+ return (
817
+ <div
818
+ role="menu"
819
+ onClick={(e) => e.stopPropagation()}
820
+ style={{ left, top }}
821
+ className={cn(
822
+ "fixed z-50 min-w-[160px] overflow-hidden rounded-md border py-1 shadow-lg",
823
+ "border-[color:var(--og-color-border,var(--color-border,#2a2a2a))]",
824
+ "bg-[color:var(--og-color-surface-1,var(--color-surface,#161616))]",
825
+ )}
826
+ >
827
+ {isDir && (
828
+ <>
829
+ {item("New file", <FilePlusIcon className="size-3.5" />, onNewFile)}
830
+ {item("New folder", <FolderPlusIcon className="size-3.5" />, onNewFolder)}
831
+ <div className="my-1 h-px bg-[color:var(--og-color-border,var(--color-border,#2a2a2a))]" />
832
+ </>
833
+ )}
834
+ {item("Rename", <PencilIcon className="size-3.5" />, onRename)}
835
+ {item("Delete", <Trash2Icon className="size-3.5" />, onDelete, true)}
836
+ </div>
837
+ );
838
+ }