@illuma-ai/code-sandbox 1.0.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.
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Workbench — Main layout component for the code sandbox.
3
+ *
4
+ * Two full-width views toggled via a pill slider in the toolbar:
5
+ * - Code: FileTree (left) + Editor (top-right) + Terminal (bottom-right)
6
+ * - Preview: Full-width browser iframe with URL bar
7
+ *
8
+ * The toggle lives inside a compact toolbar row shared with the URL bar.
9
+ * Both views are full-width — Preview has no file tree, Code has no iframe.
10
+ *
11
+ * Responsive: works from ~320px up. On narrow screens the file tree
12
+ * collapses to a smaller width, and the allotment handles overflow.
13
+ *
14
+ * @see bolt.diy: app/components/workbench/Workbench.client.tsx (reference)
15
+ */
16
+
17
+ import React, {
18
+ useMemo,
19
+ useState,
20
+ useEffect,
21
+ useCallback,
22
+ useRef,
23
+ } from "react";
24
+ import { Allotment } from "allotment";
25
+ import { motion, AnimatePresence } from "framer-motion";
26
+ import "allotment/dist/style.css";
27
+
28
+ import { buildFileTree, FileTree } from "./FileTree";
29
+ import { CodeEditor } from "./CodeEditor";
30
+ import { Terminal } from "./Terminal";
31
+ import { Preview } from "./Preview";
32
+ import { BootOverlay } from "./BootOverlay";
33
+ import { ViewSlider, type WorkbenchView } from "./ViewSlider";
34
+ import { useRuntime } from "../hooks/useRuntime";
35
+ import type { CodeSandboxProps } from "../types";
36
+
37
+ /** Transition for view crossfade */
38
+ const VIEW_TRANSITION = {
39
+ duration: 0.2,
40
+ ease: [0.4, 0, 0.2, 1] as [number, number, number, number],
41
+ };
42
+
43
+ /**
44
+ * CodeSandbox — The main entrypoint component.
45
+ *
46
+ * Renders a complete IDE-like workbench with two full-width views:
47
+ * - Code: file tree + editor + terminal (split panes)
48
+ * - Preview: full-width browser iframe
49
+ *
50
+ * A compact toolbar row contains the view toggle and (in preview mode)
51
+ * a URL bar with refresh button.
52
+ */
53
+ export function CodeSandbox(props: CodeSandboxProps) {
54
+ const { className = "", height = "100vh", port = 3000 } = props;
55
+
56
+ const {
57
+ state,
58
+ selectedFile,
59
+ openFiles,
60
+ handleFileChange,
61
+ handleSelectFile,
62
+ handleCloseFile,
63
+ restart,
64
+ } = useRuntime(props);
65
+
66
+ const fileTree = useMemo(() => buildFileTree(state.files), [state.files]);
67
+ const isBooting = state.status !== "ready" && state.status !== "error";
68
+
69
+ // Active view: code or preview
70
+ const [activeView, setActiveView] = useState<WorkbenchView>("code");
71
+
72
+ // Auto-switch to preview when server becomes ready
73
+ const hasPreview = !!state.previewUrl;
74
+ useEffect(() => {
75
+ if (hasPreview) {
76
+ setActiveView("preview");
77
+ }
78
+ }, [hasPreview]);
79
+
80
+ // Switch to code view when a file is selected from the tree
81
+ const onFileSelect = useCallback(
82
+ (path: string) => {
83
+ handleSelectFile(path);
84
+ setActiveView("code");
85
+ },
86
+ [handleSelectFile],
87
+ );
88
+
89
+ return (
90
+ <div
91
+ className={`sb-root relative w-full bg-sb-bg text-sb-text overflow-hidden flex flex-col ${className}`}
92
+ style={{ height }}
93
+ >
94
+ {/* Boot overlay */}
95
+ {isBooting && <BootOverlay progress={state.progress} />}
96
+
97
+ {/* Toolbar — compact row with view toggle + URL bar */}
98
+ <Toolbar
99
+ activeView={activeView}
100
+ onViewChange={setActiveView}
101
+ previewUrl={state.previewUrl}
102
+ port={port}
103
+ onRefresh={restart}
104
+ />
105
+
106
+ {/* Views — full width, toggled */}
107
+ <div className="flex-1 relative overflow-hidden min-h-0">
108
+ {/* Code view */}
109
+ <motion.div
110
+ className="absolute inset-0"
111
+ initial={false}
112
+ animate={{
113
+ opacity: activeView === "code" ? 1 : 0,
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>
129
+
130
+ {/* Preview view */}
131
+ <motion.div
132
+ className="absolute inset-0"
133
+ initial={false}
134
+ animate={{
135
+ opacity: activeView === "preview" ? 1 : 0,
136
+ pointerEvents: activeView === "preview" ? "auto" : "none",
137
+ }}
138
+ transition={{ duration: 0.15 }}
139
+ >
140
+ <Preview url={state.previewUrl} />
141
+ </motion.div>
142
+ </div>
143
+ </div>
144
+ );
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Toolbar
149
+ // ---------------------------------------------------------------------------
150
+
151
+ interface ToolbarProps {
152
+ activeView: WorkbenchView;
153
+ onViewChange: (view: WorkbenchView) => void;
154
+ previewUrl: string | null;
155
+ port: number;
156
+ onRefresh?: () => void;
157
+ }
158
+
159
+ /**
160
+ * Formats the internal preview URL into a friendly display URL.
161
+ *
162
+ * The actual preview URL is something like `https://host/__preview__/3000/path`
163
+ * (a Service Worker proxy URL). This is meaningless to the user — the app runs
164
+ * entirely in their browser. We show `localhost:{port}/path` instead, which
165
+ * matches what they'd see from a real local dev server.
166
+ */
167
+ function formatDisplayUrl(previewUrl: string | null, port: number): string {
168
+ if (!previewUrl) return "";
169
+
170
+ // Extract the path portion after /__preview__/PORT or /__virtual__/PORT
171
+ const match = previewUrl.match(/\/__(?:preview|virtual)__\/\d+(\/.*)?$/);
172
+ const path = match?.[1] ?? "/";
173
+ return `localhost:${port}${path}`;
174
+ }
175
+
176
+ /**
177
+ * Toolbar — single compact row with view toggle + URL bar.
178
+ * URL bar only shows in preview mode. In code mode the space is
179
+ * used for a subtle status label.
180
+ */
181
+ function Toolbar({
182
+ activeView,
183
+ onViewChange,
184
+ previewUrl,
185
+ port,
186
+ onRefresh,
187
+ }: ToolbarProps) {
188
+ const displayUrl = formatDisplayUrl(previewUrl, port);
189
+
190
+ return (
191
+ <div className="flex items-center gap-2 px-2 py-1 bg-sb-bg-alt border-b border-sb-border min-h-[36px] shrink-0">
192
+ {/* View toggle — always visible */}
193
+ <ViewSlider selected={activeView} onSelect={onViewChange} />
194
+
195
+ {/* Right side: URL bar in preview mode, status in code mode */}
196
+ <div className="flex-1 min-w-0">
197
+ {activeView === "preview" ? (
198
+ <div className="flex items-center gap-1.5">
199
+ <button
200
+ className="shrink-0 p-1 rounded text-sb-text-muted hover:text-sb-text-active hover:bg-sb-bg-hover transition-colors text-xs"
201
+ onClick={onRefresh}
202
+ title="Refresh preview"
203
+ >
204
+ <RefreshIcon />
205
+ </button>
206
+ <div className="flex-1 min-w-0 bg-sb-bg rounded px-2 py-0.5 text-xs text-sb-text-muted font-mono truncate">
207
+ {previewUrl ? displayUrl : "Waiting for server..."}
208
+ </div>
209
+ </div>
210
+ ) : (
211
+ <div className="text-xs text-sb-text-muted px-1 truncate">Editor</div>
212
+ )}
213
+ </div>
214
+ </div>
215
+ );
216
+ }
217
+
218
+ /** Small inline refresh icon (SVG) */
219
+ function RefreshIcon() {
220
+ return (
221
+ <svg
222
+ width="14"
223
+ height="14"
224
+ viewBox="0 0 24 24"
225
+ fill="none"
226
+ stroke="currentColor"
227
+ strokeWidth="2"
228
+ strokeLinecap="round"
229
+ strokeLinejoin="round"
230
+ >
231
+ <polyline points="23 4 23 10 17 10" />
232
+ <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
233
+ </svg>
234
+ );
235
+ }
236
+
237
+ // ---------------------------------------------------------------------------
238
+ // CodeView — full-width code editing layout
239
+ // ---------------------------------------------------------------------------
240
+
241
+ interface CodeViewProps {
242
+ fileTree: ReturnType<typeof buildFileTree>;
243
+ files: Record<string, string>;
244
+ selectedFile: string | null;
245
+ openFiles: string[];
246
+ terminalOutput: string[];
247
+ onSelectFile: (path: string) => void;
248
+ onCloseFile: (path: string) => void;
249
+ onFileChange: (path: string, content: string) => void;
250
+ }
251
+
252
+ /**
253
+ * CodeView — FileTree (left) + Editor (top-right) + Terminal (bottom-right).
254
+ * Uses Allotment for resizable split panes.
255
+ * Responsive: file tree shrinks on narrow containers.
256
+ */
257
+ function CodeView({
258
+ fileTree,
259
+ files,
260
+ selectedFile,
261
+ openFiles,
262
+ terminalOutput,
263
+ onSelectFile,
264
+ onCloseFile,
265
+ onFileChange,
266
+ }: CodeViewProps) {
267
+ return (
268
+ <div className="h-full w-full">
269
+ <Allotment>
270
+ {/* File tree — left */}
271
+ <Allotment.Pane preferredSize={200} minSize={120} maxSize={350}>
272
+ <FileTree
273
+ files={fileTree}
274
+ selectedFile={selectedFile}
275
+ onSelectFile={onSelectFile}
276
+ />
277
+ </Allotment.Pane>
278
+
279
+ {/* Editor + Terminal — right */}
280
+ <Allotment.Pane>
281
+ <Allotment vertical>
282
+ <Allotment.Pane preferredSize="70%">
283
+ <CodeEditor
284
+ files={files}
285
+ activeFile={selectedFile}
286
+ openFiles={openFiles}
287
+ onSelectFile={onSelectFile}
288
+ onCloseFile={onCloseFile}
289
+ onFileChange={onFileChange}
290
+ />
291
+ </Allotment.Pane>
292
+
293
+ <Allotment.Pane preferredSize="30%" minSize={60}>
294
+ <Terminal output={terminalOutput} />
295
+ </Allotment.Pane>
296
+ </Allotment>
297
+ </Allotment.Pane>
298
+ </Allotment>
299
+ </div>
300
+ );
301
+ }
@@ -0,0 +1,236 @@
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
+
8
+ import { useCallback, useEffect, useRef, useState } from "react";
9
+ import { NodepodRuntime } from "../services/runtime";
10
+ import { getTemplate } from "../templates";
11
+ import type {
12
+ BootProgress,
13
+ CodeSandboxProps,
14
+ FileMap,
15
+ RuntimeState,
16
+ } from "../types";
17
+
18
+ /**
19
+ * Hook that manages the full Nodepod runtime lifecycle.
20
+ *
21
+ * @param props - CodeSandbox component props
22
+ * @returns Reactive runtime state + control functions
23
+ */
24
+ export function useRuntime(props: CodeSandboxProps) {
25
+ const {
26
+ files: propFiles,
27
+ template,
28
+ entryCommand: propEntryCommand,
29
+ port: propPort,
30
+ env,
31
+ onFileChange,
32
+ onServerReady,
33
+ onProgress,
34
+ onError,
35
+ } = props;
36
+
37
+ const runtimeRef = useRef<NodepodRuntime | null>(null);
38
+ const bootedRef = useRef(false);
39
+
40
+ const [state, setState] = useState<RuntimeState>({
41
+ status: "initializing",
42
+ progress: { stage: "initializing", message: "Preparing...", percent: 0 },
43
+ previewUrl: null,
44
+ terminalOutput: [],
45
+ files: {},
46
+ error: null,
47
+ });
48
+
49
+ const [selectedFile, setSelectedFile] = useState<string | null>(null);
50
+ const [openFiles, setOpenFiles] = useState<string[]>([]);
51
+
52
+ // Resolve files + entry command from props or template
53
+ const resolvedConfig = useCallback(() => {
54
+ if (propFiles) {
55
+ return {
56
+ files: propFiles,
57
+ entryCommand: propEntryCommand || "node server.js",
58
+ port: propPort || 3000,
59
+ };
60
+ }
61
+
62
+ if (template) {
63
+ const tpl = getTemplate(template);
64
+ if (tpl) {
65
+ return {
66
+ files: tpl.files,
67
+ entryCommand: propEntryCommand || tpl.entryCommand,
68
+ port: propPort || tpl.port,
69
+ };
70
+ }
71
+ }
72
+
73
+ // Default: empty project
74
+ return {
75
+ files: {},
76
+ entryCommand: propEntryCommand || "node server.js",
77
+ port: propPort || 3000,
78
+ };
79
+ }, [propFiles, template, propEntryCommand, propPort]);
80
+
81
+ // Boot the runtime on mount
82
+ useEffect(() => {
83
+ if (bootedRef.current) {
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;
101
+
102
+ // Set initial files state + open the first file
103
+ setState((prev) => ({
104
+ ...prev,
105
+ files: config.files,
106
+ }));
107
+
108
+ const fileNames = Object.keys(config.files);
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
+ }
120
+
121
+ // Wire callbacks
122
+ runtime.setProgressCallback((progress: BootProgress) => {
123
+ setState((prev) => ({
124
+ ...prev,
125
+ status: progress.stage,
126
+ progress,
127
+ previewUrl: runtime.getPreviewUrl(),
128
+ error: progress.stage === "error" ? progress.message : prev.error,
129
+ }));
130
+ onProgress?.(progress);
131
+ if (progress.stage === "error") {
132
+ onError?.(progress.message);
133
+ }
134
+ });
135
+
136
+ runtime.setOutputCallback((line: string) => {
137
+ setState((prev) => ({
138
+ ...prev,
139
+ terminalOutput: [...prev.terminalOutput, line],
140
+ }));
141
+ });
142
+
143
+ runtime.setServerReadyCallback((port: number, url: string) => {
144
+ setState((prev) => ({
145
+ ...prev,
146
+ previewUrl: url,
147
+ status: "ready",
148
+ }));
149
+ onServerReady?.(port, url);
150
+ });
151
+
152
+ // Boot
153
+ runtime.boot().catch((err) => {
154
+ const msg = err instanceof Error ? err.message : String(err);
155
+ onError?.(msg);
156
+ });
157
+
158
+ // Cleanup on unmount
159
+ return () => {
160
+ runtime.teardown();
161
+ runtimeRef.current = null;
162
+ };
163
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
164
+
165
+ // File change handler
166
+ const handleFileChange = useCallback(
167
+ async (path: string, content: string) => {
168
+ const runtime = runtimeRef.current;
169
+ if (!runtime) {
170
+ return;
171
+ }
172
+
173
+ // Write to Nodepod FS
174
+ try {
175
+ await runtime.writeFile(path, content);
176
+ } catch (err) {
177
+ console.error(`Failed to write file ${path}:`, err);
178
+ }
179
+
180
+ // Update state
181
+ setState((prev) => ({
182
+ ...prev,
183
+ files: { ...prev.files, [path]: content },
184
+ }));
185
+
186
+ onFileChange?.(path, content);
187
+ },
188
+ [onFileChange],
189
+ );
190
+
191
+ // Select a file
192
+ const handleSelectFile = useCallback((path: string) => {
193
+ setSelectedFile(path);
194
+ setOpenFiles((prev) => (prev.includes(path) ? prev : [...prev, path]));
195
+ }, []);
196
+
197
+ // Close a file tab
198
+ const handleCloseFile = useCallback(
199
+ (path: string) => {
200
+ setOpenFiles((prev) => {
201
+ const next = prev.filter((p) => p !== path);
202
+ if (selectedFile === path) {
203
+ setSelectedFile(next.length > 0 ? next[next.length - 1] : null);
204
+ }
205
+ return next;
206
+ });
207
+ },
208
+ [selectedFile],
209
+ );
210
+
211
+ // Restart the server
212
+ const restart = useCallback(async () => {
213
+ const runtime = runtimeRef.current;
214
+ if (!runtime) {
215
+ return;
216
+ }
217
+ await runtime.restart();
218
+ }, []);
219
+
220
+ // Get changed files for git operations
221
+ const getChangedFiles = useCallback((): FileMap => {
222
+ return runtimeRef.current?.getChangedFiles() ?? {};
223
+ }, []);
224
+
225
+ return {
226
+ state,
227
+ selectedFile,
228
+ openFiles,
229
+ handleFileChange,
230
+ handleSelectFile,
231
+ handleCloseFile,
232
+ restart,
233
+ getChangedFiles,
234
+ runtime: runtimeRef,
235
+ };
236
+ }
package/src/index.ts ADDED
@@ -0,0 +1,48 @@
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
+ // Main component
9
+ export { CodeSandbox } from "./components/Workbench";
10
+
11
+ // Individual components (for custom layouts)
12
+ export { FileTree, buildFileTree } from "./components/FileTree";
13
+ export { CodeEditor } from "./components/CodeEditor";
14
+ export { Terminal } from "./components/Terminal";
15
+ export { Preview } from "./components/Preview";
16
+ export { BootOverlay } from "./components/BootOverlay";
17
+ export { ViewSlider } from "./components/ViewSlider";
18
+ export type { WorkbenchView } from "./components/ViewSlider";
19
+
20
+ // Services
21
+ export { NodepodRuntime } from "./services/runtime";
22
+ export { GitService } from "./services/git";
23
+
24
+ // Hooks
25
+ export { useRuntime } from "./hooks/useRuntime";
26
+
27
+ // Templates
28
+ export { getTemplate, listTemplates } from "./templates";
29
+
30
+ // Types
31
+ export type {
32
+ FileMap,
33
+ FileNode,
34
+ BootStage,
35
+ BootProgress,
36
+ RuntimeConfig,
37
+ RuntimeState,
38
+ GitHubRepo,
39
+ GitCommitRequest,
40
+ GitPRRequest,
41
+ GitPRResult,
42
+ CodeSandboxProps,
43
+ FileTreeProps,
44
+ CodeEditorProps,
45
+ TerminalProps,
46
+ PreviewProps,
47
+ BootOverlayProps,
48
+ } from "./types";