@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
|
@@ -7,7 +7,28 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import React, { useState } from "react";
|
|
10
|
-
import type {
|
|
10
|
+
import type {
|
|
11
|
+
FileMap,
|
|
12
|
+
FileNode,
|
|
13
|
+
FileTreeProps,
|
|
14
|
+
FileChangeStatus,
|
|
15
|
+
} from "../types";
|
|
16
|
+
|
|
17
|
+
/** Color for each file change status indicator dot */
|
|
18
|
+
const STATUS_COLORS: Record<FileChangeStatus, string> = {
|
|
19
|
+
new: "#4ade80", // green-400
|
|
20
|
+
modified: "#fb923c", // orange-400
|
|
21
|
+
deleted: "#f87171", // red-400
|
|
22
|
+
unchanged: "transparent",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Short label for each file change status (one character) */
|
|
26
|
+
const STATUS_LETTERS: Record<FileChangeStatus, string> = {
|
|
27
|
+
new: "N",
|
|
28
|
+
modified: "M",
|
|
29
|
+
deleted: "D",
|
|
30
|
+
unchanged: "",
|
|
31
|
+
};
|
|
11
32
|
|
|
12
33
|
/**
|
|
13
34
|
* Build a tree structure from a flat FileMap.
|
|
@@ -201,8 +222,14 @@ function FileIcon({ name }: { name: string }) {
|
|
|
201
222
|
|
|
202
223
|
/**
|
|
203
224
|
* FileTree component — renders a collapsible tree of files and folders.
|
|
225
|
+
* Shows change status indicators (N/M/D) next to modified files.
|
|
204
226
|
*/
|
|
205
|
-
export function FileTree({
|
|
227
|
+
export function FileTree({
|
|
228
|
+
files,
|
|
229
|
+
selectedFile,
|
|
230
|
+
onSelectFile,
|
|
231
|
+
fileChanges,
|
|
232
|
+
}: FileTreeProps) {
|
|
206
233
|
return (
|
|
207
234
|
<div className="h-full overflow-auto bg-sb-sidebar text-sm select-none">
|
|
208
235
|
<div className="px-3 py-2 text-[11px] font-semibold text-sb-text-muted uppercase tracking-wider border-b border-sb-border">
|
|
@@ -216,6 +243,7 @@ export function FileTree({ files, selectedFile, onSelectFile }: FileTreeProps) {
|
|
|
216
243
|
depth={0}
|
|
217
244
|
selectedFile={selectedFile}
|
|
218
245
|
onSelectFile={onSelectFile}
|
|
246
|
+
fileChanges={fileChanges}
|
|
219
247
|
/>
|
|
220
248
|
))}
|
|
221
249
|
</div>
|
|
@@ -229,18 +257,36 @@ function TreeNode({
|
|
|
229
257
|
depth,
|
|
230
258
|
selectedFile,
|
|
231
259
|
onSelectFile,
|
|
260
|
+
fileChanges,
|
|
232
261
|
}: {
|
|
233
262
|
node: FileNode;
|
|
234
263
|
depth: number;
|
|
235
264
|
selectedFile: string | null;
|
|
236
265
|
onSelectFile: (path: string) => void;
|
|
266
|
+
fileChanges?: Record<string, FileChangeStatus>;
|
|
237
267
|
}) {
|
|
238
268
|
const [expanded, setExpanded] = useState(depth < 2);
|
|
239
269
|
|
|
240
270
|
const isSelected = node.path === selectedFile;
|
|
241
271
|
const paddingLeft = 8 + depth * 16;
|
|
272
|
+
const changeStatus: FileChangeStatus =
|
|
273
|
+
fileChanges?.[node.path] ?? "unchanged";
|
|
242
274
|
|
|
243
275
|
if (node.type === "directory") {
|
|
276
|
+
// Check if any child has changes (to show a subtle indicator on folders)
|
|
277
|
+
const hasChangedChild =
|
|
278
|
+
fileChanges &&
|
|
279
|
+
node.children?.some((c) => {
|
|
280
|
+
if (fileChanges[c.path]) return true;
|
|
281
|
+
// For directories, check recursively by prefix
|
|
282
|
+
if (c.type === "directory") {
|
|
283
|
+
return Object.keys(fileChanges).some(
|
|
284
|
+
(p) => p.startsWith(c.path + "/") && fileChanges[p] !== "unchanged",
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
return false;
|
|
288
|
+
});
|
|
289
|
+
|
|
244
290
|
return (
|
|
245
291
|
<div>
|
|
246
292
|
<button
|
|
@@ -252,7 +298,13 @@ function TreeNode({
|
|
|
252
298
|
>
|
|
253
299
|
<ChevronIcon expanded={expanded} />
|
|
254
300
|
<FolderIcon open={expanded} />
|
|
255
|
-
<span className="truncate ml-0.5">{node.name}</span>
|
|
301
|
+
<span className="truncate ml-0.5 flex-1">{node.name}</span>
|
|
302
|
+
{hasChangedChild && (
|
|
303
|
+
<span
|
|
304
|
+
className="w-1.5 h-1.5 rounded-full shrink-0 mr-2"
|
|
305
|
+
style={{ backgroundColor: "#fb923c" }}
|
|
306
|
+
/>
|
|
307
|
+
)}
|
|
256
308
|
</button>
|
|
257
309
|
{expanded && node.children && (
|
|
258
310
|
<div>
|
|
@@ -263,6 +315,7 @@ function TreeNode({
|
|
|
263
315
|
depth={depth + 1}
|
|
264
316
|
selectedFile={selectedFile}
|
|
265
317
|
onSelectFile={onSelectFile}
|
|
318
|
+
fileChanges={fileChanges}
|
|
266
319
|
/>
|
|
267
320
|
))}
|
|
268
321
|
</div>
|
|
@@ -280,7 +333,16 @@ function TreeNode({
|
|
|
280
333
|
onClick={() => onSelectFile(node.path)}
|
|
281
334
|
>
|
|
282
335
|
<FileIcon name={node.name} />
|
|
283
|
-
<span className="truncate ml-0.5">{node.name}</span>
|
|
336
|
+
<span className="truncate ml-0.5 flex-1">{node.name}</span>
|
|
337
|
+
{changeStatus !== "unchanged" && (
|
|
338
|
+
<span
|
|
339
|
+
className="text-[9px] font-bold shrink-0 mr-2"
|
|
340
|
+
style={{ color: STATUS_COLORS[changeStatus] }}
|
|
341
|
+
title={changeStatus}
|
|
342
|
+
>
|
|
343
|
+
{STATUS_LETTERS[changeStatus]}
|
|
344
|
+
</span>
|
|
345
|
+
)}
|
|
284
346
|
</button>
|
|
285
347
|
);
|
|
286
348
|
}
|
|
@@ -1,22 +1,204 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Preview — Full-width iframe that renders the Nodepod virtual server.
|
|
3
3
|
*
|
|
4
|
-
* Loads the URL from Nodepod's Service Worker proxy (/
|
|
4
|
+
* Loads the URL from Nodepod's Service Worker proxy (/__preview__/PORT/).
|
|
5
5
|
* The toolbar handles the URL bar and refresh — this is just the iframe.
|
|
6
|
+
*
|
|
7
|
+
* Error capture: After the iframe loads, we inject a small script that
|
|
8
|
+
* hooks `window.onerror`, `window.onunhandledrejection`, and
|
|
9
|
+
* `console.error` inside the iframe. These are relayed to the parent
|
|
10
|
+
* via `postMessage`, where we parse them into SandboxError objects
|
|
11
|
+
* and forward to the `onBrowserError` callback.
|
|
12
|
+
*
|
|
13
|
+
* The iframe and parent are same-origin (both served through the
|
|
14
|
+
* Nodepod Service Worker), so direct contentWindow access works.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import React, { useRef, useEffect, useCallback } from "react";
|
|
18
|
+
import type {
|
|
19
|
+
PreviewProps,
|
|
20
|
+
SandboxError,
|
|
21
|
+
SandboxErrorCategory,
|
|
22
|
+
} from "../types";
|
|
23
|
+
|
|
24
|
+
/** Message type sent from the error-capture script inside the iframe */
|
|
25
|
+
interface IframeErrorMessage {
|
|
26
|
+
type: "sandbox-error";
|
|
27
|
+
category: SandboxErrorCategory;
|
|
28
|
+
message: string;
|
|
29
|
+
stack?: string;
|
|
30
|
+
filename?: string;
|
|
31
|
+
lineno?: number;
|
|
32
|
+
colno?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build the error-capture script injected into the iframe.
|
|
37
|
+
*
|
|
38
|
+
* This script runs inside the preview iframe's global scope and
|
|
39
|
+
* intercepts three error vectors:
|
|
40
|
+
* 1. window.onerror — uncaught exceptions
|
|
41
|
+
* 2. window.onunhandledrejection — unhandled Promise rejections
|
|
42
|
+
* 3. console.error — explicit error logging
|
|
43
|
+
*
|
|
44
|
+
* Each error is posted to the parent via postMessage with a
|
|
45
|
+
* structured payload so the parent can construct a SandboxError.
|
|
6
46
|
*/
|
|
47
|
+
function buildErrorCaptureScript(): string {
|
|
48
|
+
return `
|
|
49
|
+
(function() {
|
|
50
|
+
// Guard against double-injection
|
|
51
|
+
if (window.__sandboxErrorCapture) return;
|
|
52
|
+
window.__sandboxErrorCapture = true;
|
|
53
|
+
|
|
54
|
+
var post = function(category, message, stack, filename, lineno, colno) {
|
|
55
|
+
try {
|
|
56
|
+
window.parent.postMessage({
|
|
57
|
+
type: 'sandbox-error',
|
|
58
|
+
category: category,
|
|
59
|
+
message: String(message || 'Unknown error'),
|
|
60
|
+
stack: stack || undefined,
|
|
61
|
+
filename: filename || undefined,
|
|
62
|
+
lineno: lineno || undefined,
|
|
63
|
+
colno: colno || undefined
|
|
64
|
+
}, '*');
|
|
65
|
+
} catch(e) { /* swallow — parent may not be listening */ }
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// 1. Uncaught exceptions
|
|
69
|
+
window.onerror = function(msg, source, lineno, colno, error) {
|
|
70
|
+
post(
|
|
71
|
+
'browser-error',
|
|
72
|
+
msg,
|
|
73
|
+
error && error.stack ? error.stack : undefined,
|
|
74
|
+
source,
|
|
75
|
+
lineno,
|
|
76
|
+
colno
|
|
77
|
+
);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// 2. Unhandled promise rejections
|
|
81
|
+
window.onunhandledrejection = function(event) {
|
|
82
|
+
var reason = event.reason;
|
|
83
|
+
var msg = reason instanceof Error ? reason.message : String(reason);
|
|
84
|
+
var stack = reason instanceof Error ? reason.stack : undefined;
|
|
85
|
+
post('browser-unhandled-rejection', msg, stack);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// 3. console.error intercept
|
|
89
|
+
var origError = console.error;
|
|
90
|
+
console.error = function() {
|
|
91
|
+
// Forward to original so DevTools still shows it
|
|
92
|
+
origError.apply(console, arguments);
|
|
93
|
+
var parts = [];
|
|
94
|
+
for (var i = 0; i < arguments.length; i++) {
|
|
95
|
+
var arg = arguments[i];
|
|
96
|
+
if (arg instanceof Error) {
|
|
97
|
+
parts.push(arg.message);
|
|
98
|
+
// Use the stack from the first Error argument
|
|
99
|
+
if (parts.length === 1) {
|
|
100
|
+
post('browser-console-error', arg.message, arg.stack);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
parts.push(String(arg));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
post('browser-console-error', parts.join(' '));
|
|
108
|
+
};
|
|
109
|
+
})();
|
|
110
|
+
`;
|
|
111
|
+
}
|
|
7
112
|
|
|
8
|
-
|
|
9
|
-
|
|
113
|
+
/** Counter for unique error IDs within this component instance */
|
|
114
|
+
let browserErrorCounter = 0;
|
|
10
115
|
|
|
11
116
|
/**
|
|
12
117
|
* Preview component — renders the dev server output in a full-width iframe.
|
|
13
118
|
*
|
|
14
|
-
* The URL points to /
|
|
119
|
+
* The URL points to /__preview__/PORT/ which is intercepted by the
|
|
15
120
|
* Nodepod Service Worker and proxied to the Express server running
|
|
16
121
|
* in the Web Worker.
|
|
122
|
+
*
|
|
123
|
+
* After the iframe loads, an error-capture script is injected so that
|
|
124
|
+
* runtime errors inside the preview are surfaced to the AI agent.
|
|
17
125
|
*/
|
|
18
|
-
export function Preview({ url, className = "" }: PreviewProps) {
|
|
126
|
+
export function Preview({ url, className = "", onBrowserError }: PreviewProps) {
|
|
19
127
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
128
|
+
const onBrowserErrorRef = useRef(onBrowserError);
|
|
129
|
+
onBrowserErrorRef.current = onBrowserError;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Inject the error-capture script into the iframe's contentWindow.
|
|
133
|
+
*
|
|
134
|
+
* Called on iframe `load` event. We use direct contentWindow access
|
|
135
|
+
* (same-origin) rather than srcdoc to avoid breaking the SW proxy.
|
|
136
|
+
*/
|
|
137
|
+
const injectErrorCapture = useCallback(() => {
|
|
138
|
+
const iframe = iframeRef.current;
|
|
139
|
+
if (!iframe) return;
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const iframeDoc =
|
|
143
|
+
iframe.contentDocument || iframe.contentWindow?.document;
|
|
144
|
+
if (!iframeDoc) return;
|
|
145
|
+
|
|
146
|
+
const script = iframeDoc.createElement("script");
|
|
147
|
+
script.textContent = buildErrorCaptureScript();
|
|
148
|
+
// Append to <head> (or <body> if <head> doesn't exist)
|
|
149
|
+
const target =
|
|
150
|
+
iframeDoc.head || iframeDoc.body || iframeDoc.documentElement;
|
|
151
|
+
if (target) {
|
|
152
|
+
target.appendChild(script);
|
|
153
|
+
}
|
|
154
|
+
} catch (err) {
|
|
155
|
+
// Cross-origin restriction — shouldn't happen with SW proxy but
|
|
156
|
+
// degrade gracefully if it does.
|
|
157
|
+
console.warn(
|
|
158
|
+
"[CodeSandbox:Preview] Could not inject error capture:",
|
|
159
|
+
err,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
}, []);
|
|
163
|
+
|
|
164
|
+
// Listen for postMessage from the iframe
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
const handler = (event: MessageEvent) => {
|
|
167
|
+
if (!event.data || event.data.type !== "sandbox-error") return;
|
|
168
|
+
|
|
169
|
+
const data = event.data as IframeErrorMessage;
|
|
170
|
+
const cb = onBrowserErrorRef.current;
|
|
171
|
+
if (!cb) return;
|
|
172
|
+
|
|
173
|
+
// Parse filename to extract relative file path.
|
|
174
|
+
// The iframe's script src URLs look like:
|
|
175
|
+
// https://host/__preview__/3000/js/app.js
|
|
176
|
+
// We want just "public/js/app.js" or "js/app.js".
|
|
177
|
+
let filePath: string | undefined;
|
|
178
|
+
if (data.filename) {
|
|
179
|
+
const match = data.filename.match(
|
|
180
|
+
/\/__(?:preview|virtual)__\/\d+(\/.*)/,
|
|
181
|
+
);
|
|
182
|
+
filePath = match ? match[1].replace(/^\//, "") : undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const error: SandboxError = {
|
|
186
|
+
id: `browser_${++browserErrorCounter}_${Date.now()}`,
|
|
187
|
+
category: data.category,
|
|
188
|
+
message: data.message,
|
|
189
|
+
stack: data.stack,
|
|
190
|
+
filePath,
|
|
191
|
+
line: data.lineno ?? undefined,
|
|
192
|
+
column: data.colno ?? undefined,
|
|
193
|
+
timestamp: new Date().toISOString(),
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
cb(error);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
window.addEventListener("message", handler);
|
|
200
|
+
return () => window.removeEventListener("message", handler);
|
|
201
|
+
}, []);
|
|
20
202
|
|
|
21
203
|
// Reload iframe when URL changes
|
|
22
204
|
useEffect(() => {
|
|
@@ -34,6 +216,7 @@ export function Preview({ url, className = "" }: PreviewProps) {
|
|
|
34
216
|
className="w-full h-full border-none"
|
|
35
217
|
title="Preview"
|
|
36
218
|
sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-modals"
|
|
219
|
+
onLoad={injectErrorCapture}
|
|
37
220
|
/>
|
|
38
221
|
) : (
|
|
39
222
|
<div className="flex items-center justify-center h-full bg-sb-bg">
|
|
@@ -20,6 +20,8 @@ import React, {
|
|
|
20
20
|
useEffect,
|
|
21
21
|
useCallback,
|
|
22
22
|
useRef,
|
|
23
|
+
useImperativeHandle,
|
|
24
|
+
forwardRef,
|
|
23
25
|
} from "react";
|
|
24
26
|
import { Allotment } from "allotment";
|
|
25
27
|
import { motion, AnimatePresence } from "framer-motion";
|
|
@@ -32,7 +34,11 @@ import { Preview } from "./Preview";
|
|
|
32
34
|
import { BootOverlay } from "./BootOverlay";
|
|
33
35
|
import { ViewSlider, type WorkbenchView } from "./ViewSlider";
|
|
34
36
|
import { useRuntime } from "../hooks/useRuntime";
|
|
35
|
-
import type {
|
|
37
|
+
import type {
|
|
38
|
+
CodeSandboxProps,
|
|
39
|
+
CodeSandboxHandle,
|
|
40
|
+
FileChangeStatus,
|
|
41
|
+
} from "../types";
|
|
36
42
|
|
|
37
43
|
/** Transition for view crossfade */
|
|
38
44
|
const VIEW_TRANSITION = {
|
|
@@ -50,99 +56,142 @@ const VIEW_TRANSITION = {
|
|
|
50
56
|
* A compact toolbar row contains the view toggle and (in preview mode)
|
|
51
57
|
* a URL bar with refresh button.
|
|
52
58
|
*/
|
|
53
|
-
export
|
|
54
|
-
|
|
59
|
+
export const CodeSandbox = forwardRef<CodeSandboxHandle, CodeSandboxProps>(
|
|
60
|
+
function CodeSandbox(props, ref) {
|
|
61
|
+
const { className = "", height = "100vh", port = 3000 } = props;
|
|
55
62
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
const runtime = useRuntime(props);
|
|
64
|
+
const {
|
|
65
|
+
state,
|
|
66
|
+
selectedFile,
|
|
67
|
+
openFiles,
|
|
68
|
+
handleFileChange,
|
|
69
|
+
handleSelectFile,
|
|
70
|
+
handleCloseFile,
|
|
71
|
+
handleBrowserError,
|
|
72
|
+
restart,
|
|
73
|
+
updateFiles,
|
|
74
|
+
updateFile,
|
|
75
|
+
getFiles,
|
|
76
|
+
getChangedFiles,
|
|
77
|
+
getFileChanges,
|
|
78
|
+
getErrors,
|
|
79
|
+
getState,
|
|
80
|
+
} = runtime;
|
|
65
81
|
|
|
66
|
-
|
|
67
|
-
|
|
82
|
+
// Expose imperative handle to parent via ref
|
|
83
|
+
useImperativeHandle(
|
|
84
|
+
ref,
|
|
85
|
+
() => ({
|
|
86
|
+
updateFiles,
|
|
87
|
+
updateFile,
|
|
88
|
+
restart,
|
|
89
|
+
getFiles,
|
|
90
|
+
getChangedFiles,
|
|
91
|
+
getFileChanges,
|
|
92
|
+
getErrors,
|
|
93
|
+
getState,
|
|
94
|
+
}),
|
|
95
|
+
[
|
|
96
|
+
updateFiles,
|
|
97
|
+
updateFile,
|
|
98
|
+
restart,
|
|
99
|
+
getFiles,
|
|
100
|
+
getChangedFiles,
|
|
101
|
+
getFileChanges,
|
|
102
|
+
getErrors,
|
|
103
|
+
getState,
|
|
104
|
+
],
|
|
105
|
+
);
|
|
68
106
|
|
|
69
|
-
|
|
70
|
-
|
|
107
|
+
const fileTree = useMemo(() => buildFileTree(state.files), [state.files]);
|
|
108
|
+
const isBooting = state.status !== "ready" && state.status !== "error";
|
|
71
109
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
useEffect(() => {
|
|
75
|
-
if (hasPreview) {
|
|
76
|
-
setActiveView("preview");
|
|
77
|
-
}
|
|
78
|
-
}, [hasPreview]);
|
|
110
|
+
// Active view: code or preview
|
|
111
|
+
const [activeView, setActiveView] = useState<WorkbenchView>("code");
|
|
79
112
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
(
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
[
|
|
87
|
-
);
|
|
113
|
+
// Auto-switch to preview when server becomes ready
|
|
114
|
+
const hasPreview = !!state.previewUrl;
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (hasPreview) {
|
|
117
|
+
setActiveView("preview");
|
|
118
|
+
}
|
|
119
|
+
}, [hasPreview]);
|
|
88
120
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
121
|
+
// Switch to code view when a file is selected from the tree
|
|
122
|
+
const onFileSelect = useCallback(
|
|
123
|
+
(path: string) => {
|
|
124
|
+
handleSelectFile(path);
|
|
125
|
+
setActiveView("code");
|
|
126
|
+
},
|
|
127
|
+
[handleSelectFile],
|
|
128
|
+
);
|
|
96
129
|
|
|
97
|
-
|
|
98
|
-
<
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
/>
|
|
130
|
+
return (
|
|
131
|
+
<div
|
|
132
|
+
className={`sb-root relative w-full bg-sb-bg text-sb-text overflow-hidden flex flex-col ${className}`}
|
|
133
|
+
style={{ height }}
|
|
134
|
+
>
|
|
135
|
+
{/* Boot overlay */}
|
|
136
|
+
{isBooting && <BootOverlay progress={state.progress} />}
|
|
105
137
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
pointerEvents: activeView === "code" ? "auto" : "none",
|
|
115
|
-
}}
|
|
116
|
-
transition={{ duration: 0.15 }}
|
|
117
|
-
>
|
|
118
|
-
<CodeView
|
|
119
|
-
fileTree={fileTree}
|
|
120
|
-
files={state.files}
|
|
121
|
-
selectedFile={selectedFile}
|
|
122
|
-
openFiles={openFiles}
|
|
123
|
-
terminalOutput={state.terminalOutput}
|
|
124
|
-
onSelectFile={onFileSelect}
|
|
125
|
-
onCloseFile={handleCloseFile}
|
|
126
|
-
onFileChange={handleFileChange}
|
|
127
|
-
/>
|
|
128
|
-
</motion.div>
|
|
138
|
+
{/* Toolbar — compact row with view toggle + URL bar */}
|
|
139
|
+
<Toolbar
|
|
140
|
+
activeView={activeView}
|
|
141
|
+
onViewChange={setActiveView}
|
|
142
|
+
previewUrl={state.previewUrl}
|
|
143
|
+
port={port}
|
|
144
|
+
onRefresh={restart}
|
|
145
|
+
/>
|
|
129
146
|
|
|
130
|
-
{/*
|
|
131
|
-
<
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
147
|
+
{/* Views — full width, toggled */}
|
|
148
|
+
<div className="flex-1 relative overflow-hidden min-h-0">
|
|
149
|
+
{/* Code view */}
|
|
150
|
+
<motion.div
|
|
151
|
+
className="absolute inset-0"
|
|
152
|
+
initial={false}
|
|
153
|
+
animate={{
|
|
154
|
+
opacity: activeView === "code" ? 1 : 0,
|
|
155
|
+
pointerEvents: activeView === "code" ? "auto" : "none",
|
|
156
|
+
}}
|
|
157
|
+
transition={{ duration: 0.15 }}
|
|
158
|
+
>
|
|
159
|
+
<CodeView
|
|
160
|
+
fileTree={fileTree}
|
|
161
|
+
files={state.files}
|
|
162
|
+
originalFiles={state.originalFiles}
|
|
163
|
+
fileChanges={state.fileChanges}
|
|
164
|
+
selectedFile={selectedFile}
|
|
165
|
+
openFiles={openFiles}
|
|
166
|
+
terminalOutput={state.terminalOutput}
|
|
167
|
+
onSelectFile={onFileSelect}
|
|
168
|
+
onCloseFile={handleCloseFile}
|
|
169
|
+
onFileChange={handleFileChange}
|
|
170
|
+
/>
|
|
171
|
+
</motion.div>
|
|
172
|
+
|
|
173
|
+
{/* Preview view */}
|
|
174
|
+
<motion.div
|
|
175
|
+
className="absolute inset-0"
|
|
176
|
+
initial={false}
|
|
177
|
+
animate={{
|
|
178
|
+
opacity: activeView === "preview" ? 1 : 0,
|
|
179
|
+
pointerEvents: activeView === "preview" ? "auto" : "none",
|
|
180
|
+
}}
|
|
181
|
+
transition={{ duration: 0.15 }}
|
|
182
|
+
>
|
|
183
|
+
<Preview
|
|
184
|
+
url={state.previewUrl}
|
|
185
|
+
onBrowserError={handleBrowserError}
|
|
186
|
+
/>
|
|
187
|
+
</motion.div>
|
|
188
|
+
</div>
|
|
142
189
|
</div>
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
190
|
+
);
|
|
191
|
+
},
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
CodeSandbox.displayName = "CodeSandbox";
|
|
146
195
|
|
|
147
196
|
// ---------------------------------------------------------------------------
|
|
148
197
|
// Toolbar
|
|
@@ -241,6 +290,8 @@ function RefreshIcon() {
|
|
|
241
290
|
interface CodeViewProps {
|
|
242
291
|
fileTree: ReturnType<typeof buildFileTree>;
|
|
243
292
|
files: Record<string, string>;
|
|
293
|
+
originalFiles: Record<string, string>;
|
|
294
|
+
fileChanges: Record<string, FileChangeStatus>;
|
|
244
295
|
selectedFile: string | null;
|
|
245
296
|
openFiles: string[];
|
|
246
297
|
terminalOutput: string[];
|
|
@@ -257,6 +308,8 @@ interface CodeViewProps {
|
|
|
257
308
|
function CodeView({
|
|
258
309
|
fileTree,
|
|
259
310
|
files,
|
|
311
|
+
originalFiles,
|
|
312
|
+
fileChanges,
|
|
260
313
|
selectedFile,
|
|
261
314
|
openFiles,
|
|
262
315
|
terminalOutput,
|
|
@@ -273,6 +326,7 @@ function CodeView({
|
|
|
273
326
|
files={fileTree}
|
|
274
327
|
selectedFile={selectedFile}
|
|
275
328
|
onSelectFile={onSelectFile}
|
|
329
|
+
fileChanges={fileChanges}
|
|
276
330
|
/>
|
|
277
331
|
</Allotment.Pane>
|
|
278
332
|
|
|
@@ -282,6 +336,8 @@ function CodeView({
|
|
|
282
336
|
<Allotment.Pane preferredSize="70%">
|
|
283
337
|
<CodeEditor
|
|
284
338
|
files={files}
|
|
339
|
+
originalFiles={originalFiles}
|
|
340
|
+
fileChanges={fileChanges}
|
|
285
341
|
activeFile={selectedFile}
|
|
286
342
|
openFiles={openFiles}
|
|
287
343
|
onSelectFile={onSelectFile}
|