@illuma-ai/code-sandbox 1.0.0 → 1.2.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/components/CodeEditor.d.ts +18 -5
- package/dist/components/FileTree.d.ts +2 -1
- package/dist/components/Preview.d.ts +15 -3
- package/dist/components/Workbench.d.ts +3 -2
- package/dist/hooks/useRuntime.d.ts +35 -2
- package/dist/index.cjs +348 -80
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +8566 -7799
- package/dist/index.js.map +1 -1
- package/dist/services/runtime.d.ts +58 -1
- package/dist/types.d.ts +197 -9
- package/package.json +1 -1
- package/src/components/CodeEditor.tsx +141 -11
- package/src/components/FileTree.tsx +66 -4
- package/src/components/Preview.tsx +188 -5
- package/src/components/Workbench.tsx +140 -84
- package/src/hooks/useRuntime.ts +426 -89
- package/src/index.ts +4 -0
- package/src/services/runtime.ts +240 -1
- package/src/styles.css +96 -0
- package/src/templates/fullstack-starter.ts +211 -1
- package/src/types.ts +227 -10
package/src/hooks/useRuntime.ts
CHANGED
|
@@ -3,6 +3,15 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Manages the Nodepod runtime instance and exposes reactive state
|
|
5
5
|
* for all UI components (progress, files, terminal output, preview URL).
|
|
6
|
+
*
|
|
7
|
+
* The sandbox is a pure renderer — it receives files via props (initial
|
|
8
|
+
* load) and via the imperative handle (updateFiles/updateFile for live
|
|
9
|
+
* updates). It does NOT interact with any storage backend directly.
|
|
10
|
+
*
|
|
11
|
+
* Error exposure: wires the runtime's structured error system and the
|
|
12
|
+
* preview iframe's browser error capture into a unified `errors[]` array
|
|
13
|
+
* in state, and forwards each error to the `onSandboxError` callback
|
|
14
|
+
* so the AI agent can auto-fix.
|
|
6
15
|
*/
|
|
7
16
|
|
|
8
17
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
@@ -11,15 +20,67 @@ import { getTemplate } from "../templates";
|
|
|
11
20
|
import type {
|
|
12
21
|
BootProgress,
|
|
13
22
|
CodeSandboxProps,
|
|
23
|
+
FileChangeStatus,
|
|
14
24
|
FileMap,
|
|
15
25
|
RuntimeState,
|
|
26
|
+
SandboxError,
|
|
16
27
|
} from "../types";
|
|
17
28
|
|
|
29
|
+
/** Debug log prefix for easy filtering in DevTools */
|
|
30
|
+
const DBG = "[CodeSandbox:useRuntime]";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Compute per-file change statuses between an original and current file set.
|
|
34
|
+
*
|
|
35
|
+
* Returns only files with a non-"unchanged" status (new, modified, deleted).
|
|
36
|
+
* Files that are identical are omitted — treat missing keys as "unchanged".
|
|
37
|
+
*/
|
|
38
|
+
function computeFileChanges(
|
|
39
|
+
original: FileMap,
|
|
40
|
+
current: FileMap,
|
|
41
|
+
): Record<string, FileChangeStatus> {
|
|
42
|
+
const changes: Record<string, FileChangeStatus> = {};
|
|
43
|
+
|
|
44
|
+
// Check current files against original
|
|
45
|
+
for (const path of Object.keys(current)) {
|
|
46
|
+
if (!(path in original)) {
|
|
47
|
+
changes[path] = "new";
|
|
48
|
+
} else if (original[path] !== current[path]) {
|
|
49
|
+
changes[path] = "modified";
|
|
50
|
+
}
|
|
51
|
+
// unchanged — omit
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Check for deleted files (in original but not in current)
|
|
55
|
+
for (const path of Object.keys(original)) {
|
|
56
|
+
if (!(path in current)) {
|
|
57
|
+
changes[path] = "deleted";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return changes;
|
|
62
|
+
}
|
|
63
|
+
|
|
18
64
|
/**
|
|
19
65
|
* Hook that manages the full Nodepod runtime lifecycle.
|
|
20
66
|
*
|
|
67
|
+
* The sandbox is a pure renderer — files come in via props or the
|
|
68
|
+
* imperative handle. No GitHub, no polling, no storage backends.
|
|
69
|
+
*
|
|
21
70
|
* @param props - CodeSandbox component props
|
|
22
|
-
* @returns Reactive runtime state + control functions
|
|
71
|
+
* @returns Reactive runtime state + control functions + imperative methods
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* ```tsx
|
|
75
|
+
* // Direct files (fetched from any source by the host app)
|
|
76
|
+
* const runtime = useRuntime({ files: myFiles, entryCommand: 'node server.js' });
|
|
77
|
+
*
|
|
78
|
+
* // Later, push updated files from the host:
|
|
79
|
+
* await runtime.updateFiles(newFiles);
|
|
80
|
+
*
|
|
81
|
+
* // Or update a single file:
|
|
82
|
+
* await runtime.updateFile('server.js', newContent);
|
|
83
|
+
* ```
|
|
23
84
|
*/
|
|
24
85
|
export function useRuntime(props: CodeSandboxProps) {
|
|
25
86
|
const {
|
|
@@ -32,10 +93,19 @@ export function useRuntime(props: CodeSandboxProps) {
|
|
|
32
93
|
onServerReady,
|
|
33
94
|
onProgress,
|
|
34
95
|
onError,
|
|
96
|
+
onSandboxError,
|
|
97
|
+
onFilesUpdated,
|
|
35
98
|
} = props;
|
|
36
99
|
|
|
37
100
|
const runtimeRef = useRef<NodepodRuntime | null>(null);
|
|
38
101
|
const bootedRef = useRef(false);
|
|
102
|
+
/** Ref for the latest onSandboxError callback (avoids stale closures) */
|
|
103
|
+
const onSandboxErrorRef = useRef(onSandboxError);
|
|
104
|
+
onSandboxErrorRef.current = onSandboxError;
|
|
105
|
+
|
|
106
|
+
/** Ref for latest onFilesUpdated callback */
|
|
107
|
+
const onFilesUpdatedRef = useRef(onFilesUpdated);
|
|
108
|
+
onFilesUpdatedRef.current = onFilesUpdated;
|
|
39
109
|
|
|
40
110
|
const [state, setState] = useState<RuntimeState>({
|
|
41
111
|
status: "initializing",
|
|
@@ -43,18 +113,78 @@ export function useRuntime(props: CodeSandboxProps) {
|
|
|
43
113
|
previewUrl: null,
|
|
44
114
|
terminalOutput: [],
|
|
45
115
|
files: {},
|
|
116
|
+
originalFiles: {},
|
|
117
|
+
fileChanges: {},
|
|
46
118
|
error: null,
|
|
119
|
+
errors: [],
|
|
47
120
|
});
|
|
48
121
|
|
|
49
122
|
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
|
50
123
|
const [openFiles, setOpenFiles] = useState<string[]>([]);
|
|
51
124
|
|
|
52
|
-
//
|
|
125
|
+
// -------------------------------------------------------------------------
|
|
126
|
+
// Error handling — unified pipeline
|
|
127
|
+
// -------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Handle a structured error from any source (runtime or browser).
|
|
131
|
+
*
|
|
132
|
+
* Appends to the errors array in state and forwards to onSandboxError.
|
|
133
|
+
* The runtime calls reportError for process errors; the Preview
|
|
134
|
+
* component calls handleBrowserError for iframe errors.
|
|
135
|
+
*/
|
|
136
|
+
const handleSandboxError = useCallback((error: SandboxError) => {
|
|
137
|
+
setState((prev) => ({
|
|
138
|
+
...prev,
|
|
139
|
+
errors: [...prev.errors, error],
|
|
140
|
+
}));
|
|
141
|
+
onSandboxErrorRef.current?.(error);
|
|
142
|
+
}, []);
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Handle a browser error from the preview iframe.
|
|
146
|
+
*
|
|
147
|
+
* Called by the Preview component's onBrowserError callback.
|
|
148
|
+
* We enrich the error with source context from the runtime
|
|
149
|
+
* (if the file exists in our virtual FS) then record it.
|
|
150
|
+
*/
|
|
151
|
+
const handleBrowserError = useCallback(
|
|
152
|
+
async (error: SandboxError) => {
|
|
153
|
+
const runtime = runtimeRef.current;
|
|
154
|
+
if (runtime) {
|
|
155
|
+
// reportError enriches with source context and records internally
|
|
156
|
+
await runtime.reportError(error);
|
|
157
|
+
}
|
|
158
|
+
// Also update React state
|
|
159
|
+
handleSandboxError(error);
|
|
160
|
+
},
|
|
161
|
+
[handleSandboxError],
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// -------------------------------------------------------------------------
|
|
165
|
+
// Config resolution
|
|
166
|
+
// -------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Resolve files + entry command from props or template.
|
|
170
|
+
*/
|
|
53
171
|
const resolvedConfig = useCallback(() => {
|
|
54
|
-
if (propFiles) {
|
|
172
|
+
if (propFiles && Object.keys(propFiles).length > 0) {
|
|
173
|
+
// Infer entry command from package.json if not provided
|
|
174
|
+
let entryCommand = propEntryCommand || "node server.js";
|
|
175
|
+
if (!propEntryCommand && propFiles["package.json"]) {
|
|
176
|
+
try {
|
|
177
|
+
const pkg = JSON.parse(propFiles["package.json"]);
|
|
178
|
+
if (pkg.scripts?.start) {
|
|
179
|
+
entryCommand = pkg.scripts.start;
|
|
180
|
+
}
|
|
181
|
+
} catch {
|
|
182
|
+
/* use default */
|
|
183
|
+
}
|
|
184
|
+
}
|
|
55
185
|
return {
|
|
56
186
|
files: propFiles,
|
|
57
|
-
entryCommand
|
|
187
|
+
entryCommand,
|
|
58
188
|
port: propPort || 3000,
|
|
59
189
|
};
|
|
60
190
|
}
|
|
@@ -70,131 +200,309 @@ export function useRuntime(props: CodeSandboxProps) {
|
|
|
70
200
|
}
|
|
71
201
|
}
|
|
72
202
|
|
|
73
|
-
//
|
|
74
|
-
return
|
|
75
|
-
files: {},
|
|
76
|
-
entryCommand: propEntryCommand || "node server.js",
|
|
77
|
-
port: propPort || 3000,
|
|
78
|
-
};
|
|
203
|
+
// No files provided — return null (sandbox waits for updateFiles)
|
|
204
|
+
return null;
|
|
79
205
|
}, [propFiles, template, propEntryCommand, propPort]);
|
|
80
206
|
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
bootedRef.current = true;
|
|
87
|
-
|
|
88
|
-
const config = resolvedConfig();
|
|
89
|
-
if (Object.keys(config.files).length === 0) {
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const runtime = new NodepodRuntime({
|
|
94
|
-
files: config.files,
|
|
95
|
-
entryCommand: config.entryCommand,
|
|
96
|
-
port: config.port,
|
|
97
|
-
env,
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
runtimeRef.current = runtime;
|
|
207
|
+
// -------------------------------------------------------------------------
|
|
208
|
+
// Boot helpers
|
|
209
|
+
// -------------------------------------------------------------------------
|
|
101
210
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
211
|
+
/**
|
|
212
|
+
* Boot the runtime with a resolved file set.
|
|
213
|
+
*/
|
|
214
|
+
const bootWithFiles = useCallback(
|
|
215
|
+
(files: FileMap, entryCommand: string, port: number) => {
|
|
216
|
+
const runtime = new NodepodRuntime({
|
|
217
|
+
files,
|
|
218
|
+
entryCommand,
|
|
219
|
+
port,
|
|
220
|
+
env,
|
|
221
|
+
});
|
|
107
222
|
|
|
108
|
-
|
|
109
|
-
// Auto-select entry file or first file
|
|
110
|
-
const entryFile =
|
|
111
|
-
fileNames.find((f) => f === "server.js") ||
|
|
112
|
-
fileNames.find((f) => f === "index.js") ||
|
|
113
|
-
fileNames.find((f) => f.endsWith(".js")) ||
|
|
114
|
-
fileNames[0];
|
|
115
|
-
|
|
116
|
-
if (entryFile) {
|
|
117
|
-
setSelectedFile(entryFile);
|
|
118
|
-
setOpenFiles([entryFile]);
|
|
119
|
-
}
|
|
223
|
+
runtimeRef.current = runtime;
|
|
120
224
|
|
|
121
|
-
|
|
122
|
-
runtime.setProgressCallback((progress: BootProgress) => {
|
|
225
|
+
// Set initial files state + open the first file
|
|
123
226
|
setState((prev) => ({
|
|
124
227
|
...prev,
|
|
125
|
-
|
|
126
|
-
progress,
|
|
127
|
-
previewUrl: runtime.getPreviewUrl(),
|
|
128
|
-
error: progress.stage === "error" ? progress.message : prev.error,
|
|
228
|
+
files,
|
|
129
229
|
}));
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
230
|
+
|
|
231
|
+
const fileNames = Object.keys(files);
|
|
232
|
+
const entryFile =
|
|
233
|
+
fileNames.find((f) => f === "server.js") ||
|
|
234
|
+
fileNames.find((f) => f === "index.js") ||
|
|
235
|
+
fileNames.find((f) => f.endsWith(".js")) ||
|
|
236
|
+
fileNames[0];
|
|
237
|
+
|
|
238
|
+
if (entryFile) {
|
|
239
|
+
setSelectedFile(entryFile);
|
|
240
|
+
setOpenFiles([entryFile]);
|
|
133
241
|
}
|
|
134
|
-
});
|
|
135
242
|
|
|
136
|
-
|
|
243
|
+
// Wire progress callback
|
|
244
|
+
runtime.setProgressCallback((progress: BootProgress) => {
|
|
245
|
+
setState((prev) => ({
|
|
246
|
+
...prev,
|
|
247
|
+
status: progress.stage,
|
|
248
|
+
progress,
|
|
249
|
+
previewUrl: runtime.getPreviewUrl(),
|
|
250
|
+
error: progress.stage === "error" ? progress.message : prev.error,
|
|
251
|
+
}));
|
|
252
|
+
onProgress?.(progress);
|
|
253
|
+
if (progress.stage === "error") {
|
|
254
|
+
onError?.(progress.message);
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// Wire terminal output callback
|
|
259
|
+
runtime.setOutputCallback((line: string) => {
|
|
260
|
+
setState((prev) => ({
|
|
261
|
+
...prev,
|
|
262
|
+
terminalOutput: [...prev.terminalOutput, line],
|
|
263
|
+
}));
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// Wire server ready callback
|
|
267
|
+
runtime.setServerReadyCallback((readyPort: number, url: string) => {
|
|
268
|
+
setState((prev) => ({
|
|
269
|
+
...prev,
|
|
270
|
+
previewUrl: url,
|
|
271
|
+
status: "ready",
|
|
272
|
+
}));
|
|
273
|
+
onServerReady?.(readyPort, url);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// Wire structured error callback
|
|
277
|
+
runtime.setErrorCallback(handleSandboxError);
|
|
278
|
+
|
|
279
|
+
// Boot
|
|
280
|
+
runtime.boot().catch((err) => {
|
|
281
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
282
|
+
onError?.(msg);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
return runtime;
|
|
286
|
+
},
|
|
287
|
+
[env, onProgress, onError, onServerReady, handleSandboxError],
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
// -------------------------------------------------------------------------
|
|
291
|
+
// Imperative file update methods
|
|
292
|
+
// -------------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Push a complete new file set into the sandbox.
|
|
296
|
+
*
|
|
297
|
+
* Diffs against current files, writes only changed files to Nodepod FS,
|
|
298
|
+
* and restarts the server if anything changed (unless restartServer=false).
|
|
299
|
+
*
|
|
300
|
+
* The previous file set becomes `originalFiles` for diff tracking.
|
|
301
|
+
*
|
|
302
|
+
* Called by the imperative handle's updateFiles() method.
|
|
303
|
+
* Also usable as the initial boot path when files arrive asynchronously
|
|
304
|
+
* (e.g., host fetches from GitHub/DB/S3, then calls updateFiles).
|
|
305
|
+
*/
|
|
306
|
+
const updateFiles = useCallback(
|
|
307
|
+
async (newFiles: FileMap, options?: { restartServer?: boolean }) => {
|
|
308
|
+
const shouldRestart = options?.restartServer !== false;
|
|
309
|
+
const runtime = runtimeRef.current;
|
|
310
|
+
|
|
311
|
+
// If runtime hasn't booted yet, boot with these files
|
|
312
|
+
if (!runtime) {
|
|
313
|
+
if (bootedRef.current) {
|
|
314
|
+
console.warn(
|
|
315
|
+
DBG,
|
|
316
|
+
"updateFiles called but runtime is gone (already torn down)",
|
|
317
|
+
);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
bootedRef.current = true;
|
|
321
|
+
|
|
322
|
+
// Infer entry command from package.json
|
|
323
|
+
let entryCommand = propEntryCommand || "node server.js";
|
|
324
|
+
if (!propEntryCommand && newFiles["package.json"]) {
|
|
325
|
+
try {
|
|
326
|
+
const pkg = JSON.parse(newFiles["package.json"]);
|
|
327
|
+
if (pkg.scripts?.start) {
|
|
328
|
+
entryCommand = pkg.scripts.start;
|
|
329
|
+
}
|
|
330
|
+
} catch {
|
|
331
|
+
/* use default */
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const port = propPort || 3000;
|
|
335
|
+
|
|
336
|
+
bootWithFiles(newFiles, entryCommand, port);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Runtime exists — diff and write changed files
|
|
341
|
+
const currentFiles = runtime.getCurrentFiles();
|
|
342
|
+
let changed = false;
|
|
343
|
+
|
|
344
|
+
for (const [path, content] of Object.entries(newFiles)) {
|
|
345
|
+
if (currentFiles[path] !== content) {
|
|
346
|
+
try {
|
|
347
|
+
await runtime.writeFile(path, content);
|
|
348
|
+
changed = true;
|
|
349
|
+
console.log(DBG, `updated file: ${path}`);
|
|
350
|
+
} catch (err) {
|
|
351
|
+
console.warn(DBG, `failed to write ${path}:`, err);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Compute change status — "original" is what we had before this update
|
|
357
|
+
const fileChanges = computeFileChanges(currentFiles, newFiles);
|
|
358
|
+
|
|
359
|
+
// Update React state with new file set + diff info
|
|
137
360
|
setState((prev) => ({
|
|
138
361
|
...prev,
|
|
139
|
-
|
|
362
|
+
originalFiles: currentFiles,
|
|
363
|
+
files: newFiles,
|
|
364
|
+
fileChanges,
|
|
140
365
|
}));
|
|
141
|
-
});
|
|
142
366
|
|
|
143
|
-
|
|
144
|
-
|
|
367
|
+
// Restart server if any files changed
|
|
368
|
+
if (changed && shouldRestart) {
|
|
369
|
+
console.log(
|
|
370
|
+
DBG,
|
|
371
|
+
`files changed — restarting server (${Object.keys(fileChanges).length} files differ)`,
|
|
372
|
+
);
|
|
373
|
+
try {
|
|
374
|
+
await runtime.restart();
|
|
375
|
+
} catch (err) {
|
|
376
|
+
console.error(DBG, "restart failed:", err);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Notify host that files have been processed
|
|
381
|
+
onFilesUpdatedRef.current?.(fileChanges);
|
|
382
|
+
},
|
|
383
|
+
[propEntryCommand, propPort, bootWithFiles],
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Push a single file update into the sandbox.
|
|
388
|
+
*
|
|
389
|
+
* Writes to Nodepod FS and updates React state. Does NOT restart
|
|
390
|
+
* the server — use restart() if needed, or updateFiles() for bulk
|
|
391
|
+
* updates with auto-restart.
|
|
392
|
+
*
|
|
393
|
+
* This is the same path as editor edits, but callable imperatively
|
|
394
|
+
* (e.g., when the agent modifies a single file).
|
|
395
|
+
*/
|
|
396
|
+
const updateFile = useCallback(async (path: string, content: string) => {
|
|
397
|
+
const runtime = runtimeRef.current;
|
|
398
|
+
if (!runtime) {
|
|
399
|
+
console.warn(
|
|
400
|
+
DBG,
|
|
401
|
+
"updateFile called but runtime not ready. Use updateFiles() for initial load.",
|
|
402
|
+
);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
try {
|
|
407
|
+
await runtime.writeFile(path, content);
|
|
408
|
+
} catch (err) {
|
|
409
|
+
console.error(`Failed to write file ${path}:`, err);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
setState((prev) => {
|
|
413
|
+
const newFiles = { ...prev.files, [path]: content };
|
|
414
|
+
const newChanges = { ...prev.fileChanges };
|
|
415
|
+
if (!(path in prev.originalFiles)) {
|
|
416
|
+
newChanges[path] = "new";
|
|
417
|
+
} else if (prev.originalFiles[path] !== content) {
|
|
418
|
+
newChanges[path] = "modified";
|
|
419
|
+
} else {
|
|
420
|
+
// Reverted back to original — remove from changes
|
|
421
|
+
delete newChanges[path];
|
|
422
|
+
}
|
|
423
|
+
return {
|
|
145
424
|
...prev,
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
149
|
-
onServerReady?.(port, url);
|
|
425
|
+
files: newFiles,
|
|
426
|
+
fileChanges: newChanges,
|
|
427
|
+
};
|
|
150
428
|
});
|
|
429
|
+
}, []);
|
|
151
430
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
431
|
+
// -------------------------------------------------------------------------
|
|
432
|
+
// Boot on mount
|
|
433
|
+
// -------------------------------------------------------------------------
|
|
434
|
+
|
|
435
|
+
useEffect(() => {
|
|
436
|
+
if (bootedRef.current) return;
|
|
437
|
+
|
|
438
|
+
const config = resolvedConfig();
|
|
439
|
+
|
|
440
|
+
if (config) {
|
|
441
|
+
// Direct files or template — boot immediately
|
|
442
|
+
bootedRef.current = true;
|
|
443
|
+
if (Object.keys(config.files).length === 0) return;
|
|
444
|
+
bootWithFiles(config.files, config.entryCommand, config.port);
|
|
445
|
+
}
|
|
446
|
+
// If no config (no files, no template), sandbox waits for
|
|
447
|
+
// updateFiles() to be called via the imperative handle.
|
|
157
448
|
|
|
158
449
|
// Cleanup on unmount
|
|
159
450
|
return () => {
|
|
160
|
-
|
|
451
|
+
runtimeRef.current?.teardown();
|
|
161
452
|
runtimeRef.current = null;
|
|
162
453
|
};
|
|
163
454
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
164
455
|
|
|
165
|
-
//
|
|
456
|
+
// -------------------------------------------------------------------------
|
|
457
|
+
// File editing (from the CodeEditor UI)
|
|
458
|
+
// -------------------------------------------------------------------------
|
|
459
|
+
|
|
166
460
|
const handleFileChange = useCallback(
|
|
167
461
|
async (path: string, content: string) => {
|
|
168
462
|
const runtime = runtimeRef.current;
|
|
169
|
-
if (!runtime)
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
463
|
+
if (!runtime) return;
|
|
172
464
|
|
|
173
|
-
// Write to Nodepod FS
|
|
174
465
|
try {
|
|
175
466
|
await runtime.writeFile(path, content);
|
|
176
467
|
} catch (err) {
|
|
177
468
|
console.error(`Failed to write file ${path}:`, err);
|
|
178
469
|
}
|
|
179
470
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
471
|
+
setState((prev) => {
|
|
472
|
+
const newFiles = { ...prev.files, [path]: content };
|
|
473
|
+
// Recompute change status for this file against originalFiles.
|
|
474
|
+
// Only update this file's status — don't recompute everything.
|
|
475
|
+
const newChanges = { ...prev.fileChanges };
|
|
476
|
+
if (!(path in prev.originalFiles)) {
|
|
477
|
+
newChanges[path] = "new";
|
|
478
|
+
} else if (prev.originalFiles[path] !== content) {
|
|
479
|
+
newChanges[path] = "modified";
|
|
480
|
+
} else {
|
|
481
|
+
// Reverted back to original — remove from changes
|
|
482
|
+
delete newChanges[path];
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return {
|
|
486
|
+
...prev,
|
|
487
|
+
files: newFiles,
|
|
488
|
+
fileChanges: newChanges,
|
|
489
|
+
};
|
|
490
|
+
});
|
|
185
491
|
|
|
186
492
|
onFileChange?.(path, content);
|
|
187
493
|
},
|
|
188
494
|
[onFileChange],
|
|
189
495
|
);
|
|
190
496
|
|
|
191
|
-
//
|
|
497
|
+
// -------------------------------------------------------------------------
|
|
498
|
+
// File selection
|
|
499
|
+
// -------------------------------------------------------------------------
|
|
500
|
+
|
|
192
501
|
const handleSelectFile = useCallback((path: string) => {
|
|
193
502
|
setSelectedFile(path);
|
|
194
503
|
setOpenFiles((prev) => (prev.includes(path) ? prev : [...prev, path]));
|
|
195
504
|
}, []);
|
|
196
505
|
|
|
197
|
-
// Close a file tab
|
|
198
506
|
const handleCloseFile = useCallback(
|
|
199
507
|
(path: string) => {
|
|
200
508
|
setOpenFiles((prev) => {
|
|
@@ -208,20 +516,41 @@ export function useRuntime(props: CodeSandboxProps) {
|
|
|
208
516
|
[selectedFile],
|
|
209
517
|
);
|
|
210
518
|
|
|
211
|
-
//
|
|
519
|
+
// -------------------------------------------------------------------------
|
|
520
|
+
// Server control
|
|
521
|
+
// -------------------------------------------------------------------------
|
|
522
|
+
|
|
212
523
|
const restart = useCallback(async () => {
|
|
213
524
|
const runtime = runtimeRef.current;
|
|
214
|
-
if (!runtime)
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
525
|
+
if (!runtime) return;
|
|
217
526
|
await runtime.restart();
|
|
218
527
|
}, []);
|
|
219
528
|
|
|
220
|
-
//
|
|
529
|
+
// -------------------------------------------------------------------------
|
|
530
|
+
// State readers (for imperative handle)
|
|
531
|
+
// -------------------------------------------------------------------------
|
|
532
|
+
|
|
533
|
+
const getFiles = useCallback((): FileMap => {
|
|
534
|
+
return runtimeRef.current?.getCurrentFiles() ?? {};
|
|
535
|
+
}, []);
|
|
536
|
+
|
|
221
537
|
const getChangedFiles = useCallback((): FileMap => {
|
|
222
538
|
return runtimeRef.current?.getChangedFiles() ?? {};
|
|
223
539
|
}, []);
|
|
224
540
|
|
|
541
|
+
const getFileChanges = useCallback((): Record<string, FileChangeStatus> => {
|
|
542
|
+
// Read from React state since it's the source of truth for changes
|
|
543
|
+
return state.fileChanges;
|
|
544
|
+
}, [state.fileChanges]);
|
|
545
|
+
|
|
546
|
+
const getErrors = useCallback((): SandboxError[] => {
|
|
547
|
+
return state.errors;
|
|
548
|
+
}, [state.errors]);
|
|
549
|
+
|
|
550
|
+
const getState = useCallback((): RuntimeState => {
|
|
551
|
+
return state;
|
|
552
|
+
}, [state]);
|
|
553
|
+
|
|
225
554
|
return {
|
|
226
555
|
state,
|
|
227
556
|
selectedFile,
|
|
@@ -229,8 +558,16 @@ export function useRuntime(props: CodeSandboxProps) {
|
|
|
229
558
|
handleFileChange,
|
|
230
559
|
handleSelectFile,
|
|
231
560
|
handleCloseFile,
|
|
561
|
+
handleBrowserError,
|
|
232
562
|
restart,
|
|
563
|
+
// Imperative methods (exposed via handle)
|
|
564
|
+
updateFiles,
|
|
565
|
+
updateFile,
|
|
566
|
+
getFiles,
|
|
233
567
|
getChangedFiles,
|
|
568
|
+
getFileChanges,
|
|
569
|
+
getErrors,
|
|
570
|
+
getState,
|
|
234
571
|
runtime: runtimeRef,
|
|
235
572
|
};
|
|
236
573
|
}
|
package/src/index.ts
CHANGED
|
@@ -31,6 +31,9 @@ export { getTemplate, listTemplates } from "./templates";
|
|
|
31
31
|
export type {
|
|
32
32
|
FileMap,
|
|
33
33
|
FileNode,
|
|
34
|
+
FileChangeStatus,
|
|
35
|
+
SandboxError,
|
|
36
|
+
SandboxErrorCategory,
|
|
34
37
|
BootStage,
|
|
35
38
|
BootProgress,
|
|
36
39
|
RuntimeConfig,
|
|
@@ -40,6 +43,7 @@ export type {
|
|
|
40
43
|
GitPRRequest,
|
|
41
44
|
GitPRResult,
|
|
42
45
|
CodeSandboxProps,
|
|
46
|
+
CodeSandboxHandle,
|
|
43
47
|
FileTreeProps,
|
|
44
48
|
CodeEditorProps,
|
|
45
49
|
TerminalProps,
|