@opengeni/react 0.3.1 → 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,670 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
FsChangedPayload,
|
|
3
|
+
FsReadResponse,
|
|
4
|
+
FsTreeNode,
|
|
5
|
+
FsWriteResponse,
|
|
6
|
+
GitChangedPayload,
|
|
7
|
+
GitFileStatusCode,
|
|
8
|
+
SessionEvent,
|
|
9
|
+
} from "@opengeni/sdk";
|
|
10
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
11
|
+
import { useOpenGeni, type ClientOverride } from "../provider";
|
|
12
|
+
|
|
13
|
+
/** The git-status overlay a file row may carry (tints modified files in the tree). */
|
|
14
|
+
export type FileTreeStatus = "added" | "modified" | "deleted" | "renamed" | "untracked";
|
|
15
|
+
|
|
16
|
+
/** A node in the Pierre file tree. `children === undefined` ⇒ an unexpanded dir
|
|
17
|
+
* (lazy treeMode); `children: []` ⇒ an expanded-but-empty dir. */
|
|
18
|
+
export type FileTreeNode = {
|
|
19
|
+
path: string; // workspace-relative POSIX
|
|
20
|
+
name: string;
|
|
21
|
+
kind: "file" | "dir";
|
|
22
|
+
children?: FileTreeNode[] | undefined;
|
|
23
|
+
size?: number | null | undefined;
|
|
24
|
+
status?: FileTreeStatus | undefined;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type UseSandboxFilesOptions = ClientOverride & {
|
|
28
|
+
/** Live event log (usually `useSessionEvents().events`) — drives auto-refresh
|
|
29
|
+
* on `fs.changed` / `git.changed`. */
|
|
30
|
+
events?: SessionEvent[] | undefined;
|
|
31
|
+
/** Initial path to list (workspace root by default). */
|
|
32
|
+
rootPath?: string | undefined;
|
|
33
|
+
/** Hold off the initial list (e.g. panel collapsed). Default true. */
|
|
34
|
+
enabled?: boolean | undefined;
|
|
35
|
+
/** The lease liveness ("cold" | "warm" | "draining"). The structured FileSystem
|
|
36
|
+
* capability is advertised even on a COLD box, so the mount-time list can race
|
|
37
|
+
* the box: it lists before the box is warm, gets an empty/errored result, and
|
|
38
|
+
* (with no `fs.changed` event) never re-lists. Passing liveness re-lists when
|
|
39
|
+
* the box first becomes warm, so the tree populates as soon as the box is up. */
|
|
40
|
+
liveness?: string | undefined;
|
|
41
|
+
/** Called when an OPTIMISTIC mutation is reverted because its background
|
|
42
|
+
* Channel-A op failed (e.g. a 409 rename collision). The host wires this to a
|
|
43
|
+
* toast — the tree silently rolls the node back, the user sees why. */
|
|
44
|
+
onMutationError?: ((error: Error, op: string) => void) | undefined;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type UseSandboxFilesResult = {
|
|
48
|
+
/** The tree roots (the listed root's children). */
|
|
49
|
+
tree: FileTreeNode[];
|
|
50
|
+
/** Lazy-expand a directory node in place (lists its immediate children). */
|
|
51
|
+
expand: (path: string) => Promise<void>;
|
|
52
|
+
/** Paths whose lazy `fs.list` is currently in flight — the FileBrowser shows a
|
|
53
|
+
* spinner on these nodes so a 2-3s Channel-A list never looks frozen. */
|
|
54
|
+
expandingPaths: Set<string>;
|
|
55
|
+
/** Read a file for the preview pane (text or base64-for-binary, size-capped). */
|
|
56
|
+
readFile: (path: string) => Promise<FsReadResponse>;
|
|
57
|
+
/** Write a file (overwrite, last-writer-wins) — the editor save path.
|
|
58
|
+
* Optimistic: a brand-new file is spliced into the tree immediately and the
|
|
59
|
+
* Channel-A write runs in the background; on failure the splice is reverted. */
|
|
60
|
+
writeFile: (path: string, content: string) => Promise<FsWriteResponse>;
|
|
61
|
+
/** Create a new empty file (refuses to clobber an existing path: overwrite=false). */
|
|
62
|
+
createFile: (path: string) => Promise<void>;
|
|
63
|
+
/** Create a directory (recursive by default). */
|
|
64
|
+
createDir: (path: string) => Promise<void>;
|
|
65
|
+
/** Delete a path (pass recursive=true for a non-empty directory). */
|
|
66
|
+
deleteEntry: (path: string, recursive?: boolean) => Promise<void>;
|
|
67
|
+
/** Move / rename a path (rename == move). Refuses to clobber unless overwrite=true. */
|
|
68
|
+
moveEntry: (path: string, newPath: string, opts?: { overwrite?: boolean }) => Promise<void>;
|
|
69
|
+
/** Re-list the whole tree from the root. */
|
|
70
|
+
refresh: () => Promise<void>;
|
|
71
|
+
loading: boolean;
|
|
72
|
+
error: Error | null;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** The workspace-relative parent directory of a POSIX path ("" for a root entry). */
|
|
76
|
+
function parentOf(path: string): string {
|
|
77
|
+
const i = path.lastIndexOf("/");
|
|
78
|
+
return i <= 0 ? "" : path.slice(0, i);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The leaf name of a POSIX path. */
|
|
82
|
+
function leafOf(path: string): string {
|
|
83
|
+
const i = path.lastIndexOf("/");
|
|
84
|
+
return i < 0 ? path : path.slice(i + 1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Join a parent dir with a leaf name (handles the root "" parent). */
|
|
88
|
+
function joinPath(parent: string, name: string): string {
|
|
89
|
+
return parent ? `${parent}/${name}` : name;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Stable sibling ordering: dirs before files, then case-insensitive by name —
|
|
93
|
+
* matches a typical depth-1 list so an optimistic insert lands where a real
|
|
94
|
+
* re-list would put it (no jump when the server reconciles). */
|
|
95
|
+
function sortNodes(nodes: FileTreeNode[]): FileTreeNode[] {
|
|
96
|
+
return [...nodes].sort((a, b) => {
|
|
97
|
+
if (a.kind !== b.kind) return a.kind === "dir" ? -1 : 1;
|
|
98
|
+
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function fsNodeToTree(node: FsTreeNode): FileTreeNode {
|
|
103
|
+
const kind = node.type === "dir" ? "dir" : "file";
|
|
104
|
+
// Lazy-tree contract: a depth-bounded `fsList` returns each directory at the
|
|
105
|
+
// depth boundary with `children: []` (the dir is listed, but its grandchildren
|
|
106
|
+
// are NOT). An empty array therefore means "not yet expanded", NOT "empty
|
|
107
|
+
// directory" — so we must map it to `undefined` (the unexpanded marker the
|
|
108
|
+
// FileBrowser keys lazy-expand on). If we kept `[]`, `toggle()`'s
|
|
109
|
+
// `node.children === undefined` guard would never fire and clicking a folder
|
|
110
|
+
// would do nothing (the reported bug). A directory we actually expand has its
|
|
111
|
+
// children spliced in by `replaceChildren` (bypassing this mapper), so a
|
|
112
|
+
// genuinely-empty dir correctly ends up as `[]` AFTER expansion.
|
|
113
|
+
const mappedChildren =
|
|
114
|
+
node.children && node.children.length > 0 ? node.children.map(fsNodeToTree) : undefined;
|
|
115
|
+
return {
|
|
116
|
+
path: node.path,
|
|
117
|
+
name: node.name,
|
|
118
|
+
kind,
|
|
119
|
+
size: node.sizeBytes,
|
|
120
|
+
...(kind === "dir" ? { children: mappedChildren } : {}),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const GIT_STATUS_TO_TREE: Partial<Record<GitFileStatusCode, FileTreeStatus>> = {
|
|
125
|
+
added: "added",
|
|
126
|
+
modified: "modified",
|
|
127
|
+
deleted: "deleted",
|
|
128
|
+
renamed: "renamed",
|
|
129
|
+
copied: "added",
|
|
130
|
+
untracked: "untracked",
|
|
131
|
+
typechange: "modified",
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** Replace the children of `targetPath` within the tree (immutably). */
|
|
135
|
+
function replaceChildren(nodes: FileTreeNode[], targetPath: string, children: FileTreeNode[]): FileTreeNode[] {
|
|
136
|
+
return nodes.map((node) => {
|
|
137
|
+
if (node.path === targetPath) {
|
|
138
|
+
return { ...node, children };
|
|
139
|
+
}
|
|
140
|
+
if (node.kind === "dir" && node.children && targetPath.startsWith(`${node.path}/`)) {
|
|
141
|
+
return { ...node, children: replaceChildren(node.children, targetPath, children) };
|
|
142
|
+
}
|
|
143
|
+
return node;
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Find a node by exact path (depth-first). */
|
|
148
|
+
function findNodeByPath(nodes: FileTreeNode[], path: string): FileTreeNode | undefined {
|
|
149
|
+
for (const node of nodes) {
|
|
150
|
+
if (node.path === path) return node;
|
|
151
|
+
if (node.kind === "dir" && node.children && path.startsWith(`${node.path}/`)) {
|
|
152
|
+
const hit = findNodeByPath(node.children, path);
|
|
153
|
+
if (hit) return hit;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── In-place (optimistic) tree mutations ────────────────────────────────────
|
|
160
|
+
// The Pierre mutation-handle analogue. These splice a single node in/out/across
|
|
161
|
+
// the immutable tree, PRESERVING every other node's `children` (and therefore the
|
|
162
|
+
// FileBrowser's expansion + selection). The old data flow re-listed the whole
|
|
163
|
+
// root and `setTree(rootChildren)` — that dropped every expanded dir back to the
|
|
164
|
+
// unexpanded marker, which is exactly the "everything refreshes / collapses to
|
|
165
|
+
// .config" the user saw. Mutating in place is the fix.
|
|
166
|
+
//
|
|
167
|
+
// `parent === ""` targets the root list directly. A parent that isn't present in
|
|
168
|
+
// the (lazily-loaded) tree means it's collapsed/unloaded — the helpers return the
|
|
169
|
+
// tree UNCHANGED in that case (caller treats it as "nothing visible to update",
|
|
170
|
+
// which is correct: a collapsed dir re-lists fresh when the user expands it).
|
|
171
|
+
|
|
172
|
+
/** True when `parent` is the root ("") or a dir node that is actually present in
|
|
173
|
+
* the loaded tree (so an insert/remove there would be visible). */
|
|
174
|
+
function parentIsLoaded(nodes: FileTreeNode[], parent: string): boolean {
|
|
175
|
+
if (parent === "") return true;
|
|
176
|
+
const node = findNodeByPath(nodes, parent);
|
|
177
|
+
return Boolean(node && node.kind === "dir" && node.children !== undefined);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Insert `child` under `parent` (immutably), keeping siblings sorted. A no-op
|
|
181
|
+
* (returns the same array) when the parent isn't a loaded dir or the child
|
|
182
|
+
* already exists. */
|
|
183
|
+
function insertNode(nodes: FileTreeNode[], parent: string, child: FileTreeNode): FileTreeNode[] {
|
|
184
|
+
if (parent === "") {
|
|
185
|
+
if (nodes.some((n) => n.path === child.path)) return nodes;
|
|
186
|
+
return sortNodes([...nodes, child]);
|
|
187
|
+
}
|
|
188
|
+
return nodes.map((node) => {
|
|
189
|
+
if (node.path === parent) {
|
|
190
|
+
if (node.kind !== "dir" || node.children === undefined) return node;
|
|
191
|
+
if (node.children.some((n) => n.path === child.path)) return node;
|
|
192
|
+
return { ...node, children: sortNodes([...node.children, child]) };
|
|
193
|
+
}
|
|
194
|
+
if (node.kind === "dir" && node.children && parent.startsWith(`${node.path}/`)) {
|
|
195
|
+
return { ...node, children: insertNode(node.children, parent, child) };
|
|
196
|
+
}
|
|
197
|
+
return node;
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Remove the node at `path` (immutably). No-op when it isn't in the tree. */
|
|
202
|
+
function removeNode(nodes: FileTreeNode[], path: string): FileTreeNode[] {
|
|
203
|
+
const parent = parentOf(path);
|
|
204
|
+
if (parent === "") {
|
|
205
|
+
if (!nodes.some((n) => n.path === path)) return nodes;
|
|
206
|
+
return nodes.filter((n) => n.path !== path);
|
|
207
|
+
}
|
|
208
|
+
return nodes.map((node) => {
|
|
209
|
+
if (node.path === parent) {
|
|
210
|
+
if (node.kind !== "dir" || node.children === undefined) return node;
|
|
211
|
+
return { ...node, children: node.children.filter((n) => n.path !== path) };
|
|
212
|
+
}
|
|
213
|
+
if (node.kind === "dir" && node.children && parent.startsWith(`${node.path}/`)) {
|
|
214
|
+
return { ...node, children: removeNode(node.children, path) };
|
|
215
|
+
}
|
|
216
|
+
return node;
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Reconcile a freshly-listed depth-1 set of children against the CURRENT nodes
|
|
221
|
+
* at the same level, PRESERVING expansion: an existing dir keeps its already-
|
|
222
|
+
* loaded `children` (so its expanded subtree survives), new entries are added,
|
|
223
|
+
* and entries the server no longer returns are dropped. This is the in-place
|
|
224
|
+
* merge a root ("") reconcile needs — a blind replace would collapse every
|
|
225
|
+
* expanded top-level dir back to the unexpanded marker (the reported bug). */
|
|
226
|
+
function mergeChildren(current: FileTreeNode[], listed: FileTreeNode[]): FileTreeNode[] {
|
|
227
|
+
const byPath = new Map(current.map((n) => [n.path, n] as const));
|
|
228
|
+
const merged = listed.map((next) => {
|
|
229
|
+
const existing = byPath.get(next.path);
|
|
230
|
+
// Keep an already-expanded dir's loaded children; otherwise take the listing's
|
|
231
|
+
// marker (undefined = unexpanded). Carry forward size/status from the listing.
|
|
232
|
+
if (existing && existing.kind === "dir" && next.kind === "dir" && existing.children !== undefined) {
|
|
233
|
+
return { ...next, children: existing.children };
|
|
234
|
+
}
|
|
235
|
+
return next;
|
|
236
|
+
});
|
|
237
|
+
return sortNodes(merged);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Root-level ("") reconcile: merge a fresh depth-1 listing into the root list
|
|
241
|
+
* without collapsing expanded top-level dirs. */
|
|
242
|
+
function mergeRootChildren(nodes: FileTreeNode[], listed: FileTreeNode[]): FileTreeNode[] {
|
|
243
|
+
return mergeChildren(nodes, listed);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Re-path a subtree rooted at `node` from `fromPrefix` to `toPrefix` (so a moved
|
|
247
|
+
* dir's descendants keep correct paths without a re-list). */
|
|
248
|
+
function repathNode(node: FileTreeNode, fromPrefix: string, toPrefix: string): FileTreeNode {
|
|
249
|
+
const newPath = toPrefix + node.path.slice(fromPrefix.length);
|
|
250
|
+
const next: FileTreeNode = { ...node, path: newPath, name: leafOf(newPath) };
|
|
251
|
+
if (node.children) next.children = node.children.map((c) => repathNode(c, fromPrefix, toPrefix));
|
|
252
|
+
return next;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Project the FileSystem service into a lazy-loaded Pierre tree. The initial
|
|
257
|
+
* list pulls one level (depth 1); `expand(path)` lists a directory's immediate
|
|
258
|
+
* children on demand (the fast lazy-tree UX). A git-status overlay tints
|
|
259
|
+
* modified files. Auto-refreshes when an `fs.changed` / `git.changed` event
|
|
260
|
+
* arrives on the live log.
|
|
261
|
+
*/
|
|
262
|
+
export function useSandboxFiles(
|
|
263
|
+
sessionId: string | null | undefined,
|
|
264
|
+
options: UseSandboxFilesOptions = {},
|
|
265
|
+
): UseSandboxFilesResult {
|
|
266
|
+
const { client, workspaceId } = useOpenGeni(options);
|
|
267
|
+
const enabled = (options.enabled ?? true) && Boolean(sessionId);
|
|
268
|
+
const rootPath = options.rootPath ?? "";
|
|
269
|
+
|
|
270
|
+
const [tree, setTree] = useState<FileTreeNode[]>([]);
|
|
271
|
+
// A ref mirror of the current tree — lets the optimistic path snapshot the
|
|
272
|
+
// pre-op tree WITHOUT relying on a `setTree` updater (which StrictMode invokes
|
|
273
|
+
// twice, corrupting an in-closure snapshot). Kept in sync on every set below
|
|
274
|
+
// and in a layout effect for any path that sets `tree` directly.
|
|
275
|
+
const treeRef = useRef<FileTreeNode[]>([]);
|
|
276
|
+
const [loading, setLoading] = useState(false);
|
|
277
|
+
const [expandingPaths, setExpandingPaths] = useState<Set<string>>(new Set());
|
|
278
|
+
const [error, setError] = useState<Error | null>(null);
|
|
279
|
+
const statusRef = useRef<Map<string, FileTreeStatus>>(new Map());
|
|
280
|
+
|
|
281
|
+
// ── Self-emitted fs.changed de-dupe ───────────────────────────────────────
|
|
282
|
+
// Every one of OUR mutations emits an `fs.changed` event back on the live log
|
|
283
|
+
// (source:"write"). The old flow auto-refreshed on EVERY fs.changed, so each
|
|
284
|
+
// edit self-triggered a full root collapse-reload (~5s, lost expansion). We now
|
|
285
|
+
// (a) ignore fs.changed whose `source === "write"` (our own control-plane ops),
|
|
286
|
+
// and (b) belt-and-braces, track the revisions WE caused so even a watch-sourced
|
|
287
|
+
// echo of our own write is suppressed. Only EXTERNAL changes (the agent writing,
|
|
288
|
+
// source:"agent"/"watch" we didn't cause) drive a targeted reconcile.
|
|
289
|
+
const ownRevisionsRef = useRef<Set<number>>(new Set());
|
|
290
|
+
const onMutationError = options.onMutationError;
|
|
291
|
+
|
|
292
|
+
// Keep the ref mirror current for the optimistic snapshot path.
|
|
293
|
+
treeRef.current = tree;
|
|
294
|
+
|
|
295
|
+
const applyStatus = useCallback((nodes: FileTreeNode[]): FileTreeNode[] => {
|
|
296
|
+
const overlay = statusRef.current;
|
|
297
|
+
if (overlay.size === 0) return nodes;
|
|
298
|
+
const walk = (list: FileTreeNode[]): FileTreeNode[] =>
|
|
299
|
+
list.map((node) => {
|
|
300
|
+
const status = overlay.get(node.path);
|
|
301
|
+
const next = node.children ? { ...node, children: walk(node.children) } : { ...node };
|
|
302
|
+
if (status && node.kind === "file") next.status = status;
|
|
303
|
+
else delete next.status;
|
|
304
|
+
return next;
|
|
305
|
+
});
|
|
306
|
+
return walk(nodes);
|
|
307
|
+
}, []);
|
|
308
|
+
|
|
309
|
+
const refresh = useCallback(async () => {
|
|
310
|
+
if (!sessionId) return;
|
|
311
|
+
setLoading(true);
|
|
312
|
+
setError(null);
|
|
313
|
+
try {
|
|
314
|
+
// Pull the git-status overlay first (best-effort — a non-repo box just
|
|
315
|
+
// returns isRepo:false), then the tree, so the first paint is tinted.
|
|
316
|
+
try {
|
|
317
|
+
const status = await client.gitStatus(workspaceId, sessionId, { path: rootPath });
|
|
318
|
+
const overlay = new Map<string, FileTreeStatus>();
|
|
319
|
+
for (const file of status.files) {
|
|
320
|
+
const code = file.worktree ?? file.index;
|
|
321
|
+
const mapped = code ? GIT_STATUS_TO_TREE[code] : undefined;
|
|
322
|
+
if (mapped) overlay.set(file.path, mapped);
|
|
323
|
+
}
|
|
324
|
+
statusRef.current = overlay;
|
|
325
|
+
} catch {
|
|
326
|
+
statusRef.current = new Map();
|
|
327
|
+
}
|
|
328
|
+
const listed = await client.fsList(workspaceId, sessionId, { path: rootPath, depth: 1 });
|
|
329
|
+
const children = (listed.root.children ?? []).map(fsNodeToTree);
|
|
330
|
+
// Merge rather than replace so an explicit refresh / cold→warm re-list folds
|
|
331
|
+
// in new entries WITHOUT collapsing the dirs the user already expanded. On a
|
|
332
|
+
// first (empty) load this is a plain set.
|
|
333
|
+
setTree((prev) => applyStatus(prev.length === 0 ? children : mergeRootChildren(prev, children)));
|
|
334
|
+
} catch (cause) {
|
|
335
|
+
setError(cause instanceof Error ? cause : new Error(String(cause)));
|
|
336
|
+
} finally {
|
|
337
|
+
setLoading(false);
|
|
338
|
+
}
|
|
339
|
+
}, [client, workspaceId, sessionId, rootPath, applyStatus]);
|
|
340
|
+
|
|
341
|
+
const expand = useCallback(
|
|
342
|
+
async (path: string) => {
|
|
343
|
+
if (!sessionId) return;
|
|
344
|
+
// Mark this node as expanding so the FileBrowser can render a spinner while
|
|
345
|
+
// the (often 2-3s) Channel-A fs/list is in flight — the tree never looks
|
|
346
|
+
// frozen on a click.
|
|
347
|
+
setExpandingPaths((prev) => {
|
|
348
|
+
const next = new Set(prev);
|
|
349
|
+
next.add(path);
|
|
350
|
+
return next;
|
|
351
|
+
});
|
|
352
|
+
try {
|
|
353
|
+
const listed = await client.fsList(workspaceId, sessionId, { path, depth: 1 });
|
|
354
|
+
const children = (listed.root.children ?? []).map(fsNodeToTree);
|
|
355
|
+
setTree((prev) => applyStatus(replaceChildren(prev, path, children)));
|
|
356
|
+
} catch (cause) {
|
|
357
|
+
setError(cause instanceof Error ? cause : new Error(String(cause)));
|
|
358
|
+
} finally {
|
|
359
|
+
setExpandingPaths((prev) => {
|
|
360
|
+
if (!prev.has(path)) return prev;
|
|
361
|
+
const next = new Set(prev);
|
|
362
|
+
next.delete(path);
|
|
363
|
+
return next;
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
[client, workspaceId, sessionId, applyStatus],
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
const readFile = useCallback(
|
|
371
|
+
async (path: string) => {
|
|
372
|
+
if (!sessionId) throw new Error("no session");
|
|
373
|
+
return await client.fsRead(workspaceId, sessionId, { path });
|
|
374
|
+
},
|
|
375
|
+
[client, workspaceId, sessionId],
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
// TARGETED reconcile of a single directory — re-list ONE parent at depth 1 and
|
|
379
|
+
// splice its children in place via `replaceChildren`, preserving the rest of the
|
|
380
|
+
// tree's expansion. NEVER falls back to a root refresh (that's the collapse).
|
|
381
|
+
// Used to (a) reconcile an optimistic insert against the server's real
|
|
382
|
+
// size/mtime, and (b) fold an EXTERNAL (agent) change into the tree. A reconcile
|
|
383
|
+
// of a parent that isn't loaded (collapsed/unmounted) is a no-op — there's
|
|
384
|
+
// nothing visible to update, and it re-lists fresh when the user expands it.
|
|
385
|
+
const reconcilePath = useCallback(
|
|
386
|
+
async (path: string) => {
|
|
387
|
+
if (!sessionId) return;
|
|
388
|
+
// Skip parents that aren't currently loaded/expanded in the tree (nothing
|
|
389
|
+
// visible to update; they re-list fresh on the next expand).
|
|
390
|
+
if (!parentIsLoaded(treeRef.current, path)) return;
|
|
391
|
+
try {
|
|
392
|
+
const listed = await client.fsList(workspaceId, sessionId, { path, depth: 1 });
|
|
393
|
+
const children = (listed.root.children ?? []).map(fsNodeToTree);
|
|
394
|
+
if (path === "") setTree((prev) => applyStatus(mergeRootChildren(prev, children)));
|
|
395
|
+
else
|
|
396
|
+
setTree((prev) => {
|
|
397
|
+
const existing = findNodeByPath(prev, path);
|
|
398
|
+
const merged = existing?.children ? mergeChildren(existing.children, children) : children;
|
|
399
|
+
return applyStatus(replaceChildren(prev, path, merged));
|
|
400
|
+
});
|
|
401
|
+
} catch {
|
|
402
|
+
// A failed reconcile is non-fatal: the optimistic state stands. We never
|
|
403
|
+
// root-refresh here (that would collapse the tree the user is working in).
|
|
404
|
+
}
|
|
405
|
+
},
|
|
406
|
+
[client, workspaceId, sessionId, applyStatus],
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
// Run a Channel-A op behind an OPTIMISTIC tree edit. `apply` splices the change
|
|
410
|
+
// in immediately (preserving expansion/selection); the op runs in the
|
|
411
|
+
// background; on failure we revert to the pre-op snapshot and surface a toast.
|
|
412
|
+
// On success we keep the optimistic state and (optionally) reconcile the
|
|
413
|
+
// affected parent(s) to pick up the server's real size/revision — NEVER a full
|
|
414
|
+
// refresh. The returned promise resolves/rejects with the op so callers (the
|
|
415
|
+
// editor save, inline rename) can still await it.
|
|
416
|
+
const runOptimistic = useCallback(
|
|
417
|
+
async <T,>(
|
|
418
|
+
opName: string,
|
|
419
|
+
apply: (nodes: FileTreeNode[]) => FileTreeNode[],
|
|
420
|
+
op: () => Promise<T>,
|
|
421
|
+
reconcileParents: string[],
|
|
422
|
+
): Promise<T> => {
|
|
423
|
+
// Snapshot the pre-op tree from the ref (StrictMode-safe — see treeRef).
|
|
424
|
+
const snapshot = treeRef.current;
|
|
425
|
+
setTree(applyStatus(apply(snapshot)));
|
|
426
|
+
try {
|
|
427
|
+
const res = await op();
|
|
428
|
+
// Remember the revision WE caused so the matching fs.changed echo is
|
|
429
|
+
// ignored by the event effect (no self-triggered refresh).
|
|
430
|
+
if (res && typeof res === "object" && "revision" in res) {
|
|
431
|
+
const rev = (res as { revision?: unknown }).revision;
|
|
432
|
+
if (typeof rev === "number") ownRevisionsRef.current.add(rev);
|
|
433
|
+
}
|
|
434
|
+
// Reconcile loaded parents to fold the server's real metadata. Sequential
|
|
435
|
+
// and best-effort — purely cosmetic over the already-correct optimistic UI.
|
|
436
|
+
for (const parent of reconcileParents) await reconcilePath(parent);
|
|
437
|
+
return res;
|
|
438
|
+
} catch (cause) {
|
|
439
|
+
const err = cause instanceof Error ? cause : new Error(String(cause));
|
|
440
|
+
// Revert the optimistic edit to the exact pre-op tree.
|
|
441
|
+
setTree(applyStatus(snapshot));
|
|
442
|
+
setError(err);
|
|
443
|
+
onMutationError?.(err, opName);
|
|
444
|
+
throw err;
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
[applyStatus, reconcilePath, onMutationError],
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
const writeFile = useCallback(
|
|
451
|
+
async (path: string, content: string): Promise<FsWriteResponse> => {
|
|
452
|
+
if (!sessionId) throw new Error("no session");
|
|
453
|
+
const parent = parentOf(path);
|
|
454
|
+
// Splice a new file node in immediately ONLY when the path doesn't already
|
|
455
|
+
// exist in a loaded parent (an editor SAVE to an existing file mutates no
|
|
456
|
+
// tree shape — just content — so it needs no optimistic node, no reconcile).
|
|
457
|
+
const exists = Boolean(findNodeByPath(tree, path));
|
|
458
|
+
const node: FileTreeNode = { path, name: leafOf(path), kind: "file", size: content.length };
|
|
459
|
+
return await runOptimistic(
|
|
460
|
+
"write",
|
|
461
|
+
(nodes) => (exists ? nodes : insertNode(nodes, parent, node)),
|
|
462
|
+
() => client.fsWrite(workspaceId, sessionId, { path, content, overwrite: true }),
|
|
463
|
+
exists ? [] : [parent],
|
|
464
|
+
);
|
|
465
|
+
},
|
|
466
|
+
[client, workspaceId, sessionId, tree, runOptimistic],
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
const createFile = useCallback(
|
|
470
|
+
async (path: string): Promise<void> => {
|
|
471
|
+
if (!sessionId) throw new Error("no session");
|
|
472
|
+
const parent = parentOf(path);
|
|
473
|
+
const node: FileTreeNode = { path, name: leafOf(path), kind: "file", size: 0 };
|
|
474
|
+
await runOptimistic(
|
|
475
|
+
"create file",
|
|
476
|
+
(nodes) => insertNode(nodes, parent, node),
|
|
477
|
+
() => client.fsWrite(workspaceId, sessionId, { path, content: "", overwrite: false }),
|
|
478
|
+
[parent],
|
|
479
|
+
);
|
|
480
|
+
},
|
|
481
|
+
[client, workspaceId, sessionId, runOptimistic],
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
const createDir = useCallback(
|
|
485
|
+
async (path: string): Promise<void> => {
|
|
486
|
+
if (!sessionId) throw new Error("no session");
|
|
487
|
+
const parent = parentOf(path);
|
|
488
|
+
// A freshly-created dir is empty + expanded: children:[] (not undefined, so
|
|
489
|
+
// it doesn't show the lazy-expand marker over a dir we KNOW is empty).
|
|
490
|
+
const node: FileTreeNode = { path, name: leafOf(path), kind: "dir", children: [] };
|
|
491
|
+
await runOptimistic(
|
|
492
|
+
"create folder",
|
|
493
|
+
(nodes) => insertNode(nodes, parent, node),
|
|
494
|
+
() => client.fsMkdir(workspaceId, sessionId, { path, recursive: true }),
|
|
495
|
+
[parent],
|
|
496
|
+
);
|
|
497
|
+
},
|
|
498
|
+
[client, workspaceId, sessionId, runOptimistic],
|
|
499
|
+
);
|
|
500
|
+
|
|
501
|
+
const deleteEntry = useCallback(
|
|
502
|
+
async (path: string, recursive = false): Promise<void> => {
|
|
503
|
+
if (!sessionId) throw new Error("no session");
|
|
504
|
+
await runOptimistic(
|
|
505
|
+
"delete",
|
|
506
|
+
(nodes) => removeNode(nodes, path),
|
|
507
|
+
() => client.fsDelete(workspaceId, sessionId, { path, recursive }),
|
|
508
|
+
[parentOf(path)],
|
|
509
|
+
);
|
|
510
|
+
},
|
|
511
|
+
[client, workspaceId, sessionId, runOptimistic],
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
const moveEntry = useCallback(
|
|
515
|
+
async (path: string, newPath: string, opts?: { overwrite?: boolean }): Promise<void> => {
|
|
516
|
+
if (!sessionId) throw new Error("no session");
|
|
517
|
+
const from = parentOf(path);
|
|
518
|
+
const to = parentOf(newPath);
|
|
519
|
+
await runOptimistic(
|
|
520
|
+
"move",
|
|
521
|
+
(nodes) => {
|
|
522
|
+
const moving = findNodeByPath(nodes, path);
|
|
523
|
+
if (!moving) return nodes; // not loaded — let the reconcile pick it up
|
|
524
|
+
// Re-path the moved subtree, drop it from its old parent, splice into new.
|
|
525
|
+
const moved = repathNode(moving, path, newPath);
|
|
526
|
+
return insertNode(removeNode(nodes, path), to, moved);
|
|
527
|
+
},
|
|
528
|
+
() =>
|
|
529
|
+
client.fsMove(workspaceId, sessionId, {
|
|
530
|
+
path,
|
|
531
|
+
newPath,
|
|
532
|
+
overwrite: opts?.overwrite ?? false,
|
|
533
|
+
}),
|
|
534
|
+
to === from ? [from] : [from, to],
|
|
535
|
+
);
|
|
536
|
+
},
|
|
537
|
+
[client, workspaceId, sessionId, runOptimistic],
|
|
538
|
+
);
|
|
539
|
+
|
|
540
|
+
// Initial load + reset on identity change.
|
|
541
|
+
useEffect(() => {
|
|
542
|
+
if (!enabled) {
|
|
543
|
+
setTree([]);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
void refresh();
|
|
547
|
+
}, [enabled, refresh]);
|
|
548
|
+
|
|
549
|
+
// Re-pull JUST the git-status overlay and re-tint the existing tree in place —
|
|
550
|
+
// no fs re-list, no collapse. This is all a `git.changed` (commit/stage/checkout)
|
|
551
|
+
// needs: the tree SHAPE is unchanged, only the tints move.
|
|
552
|
+
const refreshGitOverlay = useCallback(async () => {
|
|
553
|
+
if (!sessionId) return;
|
|
554
|
+
try {
|
|
555
|
+
const status = await client.gitStatus(workspaceId, sessionId, { path: rootPath });
|
|
556
|
+
const overlay = new Map<string, FileTreeStatus>();
|
|
557
|
+
for (const file of status.files) {
|
|
558
|
+
const code = file.worktree ?? file.index;
|
|
559
|
+
const mapped = code ? GIT_STATUS_TO_TREE[code] : undefined;
|
|
560
|
+
if (mapped) overlay.set(file.path, mapped);
|
|
561
|
+
}
|
|
562
|
+
statusRef.current = overlay;
|
|
563
|
+
setTree((prev) => applyStatus(prev));
|
|
564
|
+
} catch {
|
|
565
|
+
/* a non-repo box has no overlay — leave the tree untinted */
|
|
566
|
+
}
|
|
567
|
+
}, [client, workspaceId, sessionId, rootPath, applyStatus]);
|
|
568
|
+
|
|
569
|
+
// Auto-reconcile on fs/git change notifications — TARGETED, never a root
|
|
570
|
+
// collapse-reload, and de-duped against our OWN mutations.
|
|
571
|
+
//
|
|
572
|
+
// • Our own ops (`source:"write"`, or a revision WE caused) are IGNORED —
|
|
573
|
+
// the optimistic edit already reflects them, so a refresh here would be the
|
|
574
|
+
// pointless 5s collapse the user reported.
|
|
575
|
+
// • An EXTERNAL fs.changed (the agent writing files: `source:"agent"`/`watch`)
|
|
576
|
+
// reconciles ONLY the affected parent directories (in place, expansion
|
|
577
|
+
// preserved). Bursts are debounced into a single reconcile pass.
|
|
578
|
+
// • A git.changed just re-tints (refreshes the status overlay) — no fs re-list.
|
|
579
|
+
const events = options.events;
|
|
580
|
+
const lastSeqRef = useRef(0);
|
|
581
|
+
const pendingParentsRef = useRef<Set<string>>(new Set());
|
|
582
|
+
const pendingGitRef = useRef(false);
|
|
583
|
+
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
584
|
+
|
|
585
|
+
useEffect(() => {
|
|
586
|
+
if (!enabled || !events) return;
|
|
587
|
+
let sawNew = false;
|
|
588
|
+
for (const event of events) {
|
|
589
|
+
if (event.sequence <= lastSeqRef.current) continue;
|
|
590
|
+
if (event.type === "fs.changed") {
|
|
591
|
+
sawNew = true;
|
|
592
|
+
const payload = event.payload as FsChangedPayload | null;
|
|
593
|
+
if (!payload || typeof payload !== "object") continue;
|
|
594
|
+
// Suppress our own writes: the optimistic tree already shows them.
|
|
595
|
+
if (payload.source === "write") continue;
|
|
596
|
+
if (typeof payload.revision === "number" && ownRevisionsRef.current.has(payload.revision)) continue;
|
|
597
|
+
for (const change of payload.changes ?? []) {
|
|
598
|
+
pendingParentsRef.current.add(parentOf(change.path));
|
|
599
|
+
if (change.oldPath) pendingParentsRef.current.add(parentOf(change.oldPath));
|
|
600
|
+
}
|
|
601
|
+
} else if (event.type === "git.changed") {
|
|
602
|
+
sawNew = true;
|
|
603
|
+
const payload = event.payload as GitChangedPayload | null;
|
|
604
|
+
// A git.changed our own write triggered (commit/stage from the agent is
|
|
605
|
+
// external; a checkout we caused isn't — but we don't cause git ops here,
|
|
606
|
+
// so any git.changed is external) → re-tint.
|
|
607
|
+
if (payload && typeof payload === "object" && typeof payload.revision === "number"
|
|
608
|
+
&& ownRevisionsRef.current.has(payload.revision)) continue;
|
|
609
|
+
pendingGitRef.current = true;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
// Advance the high-water mark past everything we've folded.
|
|
613
|
+
for (const event of events) if (event.sequence > lastSeqRef.current) lastSeqRef.current = event.sequence;
|
|
614
|
+
if (!sawNew) return;
|
|
615
|
+
|
|
616
|
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
617
|
+
debounceRef.current = setTimeout(() => {
|
|
618
|
+
debounceRef.current = null;
|
|
619
|
+
const parents = pendingParentsRef.current;
|
|
620
|
+
pendingParentsRef.current = new Set();
|
|
621
|
+
const wantGit = pendingGitRef.current;
|
|
622
|
+
pendingGitRef.current = false;
|
|
623
|
+
// Reconcile the changed directories (in place). A git change re-tints first
|
|
624
|
+
// so the freshly-listed nodes get the correct overlay.
|
|
625
|
+
void (async () => {
|
|
626
|
+
if (wantGit) await refreshGitOverlay();
|
|
627
|
+
for (const parent of parents) await reconcilePath(parent);
|
|
628
|
+
})();
|
|
629
|
+
}, 150);
|
|
630
|
+
}, [enabled, events, reconcilePath, refreshGitOverlay]);
|
|
631
|
+
|
|
632
|
+
useEffect(
|
|
633
|
+
() => () => {
|
|
634
|
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
635
|
+
},
|
|
636
|
+
[],
|
|
637
|
+
);
|
|
638
|
+
|
|
639
|
+
// Re-list when the box first becomes warm. The FileSystem capability is
|
|
640
|
+
// advertised on a cold box too, so the mount-time `refresh()` can run before
|
|
641
|
+
// the box is up (empty/errored result); without an `fs.changed` event the tree
|
|
642
|
+
// would stay empty forever. A cold->warm transition re-lists once the box is
|
|
643
|
+
// actually serving — the real fix for the "No files" the deployed app showed.
|
|
644
|
+
const wasLiveRef = useRef(false);
|
|
645
|
+
const liveness = options.liveness;
|
|
646
|
+
useEffect(() => {
|
|
647
|
+
const live = liveness === "warm" || liveness === "draining";
|
|
648
|
+
if (enabled && live && !wasLiveRef.current) {
|
|
649
|
+
wasLiveRef.current = true;
|
|
650
|
+
void refresh();
|
|
651
|
+
} else if (!live) {
|
|
652
|
+
wasLiveRef.current = false;
|
|
653
|
+
}
|
|
654
|
+
}, [enabled, liveness, refresh]);
|
|
655
|
+
|
|
656
|
+
return {
|
|
657
|
+
tree,
|
|
658
|
+
expand,
|
|
659
|
+
expandingPaths,
|
|
660
|
+
readFile,
|
|
661
|
+
writeFile,
|
|
662
|
+
createFile,
|
|
663
|
+
createDir,
|
|
664
|
+
deleteEntry,
|
|
665
|
+
moveEntry,
|
|
666
|
+
refresh,
|
|
667
|
+
loading,
|
|
668
|
+
error,
|
|
669
|
+
};
|
|
670
|
+
}
|