@illuma-ai/code-sandbox 1.0.0 → 1.1.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/hooks/useRuntime.d.ts +24 -1
- package/dist/index.cjs +351 -83
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +8698 -7956
- package/dist/index.js.map +1 -1
- package/dist/services/runtime.d.ts +58 -1
- package/dist/types.d.ts +97 -1
- 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 +12 -2
- package/src/hooks/useRuntime.ts +450 -79
- package/src/index.ts +3 -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 +113 -1
package/src/hooks/useRuntime.ts
CHANGED
|
@@ -3,27 +3,93 @@
|
|
|
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
|
+
* Supports two file sources:
|
|
8
|
+
* 1. Direct files (via `files` prop or `template` prop)
|
|
9
|
+
* 2. GitHub repo (via `github` prop) — with branch polling for hot-reload
|
|
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";
|
|
9
18
|
import { NodepodRuntime } from "../services/runtime";
|
|
19
|
+
import { GitService } from "../services/git";
|
|
10
20
|
import { getTemplate } from "../templates";
|
|
11
21
|
import type {
|
|
12
22
|
BootProgress,
|
|
13
23
|
CodeSandboxProps,
|
|
24
|
+
FileChangeStatus,
|
|
14
25
|
FileMap,
|
|
26
|
+
GitHubRepo,
|
|
15
27
|
RuntimeState,
|
|
28
|
+
SandboxError,
|
|
16
29
|
} from "../types";
|
|
17
30
|
|
|
31
|
+
/** Debug log prefix for easy filtering in DevTools */
|
|
32
|
+
const DBG = "[CodeSandbox:useRuntime]";
|
|
33
|
+
|
|
34
|
+
/** Default polling interval for GitHub branch changes (ms) */
|
|
35
|
+
const DEFAULT_POLL_INTERVAL = 5_000;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Compute per-file change statuses between an original and current file set.
|
|
39
|
+
*
|
|
40
|
+
* Returns only files with a non-"unchanged" status (new, modified, deleted).
|
|
41
|
+
* Files that are identical are omitted — treat missing keys as "unchanged".
|
|
42
|
+
*/
|
|
43
|
+
function computeFileChanges(
|
|
44
|
+
original: FileMap,
|
|
45
|
+
current: FileMap,
|
|
46
|
+
): Record<string, FileChangeStatus> {
|
|
47
|
+
const changes: Record<string, FileChangeStatus> = {};
|
|
48
|
+
|
|
49
|
+
// Check current files against original
|
|
50
|
+
for (const path of Object.keys(current)) {
|
|
51
|
+
if (!(path in original)) {
|
|
52
|
+
changes[path] = "new";
|
|
53
|
+
} else if (original[path] !== current[path]) {
|
|
54
|
+
changes[path] = "modified";
|
|
55
|
+
}
|
|
56
|
+
// unchanged — omit
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Check for deleted files (in original but not in current)
|
|
60
|
+
for (const path of Object.keys(original)) {
|
|
61
|
+
if (!(path in current)) {
|
|
62
|
+
changes[path] = "deleted";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return changes;
|
|
67
|
+
}
|
|
68
|
+
|
|
18
69
|
/**
|
|
19
70
|
* Hook that manages the full Nodepod runtime lifecycle.
|
|
20
71
|
*
|
|
21
72
|
* @param props - CodeSandbox component props
|
|
22
73
|
* @returns Reactive runtime state + control functions
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* ```tsx
|
|
77
|
+
* // Direct files
|
|
78
|
+
* const { state } = useRuntime({ files: myFiles, entryCommand: 'node server.js' });
|
|
79
|
+
*
|
|
80
|
+
* // GitHub repo with auto-polling
|
|
81
|
+
* const { state } = useRuntime({
|
|
82
|
+
* github: { owner: 'illuma-ai', repo: 'fullstack-starter', branch: 'main' },
|
|
83
|
+
* gitToken: 'ghp_xxx',
|
|
84
|
+
* onSandboxError: (err) => agent.fix(err),
|
|
85
|
+
* });
|
|
86
|
+
* ```
|
|
23
87
|
*/
|
|
24
88
|
export function useRuntime(props: CodeSandboxProps) {
|
|
25
89
|
const {
|
|
26
90
|
files: propFiles,
|
|
91
|
+
github,
|
|
92
|
+
gitToken,
|
|
27
93
|
template,
|
|
28
94
|
entryCommand: propEntryCommand,
|
|
29
95
|
port: propPort,
|
|
@@ -32,10 +98,19 @@ export function useRuntime(props: CodeSandboxProps) {
|
|
|
32
98
|
onServerReady,
|
|
33
99
|
onProgress,
|
|
34
100
|
onError,
|
|
101
|
+
onSandboxError,
|
|
35
102
|
} = props;
|
|
36
103
|
|
|
37
104
|
const runtimeRef = useRef<NodepodRuntime | null>(null);
|
|
38
105
|
const bootedRef = useRef(false);
|
|
106
|
+
/** Ref for the latest onSandboxError callback (avoids stale closures) */
|
|
107
|
+
const onSandboxErrorRef = useRef(onSandboxError);
|
|
108
|
+
onSandboxErrorRef.current = onSandboxError;
|
|
109
|
+
|
|
110
|
+
/** SHA of the latest commit we've seen on the polled branch */
|
|
111
|
+
const lastCommitShaRef = useRef<string | null>(null);
|
|
112
|
+
/** Interval ID for GitHub polling */
|
|
113
|
+
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
39
114
|
|
|
40
115
|
const [state, setState] = useState<RuntimeState>({
|
|
41
116
|
status: "initializing",
|
|
@@ -43,13 +118,62 @@ export function useRuntime(props: CodeSandboxProps) {
|
|
|
43
118
|
previewUrl: null,
|
|
44
119
|
terminalOutput: [],
|
|
45
120
|
files: {},
|
|
121
|
+
originalFiles: {},
|
|
122
|
+
fileChanges: {},
|
|
46
123
|
error: null,
|
|
124
|
+
errors: [],
|
|
47
125
|
});
|
|
48
126
|
|
|
49
127
|
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
|
50
128
|
const [openFiles, setOpenFiles] = useState<string[]>([]);
|
|
51
129
|
|
|
52
|
-
//
|
|
130
|
+
// -------------------------------------------------------------------------
|
|
131
|
+
// Error handling — unified pipeline
|
|
132
|
+
// -------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Handle a structured error from any source (runtime or browser).
|
|
136
|
+
*
|
|
137
|
+
* Appends to the errors array in state and forwards to onSandboxError.
|
|
138
|
+
* The runtime calls reportError for process errors; the Preview
|
|
139
|
+
* component calls handleBrowserError for iframe errors.
|
|
140
|
+
*/
|
|
141
|
+
const handleSandboxError = useCallback((error: SandboxError) => {
|
|
142
|
+
setState((prev) => ({
|
|
143
|
+
...prev,
|
|
144
|
+
errors: [...prev.errors, error],
|
|
145
|
+
}));
|
|
146
|
+
onSandboxErrorRef.current?.(error);
|
|
147
|
+
}, []);
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Handle a browser error from the preview iframe.
|
|
151
|
+
*
|
|
152
|
+
* Called by the Preview component's onBrowserError callback.
|
|
153
|
+
* We enrich the error with source context from the runtime
|
|
154
|
+
* (if the file exists in our virtual FS) then record it.
|
|
155
|
+
*/
|
|
156
|
+
const handleBrowserError = useCallback(
|
|
157
|
+
async (error: SandboxError) => {
|
|
158
|
+
const runtime = runtimeRef.current;
|
|
159
|
+
if (runtime) {
|
|
160
|
+
// reportError enriches with source context and records internally
|
|
161
|
+
await runtime.reportError(error);
|
|
162
|
+
}
|
|
163
|
+
// Also update React state
|
|
164
|
+
handleSandboxError(error);
|
|
165
|
+
},
|
|
166
|
+
[handleSandboxError],
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
// -------------------------------------------------------------------------
|
|
170
|
+
// Config resolution
|
|
171
|
+
// -------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Resolve files + entry command from props or template.
|
|
175
|
+
* Returns null if using GitHub (files loaded async).
|
|
176
|
+
*/
|
|
53
177
|
const resolvedConfig = useCallback(() => {
|
|
54
178
|
if (propFiles) {
|
|
55
179
|
return {
|
|
@@ -70,131 +194,373 @@ export function useRuntime(props: CodeSandboxProps) {
|
|
|
70
194
|
}
|
|
71
195
|
}
|
|
72
196
|
|
|
197
|
+
// GitHub mode — files loaded asynchronously
|
|
198
|
+
if (github) {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
73
202
|
// Default: empty project
|
|
74
203
|
return {
|
|
75
204
|
files: {},
|
|
76
205
|
entryCommand: propEntryCommand || "node server.js",
|
|
77
206
|
port: propPort || 3000,
|
|
78
207
|
};
|
|
79
|
-
}, [propFiles, template, propEntryCommand, propPort]);
|
|
208
|
+
}, [propFiles, template, github, propEntryCommand, propPort]);
|
|
80
209
|
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
bootedRef.current = true;
|
|
210
|
+
// -------------------------------------------------------------------------
|
|
211
|
+
// Boot helpers
|
|
212
|
+
// -------------------------------------------------------------------------
|
|
87
213
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
214
|
+
/**
|
|
215
|
+
* Boot the runtime with a resolved file set.
|
|
216
|
+
*
|
|
217
|
+
* Shared between direct-file mode and GitHub mode.
|
|
218
|
+
*/
|
|
219
|
+
const bootWithFiles = useCallback(
|
|
220
|
+
(files: FileMap, entryCommand: string, port: number) => {
|
|
221
|
+
const runtime = new NodepodRuntime({
|
|
222
|
+
files,
|
|
223
|
+
entryCommand,
|
|
224
|
+
port,
|
|
225
|
+
env,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
runtimeRef.current = runtime;
|
|
229
|
+
|
|
230
|
+
// Set initial files state + open the first file
|
|
231
|
+
setState((prev) => ({
|
|
232
|
+
...prev,
|
|
233
|
+
files,
|
|
234
|
+
}));
|
|
235
|
+
|
|
236
|
+
const fileNames = Object.keys(files);
|
|
237
|
+
const entryFile =
|
|
238
|
+
fileNames.find((f) => f === "server.js") ||
|
|
239
|
+
fileNames.find((f) => f === "index.js") ||
|
|
240
|
+
fileNames.find((f) => f.endsWith(".js")) ||
|
|
241
|
+
fileNames[0];
|
|
242
|
+
|
|
243
|
+
if (entryFile) {
|
|
244
|
+
setSelectedFile(entryFile);
|
|
245
|
+
setOpenFiles([entryFile]);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Wire progress callback
|
|
249
|
+
runtime.setProgressCallback((progress: BootProgress) => {
|
|
250
|
+
setState((prev) => ({
|
|
251
|
+
...prev,
|
|
252
|
+
status: progress.stage,
|
|
253
|
+
progress,
|
|
254
|
+
previewUrl: runtime.getPreviewUrl(),
|
|
255
|
+
error: progress.stage === "error" ? progress.message : prev.error,
|
|
256
|
+
}));
|
|
257
|
+
onProgress?.(progress);
|
|
258
|
+
if (progress.stage === "error") {
|
|
259
|
+
onError?.(progress.message);
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// Wire terminal output callback
|
|
264
|
+
runtime.setOutputCallback((line: string) => {
|
|
265
|
+
setState((prev) => ({
|
|
266
|
+
...prev,
|
|
267
|
+
terminalOutput: [...prev.terminalOutput, line],
|
|
268
|
+
}));
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// Wire server ready callback
|
|
272
|
+
runtime.setServerReadyCallback((readyPort: number, url: string) => {
|
|
273
|
+
setState((prev) => ({
|
|
274
|
+
...prev,
|
|
275
|
+
previewUrl: url,
|
|
276
|
+
status: "ready",
|
|
277
|
+
}));
|
|
278
|
+
onServerReady?.(readyPort, url);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// Wire structured error callback
|
|
282
|
+
runtime.setErrorCallback(handleSandboxError);
|
|
283
|
+
|
|
284
|
+
// Boot
|
|
285
|
+
runtime.boot().catch((err) => {
|
|
286
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
287
|
+
onError?.(msg);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
return runtime;
|
|
291
|
+
},
|
|
292
|
+
[env, onProgress, onError, onServerReady, handleSandboxError],
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
// -------------------------------------------------------------------------
|
|
296
|
+
// GitHub branch polling
|
|
297
|
+
// -------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Fetch the latest commit SHA for a branch.
|
|
301
|
+
* Used to detect when the agent pushes new commits.
|
|
302
|
+
*/
|
|
303
|
+
const fetchLatestSha = useCallback(
|
|
304
|
+
async (repo: GitHubRepo, token: string): Promise<string | null> => {
|
|
305
|
+
try {
|
|
306
|
+
const { owner, repo: repoName, branch = "main" } = repo;
|
|
307
|
+
const res = await fetch(
|
|
308
|
+
`https://api.github.com/repos/${owner}/${repoName}/git/ref/heads/${branch}`,
|
|
309
|
+
{
|
|
310
|
+
headers: {
|
|
311
|
+
Authorization: `Bearer ${token}`,
|
|
312
|
+
Accept: "application/vnd.github.v3+json",
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
);
|
|
316
|
+
if (!res.ok) return null;
|
|
317
|
+
const data = await res.json();
|
|
318
|
+
return data.object?.sha ?? null;
|
|
319
|
+
} catch {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
[],
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Update files in the running sandbox when changes are detected.
|
|
328
|
+
*
|
|
329
|
+
* Writes new/changed files to Nodepod's virtual FS and restarts
|
|
330
|
+
* the server process. Deleted files are NOT removed (Nodepod FS
|
|
331
|
+
* doesn't support delete yet — they just become stale).
|
|
332
|
+
*
|
|
333
|
+
* Stores the previous file set as `originalFiles` so the diff
|
|
334
|
+
* viewer can show what changed.
|
|
335
|
+
*
|
|
336
|
+
* @param newFiles - The full new file set from the repo
|
|
337
|
+
*/
|
|
338
|
+
const updateFiles = useCallback(async (newFiles: FileMap) => {
|
|
339
|
+
const runtime = runtimeRef.current;
|
|
340
|
+
if (!runtime) return;
|
|
341
|
+
|
|
342
|
+
const currentFiles = runtime.getCurrentFiles();
|
|
343
|
+
let changed = false;
|
|
92
344
|
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
345
|
+
for (const [path, content] of Object.entries(newFiles)) {
|
|
346
|
+
if (currentFiles[path] !== content) {
|
|
347
|
+
try {
|
|
348
|
+
await runtime.writeFile(path, content);
|
|
349
|
+
changed = true;
|
|
350
|
+
console.log(DBG, `updated file: ${path}`);
|
|
351
|
+
} catch (err) {
|
|
352
|
+
console.warn(DBG, `failed to write ${path}:`, err);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
99
356
|
|
|
100
|
-
|
|
357
|
+
// Compute change status before updating state.
|
|
358
|
+
// The "original" is what we had before this update.
|
|
359
|
+
const fileChanges = computeFileChanges(currentFiles, newFiles);
|
|
101
360
|
|
|
102
|
-
//
|
|
361
|
+
// Update React state with new file set + diff info
|
|
103
362
|
setState((prev) => ({
|
|
104
363
|
...prev,
|
|
105
|
-
|
|
364
|
+
originalFiles: currentFiles,
|
|
365
|
+
files: newFiles,
|
|
366
|
+
fileChanges,
|
|
106
367
|
}));
|
|
107
368
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
369
|
+
// Restart server if any files changed
|
|
370
|
+
if (changed) {
|
|
371
|
+
console.log(
|
|
372
|
+
DBG,
|
|
373
|
+
`files changed — restarting server (${Object.keys(fileChanges).length} files differ)`,
|
|
374
|
+
);
|
|
375
|
+
try {
|
|
376
|
+
await runtime.restart();
|
|
377
|
+
} catch (err) {
|
|
378
|
+
console.error(DBG, "restart failed:", err);
|
|
379
|
+
}
|
|
119
380
|
}
|
|
381
|
+
}, []);
|
|
120
382
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
383
|
+
/**
|
|
384
|
+
* Start polling a GitHub branch for new commits.
|
|
385
|
+
*
|
|
386
|
+
* When a new commit is detected:
|
|
387
|
+
* 1. Fetch the full file tree via GitService.cloneRepo()
|
|
388
|
+
* 2. Diff against current files
|
|
389
|
+
* 3. Write changed files to Nodepod FS
|
|
390
|
+
* 4. Restart the server
|
|
391
|
+
*/
|
|
392
|
+
const startPolling = useCallback(
|
|
393
|
+
(repo: GitHubRepo, token: string) => {
|
|
394
|
+
// Stop any existing polling
|
|
395
|
+
if (pollIntervalRef.current) {
|
|
396
|
+
clearInterval(pollIntervalRef.current);
|
|
133
397
|
}
|
|
134
|
-
});
|
|
135
398
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
399
|
+
console.log(DBG, "starting branch polling", {
|
|
400
|
+
owner: repo.owner,
|
|
401
|
+
repo: repo.repo,
|
|
402
|
+
branch: repo.branch || "main",
|
|
403
|
+
interval: DEFAULT_POLL_INTERVAL,
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
pollIntervalRef.current = setInterval(async () => {
|
|
407
|
+
const latestSha = await fetchLatestSha(repo, token);
|
|
408
|
+
if (!latestSha) return;
|
|
409
|
+
|
|
410
|
+
if (
|
|
411
|
+
lastCommitShaRef.current &&
|
|
412
|
+
latestSha !== lastCommitShaRef.current
|
|
413
|
+
) {
|
|
414
|
+
console.log(DBG, "new commit detected:", latestSha);
|
|
415
|
+
lastCommitShaRef.current = latestSha;
|
|
416
|
+
|
|
417
|
+
// Fetch updated files
|
|
418
|
+
const git = new GitService(token);
|
|
419
|
+
try {
|
|
420
|
+
const newFiles = await git.cloneRepo(repo);
|
|
421
|
+
await updateFiles(newFiles);
|
|
422
|
+
} catch (err) {
|
|
423
|
+
console.error(DBG, "failed to fetch updated files:", err);
|
|
424
|
+
}
|
|
425
|
+
} else if (!lastCommitShaRef.current) {
|
|
426
|
+
// First poll — just record the SHA, don't re-fetch
|
|
427
|
+
lastCommitShaRef.current = latestSha;
|
|
428
|
+
}
|
|
429
|
+
}, DEFAULT_POLL_INTERVAL);
|
|
430
|
+
},
|
|
431
|
+
[fetchLatestSha, updateFiles],
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
// -------------------------------------------------------------------------
|
|
435
|
+
// Boot on mount
|
|
436
|
+
// -------------------------------------------------------------------------
|
|
437
|
+
|
|
438
|
+
useEffect(() => {
|
|
439
|
+
if (bootedRef.current) return;
|
|
440
|
+
bootedRef.current = true;
|
|
441
|
+
|
|
442
|
+
const config = resolvedConfig();
|
|
443
|
+
|
|
444
|
+
if (config) {
|
|
445
|
+
// Direct files or template — boot immediately
|
|
446
|
+
if (Object.keys(config.files).length === 0) return;
|
|
447
|
+
bootWithFiles(config.files, config.entryCommand, config.port);
|
|
448
|
+
} else if (github && gitToken) {
|
|
449
|
+
// GitHub mode — fetch files then boot, then start polling
|
|
450
|
+
const entryCommand = propEntryCommand || "node server.js";
|
|
451
|
+
const port = propPort || 3000;
|
|
142
452
|
|
|
143
|
-
runtime.setServerReadyCallback((port: number, url: string) => {
|
|
144
453
|
setState((prev) => ({
|
|
145
454
|
...prev,
|
|
146
|
-
|
|
147
|
-
|
|
455
|
+
progress: {
|
|
456
|
+
stage: "initializing",
|
|
457
|
+
message: "Cloning repository from GitHub...",
|
|
458
|
+
percent: 5,
|
|
459
|
+
},
|
|
148
460
|
}));
|
|
149
|
-
onServerReady?.(port, url);
|
|
150
|
-
});
|
|
151
461
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
462
|
+
const git = new GitService(gitToken);
|
|
463
|
+
git
|
|
464
|
+
.cloneRepo(github, (percent, message) => {
|
|
465
|
+
setState((prev) => ({
|
|
466
|
+
...prev,
|
|
467
|
+
progress: { stage: "initializing", message, percent },
|
|
468
|
+
}));
|
|
469
|
+
})
|
|
470
|
+
.then((files) => {
|
|
471
|
+
// Infer entry command from package.json if not provided
|
|
472
|
+
let resolvedEntryCommand = entryCommand;
|
|
473
|
+
if (!propEntryCommand && files["package.json"]) {
|
|
474
|
+
try {
|
|
475
|
+
const pkg = JSON.parse(files["package.json"]);
|
|
476
|
+
if (pkg.scripts?.start) {
|
|
477
|
+
// Convert "node server.js" style commands
|
|
478
|
+
resolvedEntryCommand = pkg.scripts.start;
|
|
479
|
+
}
|
|
480
|
+
} catch {
|
|
481
|
+
/* use default */
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const runtime = bootWithFiles(files, resolvedEntryCommand, port);
|
|
486
|
+
|
|
487
|
+
// Start polling for branch changes
|
|
488
|
+
startPolling(github, gitToken);
|
|
489
|
+
})
|
|
490
|
+
.catch((err) => {
|
|
491
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
492
|
+
console.error(DBG, "GitHub clone failed:", err);
|
|
493
|
+
setState((prev) => ({
|
|
494
|
+
...prev,
|
|
495
|
+
status: "error",
|
|
496
|
+
error: msg,
|
|
497
|
+
progress: { stage: "error", message: msg, percent: 0 },
|
|
498
|
+
}));
|
|
499
|
+
onError?.(msg);
|
|
500
|
+
});
|
|
501
|
+
}
|
|
157
502
|
|
|
158
503
|
// Cleanup on unmount
|
|
159
504
|
return () => {
|
|
160
|
-
|
|
505
|
+
runtimeRef.current?.teardown();
|
|
161
506
|
runtimeRef.current = null;
|
|
507
|
+
if (pollIntervalRef.current) {
|
|
508
|
+
clearInterval(pollIntervalRef.current);
|
|
509
|
+
pollIntervalRef.current = null;
|
|
510
|
+
}
|
|
162
511
|
};
|
|
163
512
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
164
513
|
|
|
165
|
-
//
|
|
514
|
+
// -------------------------------------------------------------------------
|
|
515
|
+
// File editing
|
|
516
|
+
// -------------------------------------------------------------------------
|
|
517
|
+
|
|
166
518
|
const handleFileChange = useCallback(
|
|
167
519
|
async (path: string, content: string) => {
|
|
168
520
|
const runtime = runtimeRef.current;
|
|
169
|
-
if (!runtime)
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
521
|
+
if (!runtime) return;
|
|
172
522
|
|
|
173
|
-
// Write to Nodepod FS
|
|
174
523
|
try {
|
|
175
524
|
await runtime.writeFile(path, content);
|
|
176
525
|
} catch (err) {
|
|
177
526
|
console.error(`Failed to write file ${path}:`, err);
|
|
178
527
|
}
|
|
179
528
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
529
|
+
setState((prev) => {
|
|
530
|
+
const newFiles = { ...prev.files, [path]: content };
|
|
531
|
+
// Recompute change status for this file against originalFiles.
|
|
532
|
+
// Only update this file's status — don't recompute everything.
|
|
533
|
+
const newChanges = { ...prev.fileChanges };
|
|
534
|
+
if (!(path in prev.originalFiles)) {
|
|
535
|
+
newChanges[path] = "new";
|
|
536
|
+
} else if (prev.originalFiles[path] !== content) {
|
|
537
|
+
newChanges[path] = "modified";
|
|
538
|
+
} else {
|
|
539
|
+
// Reverted back to original — remove from changes
|
|
540
|
+
delete newChanges[path];
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return {
|
|
544
|
+
...prev,
|
|
545
|
+
files: newFiles,
|
|
546
|
+
fileChanges: newChanges,
|
|
547
|
+
};
|
|
548
|
+
});
|
|
185
549
|
|
|
186
550
|
onFileChange?.(path, content);
|
|
187
551
|
},
|
|
188
552
|
[onFileChange],
|
|
189
553
|
);
|
|
190
554
|
|
|
191
|
-
//
|
|
555
|
+
// -------------------------------------------------------------------------
|
|
556
|
+
// File selection
|
|
557
|
+
// -------------------------------------------------------------------------
|
|
558
|
+
|
|
192
559
|
const handleSelectFile = useCallback((path: string) => {
|
|
193
560
|
setSelectedFile(path);
|
|
194
561
|
setOpenFiles((prev) => (prev.includes(path) ? prev : [...prev, path]));
|
|
195
562
|
}, []);
|
|
196
563
|
|
|
197
|
-
// Close a file tab
|
|
198
564
|
const handleCloseFile = useCallback(
|
|
199
565
|
(path: string) => {
|
|
200
566
|
setOpenFiles((prev) => {
|
|
@@ -208,16 +574,20 @@ export function useRuntime(props: CodeSandboxProps) {
|
|
|
208
574
|
[selectedFile],
|
|
209
575
|
);
|
|
210
576
|
|
|
211
|
-
//
|
|
577
|
+
// -------------------------------------------------------------------------
|
|
578
|
+
// Server control
|
|
579
|
+
// -------------------------------------------------------------------------
|
|
580
|
+
|
|
212
581
|
const restart = useCallback(async () => {
|
|
213
582
|
const runtime = runtimeRef.current;
|
|
214
|
-
if (!runtime)
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
583
|
+
if (!runtime) return;
|
|
217
584
|
await runtime.restart();
|
|
218
585
|
}, []);
|
|
219
586
|
|
|
220
|
-
//
|
|
587
|
+
// -------------------------------------------------------------------------
|
|
588
|
+
// Git operations
|
|
589
|
+
// -------------------------------------------------------------------------
|
|
590
|
+
|
|
221
591
|
const getChangedFiles = useCallback((): FileMap => {
|
|
222
592
|
return runtimeRef.current?.getChangedFiles() ?? {};
|
|
223
593
|
}, []);
|
|
@@ -229,6 +599,7 @@ export function useRuntime(props: CodeSandboxProps) {
|
|
|
229
599
|
handleFileChange,
|
|
230
600
|
handleSelectFile,
|
|
231
601
|
handleCloseFile,
|
|
602
|
+
handleBrowserError,
|
|
232
603
|
restart,
|
|
233
604
|
getChangedFiles,
|
|
234
605
|
runtime: runtimeRef,
|