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