@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.
- package/LICENSE +15 -0
- package/dist/__sw__.js +712 -0
- package/dist/code-sandbox.css +1 -0
- package/dist/components/BootOverlay.d.ts +17 -0
- package/dist/components/CodeEditor.d.ts +11 -0
- package/dist/components/FileTree.d.ts +19 -0
- package/dist/components/Preview.d.ts +15 -0
- package/dist/components/Terminal.d.ts +15 -0
- package/dist/components/ViewSlider.d.ts +25 -0
- package/dist/components/Workbench.d.ts +28 -0
- package/dist/hooks/useRuntime.d.ts +25 -0
- package/dist/index.cjs +50074 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +77335 -0
- package/dist/index.js.map +1 -0
- package/dist/services/git.d.ts +57 -0
- package/dist/services/runtime.d.ts +119 -0
- package/dist/templates/fullstack-starter.d.ts +38 -0
- package/dist/templates/index.d.ts +38 -0
- package/dist/types.d.ts +137 -0
- package/package.json +69 -0
- package/src/components/BootOverlay.tsx +145 -0
- package/src/components/CodeEditor.tsx +168 -0
- package/src/components/FileTree.tsx +286 -0
- package/src/components/Preview.tsx +50 -0
- package/src/components/Terminal.tsx +68 -0
- package/src/components/ViewSlider.tsx +87 -0
- package/src/components/Workbench.tsx +301 -0
- package/src/hooks/useRuntime.ts +236 -0
- package/src/index.ts +48 -0
- package/src/services/git.ts +415 -0
- package/src/services/runtime.ts +536 -0
- package/src/styles.css +24 -0
- package/src/templates/fullstack-starter.ts +3297 -0
- package/src/templates/index.ts +607 -0
- package/src/types.ts +179 -0
|
@@ -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
|
+
});
|