@opengeni/react 0.3.0 → 0.4.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/dist/index.d.ts +1035 -14
- package/dist/index.js +6867 -1884
- package/dist/index.js.map +1 -1
- package/package.json +65 -2
- package/src/client.ts +21 -0
- package/src/components/code-editor.tsx +398 -0
- package/src/components/desktop-viewer.tsx +647 -0
- package/src/components/diff-view.tsx +230 -0
- package/src/components/file-browser.tsx +838 -0
- package/src/components/message-timeline.tsx +70 -196
- package/src/components/pierre-diff.tsx +140 -0
- package/src/components/pierre-file.tsx +142 -0
- package/src/components/sandbox-files.tsx +509 -0
- package/src/components/sandbox-terminal.tsx +425 -0
- package/src/components/workspace-dock.tsx +247 -0
- package/src/hooks/use-desktop-stream.ts +214 -0
- package/src/hooks/use-sandbox-files.ts +670 -0
- package/src/hooks/use-sandbox-git.ts +105 -0
- package/src/hooks/use-sandbox-terminal.ts +226 -0
- package/src/hooks/use-session-capabilities.ts +415 -0
- package/src/hooks/use-terminal-stream.ts +207 -0
- package/src/index.ts +111 -2
- package/src/lib/cn.ts +20 -1
- package/src/lib/git-patch.ts +37 -0
- package/src/lib/use-theme-type.ts +40 -0
- package/src/lib/xterm-theme.ts +34 -0
- package/src/timeline/activity-rail.tsx +207 -0
- package/src/timeline/disclosure-context.tsx +34 -0
- package/src/timeline/index.ts +85 -0
- package/src/timeline/parsers.ts +248 -0
- package/src/{timeline.ts → timeline/projection.ts} +59 -134
- package/src/timeline/registry.ts +96 -0
- package/src/timeline/screenshot-lightbox.tsx +152 -0
- package/src/timeline/shared.tsx +481 -0
- package/src/timeline/tool-diff.tsx +91 -0
- package/src/timeline/tool-renderers.tsx +882 -0
- package/src/timeline/turn-summary.tsx +125 -0
- package/src/timeline/types.ts +131 -0
- package/src/types/external.d.ts +7 -0
- 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
|
+
}
|