@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,168 @@
1
+ /**
2
+ * CodeEditor — Monaco editor with file tabs.
3
+ *
4
+ * Uses @monaco-editor/react for the editor and renders a tab bar
5
+ * of open files. Supports dark theme that matches the sandbox.
6
+ */
7
+
8
+ import React, { useCallback, useMemo } from "react";
9
+ import type { CodeEditorProps } from "../types";
10
+
11
+ // Lazy import Monaco to keep the bundle splittable
12
+ let MonacoEditor: React.ComponentType<any> | null = null;
13
+ let monacoLoadPromise: Promise<void> | null = null;
14
+
15
+ /**
16
+ * Load Monaco editor dynamically.
17
+ * Returns the Editor component from @monaco-editor/react.
18
+ */
19
+ function loadMonaco(): Promise<void> {
20
+ if (MonacoEditor) return Promise.resolve();
21
+ if (monacoLoadPromise) return monacoLoadPromise;
22
+
23
+ monacoLoadPromise = import("@monaco-editor/react").then((mod) => {
24
+ MonacoEditor = mod.default || mod.Editor;
25
+ });
26
+
27
+ return monacoLoadPromise;
28
+ }
29
+
30
+ /**
31
+ * Get Monaco language from file path.
32
+ */
33
+ function getLanguage(path: string): string {
34
+ const ext = path.split(".").pop()?.toLowerCase() || "";
35
+ const langMap: Record<string, string> = {
36
+ js: "javascript",
37
+ jsx: "javascript",
38
+ ts: "typescript",
39
+ tsx: "typescript",
40
+ json: "json",
41
+ html: "html",
42
+ htm: "html",
43
+ css: "css",
44
+ scss: "scss",
45
+ less: "less",
46
+ md: "markdown",
47
+ py: "python",
48
+ rb: "ruby",
49
+ go: "go",
50
+ rs: "rust",
51
+ yml: "yaml",
52
+ yaml: "yaml",
53
+ xml: "xml",
54
+ sql: "sql",
55
+ sh: "shell",
56
+ bash: "shell",
57
+ svg: "xml",
58
+ };
59
+ return langMap[ext] || "plaintext";
60
+ }
61
+
62
+ /**
63
+ * CodeEditor component — Monaco editor with file tabs.
64
+ */
65
+ export function CodeEditor({
66
+ files,
67
+ activeFile,
68
+ openFiles,
69
+ onSelectFile,
70
+ onCloseFile,
71
+ onFileChange,
72
+ readOnly = false,
73
+ }: CodeEditorProps) {
74
+ const [loaded, setLoaded] = React.useState(!!MonacoEditor);
75
+
76
+ // Load Monaco on mount
77
+ React.useEffect(() => {
78
+ if (!MonacoEditor) {
79
+ loadMonaco().then(() => setLoaded(true));
80
+ }
81
+ }, []);
82
+
83
+ const activeContent = activeFile ? files[activeFile] || "" : "";
84
+ const language = activeFile ? getLanguage(activeFile) : "plaintext";
85
+
86
+ const handleEditorChange = useCallback(
87
+ (value: string | undefined) => {
88
+ if (activeFile && value !== undefined) {
89
+ onFileChange(activeFile, value);
90
+ }
91
+ },
92
+ [activeFile, onFileChange],
93
+ );
94
+
95
+ return (
96
+ <div className="flex flex-col h-full bg-sb-editor">
97
+ {/* Tab bar */}
98
+ <div className="flex items-center bg-sb-bg-alt border-b border-sb-border overflow-x-auto">
99
+ {openFiles.map((path) => {
100
+ const fileName = path.split("/").pop() || path;
101
+ const isActive = path === activeFile;
102
+
103
+ return (
104
+ <div
105
+ key={path}
106
+ className={`group flex items-center gap-2 px-3 py-1.5 text-xs cursor-pointer border-r border-sb-border
107
+ ${
108
+ isActive
109
+ ? "bg-sb-editor text-sb-text-active border-t-2 border-t-sb-accent"
110
+ : "bg-sb-bg-alt text-sb-text-muted hover:bg-sb-bg-hover border-t-2 border-t-transparent"
111
+ }`}
112
+ onClick={() => onSelectFile(path)}
113
+ >
114
+ <span className="truncate max-w-[120px]">{fileName}</span>
115
+ <button
116
+ className="opacity-0 group-hover:opacity-100 hover:text-sb-text-active ml-1 text-[10px]"
117
+ onClick={(e) => {
118
+ e.stopPropagation();
119
+ onCloseFile(path);
120
+ }}
121
+ title="Close"
122
+ >
123
+
124
+ </button>
125
+ </div>
126
+ );
127
+ })}
128
+ </div>
129
+
130
+ {/* Editor */}
131
+ <div className="flex-1 overflow-hidden">
132
+ {!activeFile && (
133
+ <div className="flex items-center justify-center h-full text-sb-text-muted text-sm">
134
+ Select a file to edit
135
+ </div>
136
+ )}
137
+
138
+ {activeFile && loaded && MonacoEditor && (
139
+ <MonacoEditor
140
+ height="100%"
141
+ language={language}
142
+ value={activeContent}
143
+ onChange={handleEditorChange}
144
+ theme="vs-dark"
145
+ options={{
146
+ readOnly,
147
+ minimap: { enabled: false },
148
+ fontSize: 13,
149
+ lineNumbers: "on",
150
+ wordWrap: "on",
151
+ scrollBeyondLastLine: false,
152
+ padding: { top: 8 },
153
+ renderWhitespace: "selection",
154
+ automaticLayout: true,
155
+ tabSize: 2,
156
+ }}
157
+ />
158
+ )}
159
+
160
+ {activeFile && !loaded && (
161
+ <div className="flex items-center justify-center h-full text-sb-text-muted text-sm">
162
+ Loading editor...
163
+ </div>
164
+ )}
165
+ </div>
166
+ </div>
167
+ );
168
+ }
@@ -0,0 +1,286 @@
1
+ /**
2
+ * FileTree — Collapsible file/folder tree with icons.
3
+ *
4
+ * Renders a recursive tree of FileNodes. Folders expand/collapse on click.
5
+ * Files are selectable and show the appropriate icon based on extension.
6
+ * Uses inline SVG icons for reliable cross-platform rendering.
7
+ */
8
+
9
+ import React, { useState } from "react";
10
+ import type { FileMap, FileNode, FileTreeProps } from "../types";
11
+
12
+ /**
13
+ * Build a tree structure from a flat FileMap.
14
+ *
15
+ * Converts { "routes/api.js": "...", "server.js": "..." }
16
+ * into a nested FileNode[] tree.
17
+ */
18
+ export function buildFileTree(files: FileMap): FileNode[] {
19
+ const root: FileNode[] = [];
20
+ const dirs = new Map<string, FileNode>();
21
+
22
+ // Sort paths so directories come before files at each level
23
+ const sortedPaths = Object.keys(files).sort((a, b) => {
24
+ const aParts = a.split("/");
25
+ const bParts = b.split("/");
26
+ for (let i = 0; i < Math.min(aParts.length, bParts.length); i++) {
27
+ if (aParts[i] !== bParts[i]) {
28
+ const aIsDir = i < aParts.length - 1;
29
+ const bIsDir = i < bParts.length - 1;
30
+ if (aIsDir && !bIsDir) return -1;
31
+ if (!aIsDir && bIsDir) return 1;
32
+ return aParts[i].localeCompare(bParts[i]);
33
+ }
34
+ }
35
+ return aParts.length - bParts.length;
36
+ });
37
+
38
+ for (const filePath of sortedPaths) {
39
+ const parts = filePath.split("/");
40
+
41
+ // Ensure all parent directories exist
42
+ for (let i = 0; i < parts.length - 1; i++) {
43
+ const dirPath = parts.slice(0, i + 1).join("/");
44
+ if (!dirs.has(dirPath)) {
45
+ const dirNode: FileNode = {
46
+ name: parts[i],
47
+ path: dirPath,
48
+ type: "directory",
49
+ children: [],
50
+ };
51
+ dirs.set(dirPath, dirNode);
52
+
53
+ if (i === 0) {
54
+ root.push(dirNode);
55
+ } else {
56
+ const parentPath = parts.slice(0, i).join("/");
57
+ const parent = dirs.get(parentPath);
58
+ parent?.children?.push(dirNode);
59
+ }
60
+ }
61
+ }
62
+
63
+ const fileNode: FileNode = {
64
+ name: parts[parts.length - 1],
65
+ path: filePath,
66
+ type: "file",
67
+ };
68
+
69
+ if (parts.length === 1) {
70
+ root.push(fileNode);
71
+ } else {
72
+ const parentPath = parts.slice(0, -1).join("/");
73
+ const parent = dirs.get(parentPath);
74
+ parent?.children?.push(fileNode);
75
+ }
76
+ }
77
+
78
+ return root;
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // SVG Icons — inline for reliable rendering (no emoji issues)
83
+ // ---------------------------------------------------------------------------
84
+
85
+ /** Chevron right arrow for folder expand/collapse */
86
+ function ChevronIcon({ expanded }: { expanded: boolean }) {
87
+ return (
88
+ <svg
89
+ width="16"
90
+ height="16"
91
+ viewBox="0 0 16 16"
92
+ fill="none"
93
+ className="shrink-0 transition-transform duration-150"
94
+ style={{ transform: expanded ? "rotate(90deg)" : "none" }}
95
+ >
96
+ <path
97
+ d="M6 4l4 4-4 4"
98
+ stroke="currentColor"
99
+ strokeWidth="1.5"
100
+ strokeLinecap="round"
101
+ strokeLinejoin="round"
102
+ />
103
+ </svg>
104
+ );
105
+ }
106
+
107
+ /** Folder icon (open or closed) */
108
+ function FolderIcon({ open }: { open: boolean }) {
109
+ if (open) {
110
+ return (
111
+ <svg
112
+ width="16"
113
+ height="16"
114
+ viewBox="0 0 16 16"
115
+ fill="none"
116
+ className="shrink-0"
117
+ >
118
+ <path
119
+ d="M1.5 3.5A1 1 0 012.5 2.5h3.172a1 1 0 01.707.293L7.5 3.914a1 1 0 00.707.293H13.5a1 1 0 011 1v1.293H3.207a1 1 0 00-.966.741L1.5 10.5V3.5z"
120
+ fill="#dcb67a"
121
+ />
122
+ <path
123
+ d="M2.241 7.241A1 1 0 013.207 6.5H14.5a1 1 0 01.966 1.259l-1.5 5.5A1 1 0 0113 14H2.5a1 1 0 01-1-1V8.5a1 1 0 01.741-.966l0-.293z"
124
+ fill="#e8c87a"
125
+ />
126
+ </svg>
127
+ );
128
+ }
129
+ return (
130
+ <svg
131
+ width="16"
132
+ height="16"
133
+ viewBox="0 0 16 16"
134
+ fill="none"
135
+ className="shrink-0"
136
+ >
137
+ <path
138
+ d="M1.5 3A1.5 1.5 0 013 1.5h3.172a1.5 1.5 0 011.06.44L8.354 3.06a.5.5 0 00.354.147H13A1.5 1.5 0 0114.5 4.707V12A1.5 1.5 0 0113 13.5H3A1.5 1.5 0 011.5 12V3z"
139
+ fill="#dcb67a"
140
+ />
141
+ </svg>
142
+ );
143
+ }
144
+
145
+ /** Get file icon color based on extension */
146
+ function getFileIconColor(name: string): string {
147
+ const ext = name.split(".").pop()?.toLowerCase() || "";
148
+ const colorMap: Record<string, string> = {
149
+ js: "#f0db4f",
150
+ jsx: "#61dafb",
151
+ ts: "#3178c6",
152
+ tsx: "#61dafb",
153
+ json: "#a8b065",
154
+ html: "#e44d26",
155
+ css: "#264de4",
156
+ scss: "#cd6799",
157
+ md: "#ffffff",
158
+ py: "#3776ab",
159
+ rb: "#cc342d",
160
+ go: "#00add8",
161
+ rs: "#dea584",
162
+ yml: "#cb171e",
163
+ yaml: "#cb171e",
164
+ env: "#ecd53f",
165
+ sh: "#89e051",
166
+ sql: "#e38c00",
167
+ svg: "#ffb13b",
168
+ };
169
+ return colorMap[ext] || "#8c8c8c";
170
+ }
171
+
172
+ /** Generic file icon with extension-based color */
173
+ function FileIcon({ name }: { name: string }) {
174
+ const color = getFileIconColor(name);
175
+ return (
176
+ <svg
177
+ width="16"
178
+ height="16"
179
+ viewBox="0 0 16 16"
180
+ fill="none"
181
+ className="shrink-0"
182
+ >
183
+ <path
184
+ d="M4 1.5h5.5L13 5v9.5a1 1 0 01-1 1H4a1 1 0 01-1-1v-13a1 1 0 011-1z"
185
+ fill={color}
186
+ opacity="0.2"
187
+ />
188
+ <path
189
+ d="M4 1.5h5.5L13 5v9.5a1 1 0 01-1 1H4a1 1 0 01-1-1v-13a1 1 0 011-1z"
190
+ stroke={color}
191
+ strokeWidth="1"
192
+ />
193
+ <path d="M9.5 1.5V5H13" stroke={color} strokeWidth="1" />
194
+ </svg>
195
+ );
196
+ }
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // Components
200
+ // ---------------------------------------------------------------------------
201
+
202
+ /**
203
+ * FileTree component — renders a collapsible tree of files and folders.
204
+ */
205
+ export function FileTree({ files, selectedFile, onSelectFile }: FileTreeProps) {
206
+ return (
207
+ <div className="h-full overflow-auto bg-sb-sidebar text-sm select-none">
208
+ <div className="px-3 py-2 text-[11px] font-semibold text-sb-text-muted uppercase tracking-wider border-b border-sb-border">
209
+ Explorer
210
+ </div>
211
+ <div className="py-1">
212
+ {files.map((node) => (
213
+ <TreeNode
214
+ key={node.path}
215
+ node={node}
216
+ depth={0}
217
+ selectedFile={selectedFile}
218
+ onSelectFile={onSelectFile}
219
+ />
220
+ ))}
221
+ </div>
222
+ </div>
223
+ );
224
+ }
225
+
226
+ /** Individual tree node (file or folder) */
227
+ function TreeNode({
228
+ node,
229
+ depth,
230
+ selectedFile,
231
+ onSelectFile,
232
+ }: {
233
+ node: FileNode;
234
+ depth: number;
235
+ selectedFile: string | null;
236
+ onSelectFile: (path: string) => void;
237
+ }) {
238
+ const [expanded, setExpanded] = useState(depth < 2);
239
+
240
+ const isSelected = node.path === selectedFile;
241
+ const paddingLeft = 8 + depth * 16;
242
+
243
+ if (node.type === "directory") {
244
+ return (
245
+ <div>
246
+ <button
247
+ className={`w-full flex items-center gap-1 py-[3px] text-left hover:bg-sb-bg-hover transition-colors ${
248
+ isSelected ? "bg-sb-bg-active text-sb-text-active" : "text-sb-text"
249
+ }`}
250
+ style={{ paddingLeft }}
251
+ onClick={() => setExpanded(!expanded)}
252
+ >
253
+ <ChevronIcon expanded={expanded} />
254
+ <FolderIcon open={expanded} />
255
+ <span className="truncate ml-0.5">{node.name}</span>
256
+ </button>
257
+ {expanded && node.children && (
258
+ <div>
259
+ {node.children.map((child) => (
260
+ <TreeNode
261
+ key={child.path}
262
+ node={child}
263
+ depth={depth + 1}
264
+ selectedFile={selectedFile}
265
+ onSelectFile={onSelectFile}
266
+ />
267
+ ))}
268
+ </div>
269
+ )}
270
+ </div>
271
+ );
272
+ }
273
+
274
+ return (
275
+ <button
276
+ className={`w-full flex items-center gap-1 py-[3px] text-left hover:bg-sb-bg-hover transition-colors ${
277
+ isSelected ? "bg-sb-bg-active text-sb-text-active" : "text-sb-text"
278
+ }`}
279
+ style={{ paddingLeft: paddingLeft + 20 }}
280
+ onClick={() => onSelectFile(node.path)}
281
+ >
282
+ <FileIcon name={node.name} />
283
+ <span className="truncate ml-0.5">{node.name}</span>
284
+ </button>
285
+ );
286
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Preview — Full-width iframe that renders the Nodepod virtual server.
3
+ *
4
+ * Loads the URL from Nodepod's Service Worker proxy (/__virtual__/PORT/).
5
+ * The toolbar handles the URL bar and refresh — this is just the iframe.
6
+ */
7
+
8
+ import React, { useRef, useEffect } from "react";
9
+ import type { PreviewProps } from "../types";
10
+
11
+ /**
12
+ * Preview component — renders the dev server output in a full-width iframe.
13
+ *
14
+ * The URL points to /__virtual__/PORT/ which is intercepted by the
15
+ * Nodepod Service Worker and proxied to the Express server running
16
+ * in the Web Worker.
17
+ */
18
+ export function Preview({ url, className = "" }: PreviewProps) {
19
+ const iframeRef = useRef<HTMLIFrameElement>(null);
20
+
21
+ // Reload iframe when URL changes
22
+ useEffect(() => {
23
+ if (iframeRef.current && url) {
24
+ iframeRef.current.src = url;
25
+ }
26
+ }, [url]);
27
+
28
+ return (
29
+ <div className={`h-full w-full bg-white ${className}`}>
30
+ {url ? (
31
+ <iframe
32
+ ref={iframeRef}
33
+ src={url}
34
+ className="w-full h-full border-none"
35
+ title="Preview"
36
+ sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-modals"
37
+ />
38
+ ) : (
39
+ <div className="flex items-center justify-center h-full bg-sb-bg">
40
+ <div className="text-center px-4">
41
+ <div className="w-10 h-10 mx-auto mb-3 border-2 border-sb-accent border-t-transparent rounded-full animate-spin" />
42
+ <p className="text-sb-text-muted text-sm">
43
+ Waiting for dev server to start...
44
+ </p>
45
+ </div>
46
+ </div>
47
+ )}
48
+ </div>
49
+ );
50
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Terminal — Display terminal output from Nodepod processes.
3
+ *
4
+ * Simple scrolling output panel styled like a terminal.
5
+ * Auto-scrolls to the bottom as new output arrives.
6
+ */
7
+
8
+ import React, { useEffect, useRef } from "react";
9
+ import type { TerminalProps } from "../types";
10
+
11
+ /**
12
+ * Terminal component — renders process stdout/stderr in a terminal-style panel.
13
+ *
14
+ * Uses a simple div-based approach (not xterm.js) for the initial version.
15
+ * This keeps the bundle small and avoids the xterm peer dependency
16
+ * for consumers who don't need full terminal emulation.
17
+ */
18
+ export function Terminal({ output, className = "" }: TerminalProps) {
19
+ const containerRef = useRef<HTMLDivElement>(null);
20
+
21
+ // Auto-scroll to bottom when new output arrives
22
+ useEffect(() => {
23
+ const el = containerRef.current;
24
+ if (el) {
25
+ el.scrollTop = el.scrollHeight;
26
+ }
27
+ }, [output.length]);
28
+
29
+ return (
30
+ <div className={`flex flex-col h-full bg-sb-terminal ${className}`}>
31
+ {/* Header */}
32
+ <div className="flex items-center px-3 py-1 bg-sb-bg-alt border-b border-sb-border">
33
+ <span className="text-xs font-medium text-sb-text-muted uppercase tracking-wider">
34
+ Terminal
35
+ </span>
36
+ </div>
37
+
38
+ {/* Output */}
39
+ <div
40
+ ref={containerRef}
41
+ className="flex-1 overflow-auto p-3 font-mono text-xs leading-relaxed"
42
+ >
43
+ {output.length === 0 ? (
44
+ <span className="text-sb-text-muted">Waiting for output...</span>
45
+ ) : (
46
+ output.map((line, i) => (
47
+ <div
48
+ key={i}
49
+ className={`whitespace-pre-wrap ${
50
+ line.startsWith("[stderr]")
51
+ ? "text-sb-error"
52
+ : line.startsWith("$")
53
+ ? "text-sb-success"
54
+ : line.startsWith(" ✓")
55
+ ? "text-sb-success"
56
+ : line.startsWith(" ✗")
57
+ ? "text-sb-error"
58
+ : "text-sb-text"
59
+ }`}
60
+ >
61
+ {line}
62
+ </div>
63
+ ))
64
+ )}
65
+ </div>
66
+ </div>
67
+ );
68
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * ViewSlider — Pill-shaped toggle to switch between Code and Preview views.
3
+ *
4
+ * Inspired by bolt.diy's Slider component. Uses Framer Motion's layoutId
5
+ * for a smooth animated pill that slides between options.
6
+ *
7
+ * @see bolt.diy: app/components/ui/Slider.tsx
8
+ */
9
+
10
+ import React, { memo } from "react";
11
+ import { motion } from "framer-motion";
12
+
13
+ /** The two views available in the workbench */
14
+ export type WorkbenchView = "code" | "preview";
15
+
16
+ interface ViewSliderProps {
17
+ selected: WorkbenchView;
18
+ onSelect: (view: WorkbenchView) => void;
19
+ }
20
+
21
+ /** Cubic bezier easing — typed as a 4-tuple for Framer Motion */
22
+ const CUBIC_EASING: [number, number, number, number] = [0.4, 0, 0.2, 1];
23
+
24
+ /**
25
+ * Individual button within the slider.
26
+ * When selected, renders an animated pill background via layoutId.
27
+ */
28
+ const SliderButton = memo(function SliderButton({
29
+ selected,
30
+ children,
31
+ onClick,
32
+ }: {
33
+ selected: boolean;
34
+ children: React.ReactNode;
35
+ onClick: () => void;
36
+ }) {
37
+ return (
38
+ <button
39
+ onClick={onClick}
40
+ className={`relative px-3 py-1 rounded-full text-xs font-medium transition-colors ${
41
+ selected ? "text-white" : "text-sb-text-muted hover:text-sb-text"
42
+ }`}
43
+ >
44
+ <span className="relative z-10">{children}</span>
45
+ {selected && (
46
+ <motion.span
47
+ layoutId="view-slider-pill"
48
+ transition={{
49
+ duration: 0.2,
50
+ ease: CUBIC_EASING,
51
+ }}
52
+ className="absolute inset-0 z-0 bg-sb-accent rounded-full"
53
+ />
54
+ )}
55
+ </button>
56
+ );
57
+ });
58
+
59
+ /**
60
+ * ViewSlider — renders a pill-shaped toggle with animated selection indicator.
61
+ *
62
+ * Usage:
63
+ * ```tsx
64
+ * <ViewSlider selected={view} onSelect={setView} />
65
+ * ```
66
+ */
67
+ export const ViewSlider = memo(function ViewSlider({
68
+ selected,
69
+ onSelect,
70
+ }: ViewSliderProps) {
71
+ return (
72
+ <div className="flex items-center gap-0.5 bg-sb-bg rounded-full p-0.5 border border-sb-border">
73
+ <SliderButton
74
+ selected={selected === "code"}
75
+ onClick={() => onSelect("code")}
76
+ >
77
+ Code
78
+ </SliderButton>
79
+ <SliderButton
80
+ selected={selected === "preview"}
81
+ onClick={() => onSelect("preview")}
82
+ >
83
+ Preview
84
+ </SliderButton>
85
+ </div>
86
+ );
87
+ });