@illuma-ai/code-sandbox 1.2.2 → 1.3.1

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.
@@ -1,252 +1,111 @@
1
1
  /**
2
- * Terminal — Git-bash inspired terminal output panel.
3
- *
4
- * Renders process stdout/stderr with color-coded output:
5
- * - Commands ($ ...) get a green prompt + white command
6
- * - stderr lines get red text
7
- * - Success markers (checkmarks) get green
8
- * - Warnings get yellow
9
- * - Package names get purple
10
- * - URLs and paths get cyan
11
- * - Exit codes get colored by status
2
+ * Terminal — Clean monochrome terminal output panel with minimize toggle.
12
3
  *
13
4
  * Uses a simple div-based approach (not xterm.js) to keep the bundle lean.
14
5
  * Auto-scrolls to bottom as new output arrives.
6
+ * Minimize/expand state is controlled by the parent via props.
15
7
  */
16
8
 
17
9
  import React, { useEffect, useRef } from "react";
18
10
  import type { TerminalProps } from "../types";
19
11
 
20
- /** Classify a terminal output line for color coding */
21
- type LineType =
22
- | "prompt" // $ command
23
- | "stderr" // [stderr] ...
24
- | "success" // ✓ ...
25
- | "error" // ✗ ... or error messages
26
- | "warning" // warn / Warning
27
- | "info" // Installing, Dependencies, info lines
28
- | "exit-ok" // Process exited with code 0
29
- | "exit-fail" // Process exited with non-zero code
30
- | "dim" // Empty or separator lines
31
- | "normal"; // Default output
32
-
33
12
  /**
34
- * Classify a line of terminal output for styling.
35
- *
36
- * Pattern matching order matters — more specific patterns first.
37
- */
38
- function classifyLine(line: string): LineType {
39
- const trimmed = line.trim();
40
-
41
- // Empty or whitespace-only
42
- if (!trimmed) return "dim";
43
-
44
- // Explicit stderr prefix from runtime
45
- if (trimmed.startsWith("[stderr]")) return "stderr";
46
-
47
- // Command prompt ($ node server.js)
48
- if (/^\$\s/.test(trimmed)) return "prompt";
49
-
50
- // Success indicators
51
- if (/^\s*✓/.test(line) || /^\s*✔/.test(line) || /^done$/i.test(trimmed))
52
- return "success";
53
-
54
- // Error indicators
55
- if (
56
- /^\s*✗/.test(line) ||
57
- /^\s*✘/.test(line) ||
58
- /^error\b/i.test(trimmed) ||
59
- /^Error:/i.test(trimmed) ||
60
- /^TypeError:/i.test(trimmed) ||
61
- /^ReferenceError:/i.test(trimmed) ||
62
- /^SyntaxError:/i.test(trimmed) ||
63
- /ENOENT|EACCES|ECONNREFUSED/i.test(trimmed)
64
- )
65
- return "error";
66
-
67
- // Process exit
68
- if (/^Process exited with code\s+(\d+)/.test(trimmed)) {
69
- const code = trimmed.match(/code\s+(\d+)/)?.[1];
70
- return code === "0" ? "exit-ok" : "exit-fail";
71
- }
72
-
73
- // Warning
74
- if (
75
- /^warn\b/i.test(trimmed) ||
76
- /^warning\b/i.test(trimmed) ||
77
- /^Warning:/i.test(trimmed) ||
78
- /deprecated/i.test(trimmed)
79
- )
80
- return "warning";
81
-
82
- // Info / install progress
83
- if (
84
- /^Installing\b/i.test(trimmed) ||
85
- /^Dependencies\s+installed/i.test(trimmed) ||
86
- /^Listening\b/i.test(trimmed) ||
87
- /^Server\s+(running|started|listening)/i.test(trimmed) ||
88
- /^Starting\b/i.test(trimmed)
89
- )
90
- return "info";
91
-
92
- return "normal";
93
- }
94
-
95
- /** CSS class name for each line type */
96
- const LINE_CLASS: Record<LineType, string> = {
97
- prompt: "", // handled specially (split into prompt + command)
98
- stderr: "sb-term-stderr",
99
- success: "sb-term-success",
100
- error: "sb-term-stderr",
101
- warning: "sb-term-warning",
102
- info: "sb-term-info",
103
- "exit-ok": "sb-term-exit-ok",
104
- "exit-fail": "sb-term-exit-fail",
105
- dim: "sb-term-dim",
106
- normal: "sb-term-line",
107
- };
108
-
109
- /**
110
- * Render a single terminal line with appropriate styling.
111
- *
112
- * Prompt lines are split into a green "$ " prefix and white command text.
113
- * Stderr lines strip the "[stderr]" prefix and show in red.
114
- * Package install lines highlight the package name in purple.
115
- */
116
- function TerminalLine({ line }: { line: string }) {
117
- const type = classifyLine(line);
118
-
119
- // Prompt line: split into green prompt + white command
120
- if (type === "prompt") {
121
- const commandText = line.replace(/^\$\s*/, "");
122
- return (
123
- <div className="whitespace-pre-wrap">
124
- <span className="sb-term-prompt">{"$ "}</span>
125
- <span className="sb-term-cmd">{commandText}</span>
126
- </div>
127
- );
128
- }
129
-
130
- // Stderr: strip prefix, show in red
131
- if (type === "stderr") {
132
- const message = line.replace(/^\[stderr\]\s*/, "");
133
- return (
134
- <div className="whitespace-pre-wrap sb-term-stderr">
135
- <span className="sb-term-dim select-none">{"stderr "}</span>
136
- {message}
137
- </div>
138
- );
139
- }
140
-
141
- // Package install success: " ✓ express@4.18.0" → green check + purple pkg
142
- if (type === "success" && /✓\s+\S+@/.test(line)) {
143
- const match = line.match(/(.*✓\s*)(\S+@\S+)(.*)/);
144
- if (match) {
145
- return (
146
- <div className="whitespace-pre-wrap sb-term-success">
147
- {match[1]}
148
- <span className="sb-term-pkg">{match[2]}</span>
149
- {match[3]}
150
- </div>
151
- );
152
- }
153
- }
154
-
155
- // Info lines with port numbers: highlight the port
156
- if (type === "info" && /:\d{4}/.test(line)) {
157
- const parts = line.split(/(:\d{4,5})/);
158
- return (
159
- <div className="whitespace-pre-wrap sb-term-info">
160
- {parts.map((part, i) =>
161
- /^:\d{4,5}$/.test(part) ? (
162
- <span key={i} className="sb-term-path">
163
- {part}
164
- </span>
165
- ) : (
166
- <span key={i}>{part}</span>
167
- ),
168
- )}
169
- </div>
170
- );
171
- }
172
-
173
- return (
174
- <div className={`whitespace-pre-wrap ${LINE_CLASS[type]}`}>{line}</div>
175
- );
176
- }
177
-
178
- /**
179
- * Terminal component — renders process output in a git-bash inspired panel.
13
+ * Terminal component renders process output in a clean monochrome panel.
180
14
  *
181
15
  * Features:
182
- * - Color-coded output (commands, errors, warnings, info, packages)
183
- * - Monospace font stack (Cascadia Code → JetBrains Mono → Fira Code → fallback)
16
+ * - Monospace font with proper line spacing
184
17
  * - Auto-scroll to bottom on new output
18
+ * - Minimize/expand toggle via chevron in header (parent-controlled)
185
19
  * - Modern thin scrollbar matching the sandbox theme
186
- * - Dark background separate from editor for visual distinction
187
20
  */
188
- export function Terminal({ output, className = "" }: TerminalProps) {
21
+ export function Terminal({
22
+ output,
23
+ className = "",
24
+ minimized = false,
25
+ onToggleMinimize,
26
+ }: TerminalProps) {
189
27
  const containerRef = useRef<HTMLDivElement>(null);
190
28
 
191
29
  // Auto-scroll to bottom when new output arrives
192
30
  useEffect(() => {
193
31
  const el = containerRef.current;
194
- if (el) {
32
+ if (el && !minimized) {
195
33
  el.scrollTop = el.scrollHeight;
196
34
  }
197
- }, [output.length]);
35
+ }, [output.length, minimized]);
198
36
 
199
37
  return (
200
38
  <div
201
- className={`flex flex-col h-full ${className}`}
39
+ className={`flex flex-col ${minimized ? "" : "h-full"} ${className}`}
202
40
  style={{ background: "var(--sb-terminal)" }}
203
41
  >
204
- {/* Header — styled like a real terminal tab bar */}
42
+ {/* Header */}
205
43
  <div
206
- className="flex items-center gap-2 px-3 py-1.5 border-b border-sb-border shrink-0"
44
+ className="flex items-center gap-2 px-3 py-1 border-t border-sb-border shrink-0 select-none cursor-pointer"
207
45
  style={{ background: "var(--sb-terminal-header)" }}
46
+ onClick={onToggleMinimize}
208
47
  >
209
- {/* Traffic light dots (decorative) */}
210
- <div className="flex items-center gap-1.5 mr-1">
211
- <span
212
- className="w-2.5 h-2.5 rounded-full"
213
- style={{ background: "#f85149" }}
214
- />
215
- <span
216
- className="w-2.5 h-2.5 rounded-full"
217
- style={{ background: "#d29922" }}
218
- />
219
- <span
220
- className="w-2.5 h-2.5 rounded-full"
221
- style={{ background: "#3fb950" }}
48
+ {/* Chevron toggle */}
49
+ <svg
50
+ width="14"
51
+ height="14"
52
+ viewBox="0 0 16 16"
53
+ fill="none"
54
+ className="shrink-0 transition-transform duration-150"
55
+ style={{
56
+ transform: minimized ? "rotate(-90deg)" : "rotate(0deg)",
57
+ color: "var(--sb-text-muted)",
58
+ }}
59
+ >
60
+ <path
61
+ d="M4 6l4 4 4-4"
62
+ stroke="currentColor"
63
+ strokeWidth="1.5"
64
+ strokeLinecap="round"
65
+ strokeLinejoin="round"
222
66
  />
223
- </div>
67
+ </svg>
224
68
  <span
225
69
  className="text-[11px] font-medium tracking-wider"
226
70
  style={{ color: "var(--sb-text-muted)" }}
227
71
  >
228
72
  TERMINAL
229
73
  </span>
230
- {/* Shell indicator */}
231
- <span className="text-[10px] ml-auto" style={{ color: "#484f58" }}>
232
- bash
233
- </span>
234
- </div>
235
-
236
- {/* Output area */}
237
- <div
238
- ref={containerRef}
239
- className="flex-1 overflow-auto px-3.5 py-2.5 sb-terminal-output overscroll-contain"
240
- >
241
- {output.length === 0 ? (
242
- <div className="flex items-center gap-2 sb-term-dim">
243
- <span className="sb-term-prompt">$</span>
244
- <span className="animate-pulse">_</span>
245
- </div>
246
- ) : (
247
- output.map((line, i) => <TerminalLine key={i} line={line} />)
74
+ {/* Line count badge */}
75
+ {output.length > 0 && (
76
+ <span
77
+ className="text-[10px] ml-auto tabular-nums"
78
+ style={{ color: "var(--sb-text-muted)", opacity: 0.6 }}
79
+ >
80
+ {output.length} lines
81
+ </span>
248
82
  )}
249
83
  </div>
84
+
85
+ {/* Output area — hidden when minimized */}
86
+ {!minimized && (
87
+ <div
88
+ ref={containerRef}
89
+ className="flex-1 overflow-auto px-3.5 py-2 sb-terminal-output overscroll-contain min-h-0"
90
+ >
91
+ {output.length === 0 ? (
92
+ <div style={{ color: "var(--sb-text-muted)" }}>
93
+ <span>$ </span>
94
+ <span className="animate-pulse">_</span>
95
+ </div>
96
+ ) : (
97
+ output.map((line, i) => (
98
+ <div
99
+ key={i}
100
+ className="whitespace-pre-wrap"
101
+ style={{ color: "var(--sb-text)" }}
102
+ >
103
+ {line}
104
+ </div>
105
+ ))
106
+ )}
107
+ </div>
108
+ )}
250
109
  </div>
251
110
  );
252
111
  }
@@ -183,6 +183,7 @@ export const CodeSandbox = forwardRef<CodeSandboxHandle, CodeSandboxProps>(
183
183
  <Preview
184
184
  url={state.previewUrl}
185
185
  onBrowserError={handleBrowserError}
186
+ reloadKey={state.previewReloadKey}
186
187
  />
187
188
  </motion.div>
188
189
  </div>
@@ -303,6 +304,7 @@ interface CodeViewProps {
303
304
  /**
304
305
  * CodeView — FileTree (left) + Editor (top-right) + Terminal (bottom-right).
305
306
  * Uses Allotment for resizable split panes.
307
+ * Terminal can be minimized to just a header bar.
306
308
  * Responsive: file tree shrinks on narrow containers.
307
309
  */
308
310
  function CodeView({
@@ -317,6 +319,8 @@ function CodeView({
317
319
  onCloseFile,
318
320
  onFileChange,
319
321
  }: CodeViewProps) {
322
+ const [terminalMinimized, setTerminalMinimized] = useState(false);
323
+
320
324
  return (
321
325
  <div className="h-full w-full">
322
326
  <Allotment>
@@ -332,24 +336,45 @@ function CodeView({
332
336
 
333
337
  {/* Editor + Terminal — right */}
334
338
  <Allotment.Pane>
335
- <Allotment vertical>
336
- <Allotment.Pane preferredSize="70%">
337
- <CodeEditor
338
- files={files}
339
- originalFiles={originalFiles}
340
- fileChanges={fileChanges}
341
- activeFile={selectedFile}
342
- openFiles={openFiles}
343
- onSelectFile={onSelectFile}
344
- onCloseFile={onCloseFile}
345
- onFileChange={onFileChange}
346
- />
347
- </Allotment.Pane>
339
+ <div className="h-full flex flex-col">
340
+ {/* Editor takes remaining space */}
341
+ <div
342
+ className={
343
+ terminalMinimized ? "flex-1 min-h-0" : "flex-1 min-h-0"
344
+ }
345
+ style={{ height: terminalMinimized ? "100%" : "70%" }}
346
+ >
347
+ <div className="h-full">
348
+ <CodeEditor
349
+ files={files}
350
+ originalFiles={originalFiles}
351
+ fileChanges={fileChanges}
352
+ activeFile={selectedFile}
353
+ openFiles={openFiles}
354
+ onSelectFile={onSelectFile}
355
+ onCloseFile={onCloseFile}
356
+ onFileChange={onFileChange}
357
+ />
358
+ </div>
359
+ </div>
348
360
 
349
- <Allotment.Pane preferredSize="30%" minSize={60}>
350
- <Terminal output={terminalOutput} />
351
- </Allotment.Pane>
352
- </Allotment>
361
+ {/* Terminal — collapsible */}
362
+ {terminalMinimized ? (
363
+ <Terminal
364
+ output={terminalOutput}
365
+ minimized={true}
366
+ onToggleMinimize={() => setTerminalMinimized(false)}
367
+ />
368
+ ) : (
369
+ <div style={{ height: "30%", minHeight: 60 }}>
370
+ <Terminal
371
+ output={terminalOutput}
372
+ minimized={false}
373
+ onToggleMinimize={() => setTerminalMinimized(true)}
374
+ />
375
+ </div>
376
+ )}
377
+ </div>
353
378
  </Allotment.Pane>
354
379
  </Allotment>
355
380
  </div>
@@ -29,6 +29,38 @@ import type {
29
29
  /** Debug log prefix for easy filtering in DevTools */
30
30
  const DBG = "[CodeSandbox:useRuntime]";
31
31
 
32
+ /**
33
+ * File extensions that can be hot-reloaded (iframe refresh) without
34
+ * restarting the Node.js server process. These are static assets
35
+ * served by Express — changing them doesn't require a process restart.
36
+ */
37
+ const HOT_RELOAD_EXTENSIONS = new Set([
38
+ "css",
39
+ "html",
40
+ "htm",
41
+ "svg",
42
+ "png",
43
+ "jpg",
44
+ "jpeg",
45
+ "gif",
46
+ "webp",
47
+ "ico",
48
+ "woff",
49
+ "woff2",
50
+ "ttf",
51
+ "eot",
52
+ ]);
53
+
54
+ /**
55
+ * Check whether a file path is hot-reloadable (static asset that doesn't
56
+ * require a server restart). Returns false for JS/TS/JSON and any file
57
+ * without an extension.
58
+ */
59
+ function isHotReloadable(filePath: string): boolean {
60
+ const ext = filePath.split(".").pop()?.toLowerCase();
61
+ return ext ? HOT_RELOAD_EXTENSIONS.has(ext) : false;
62
+ }
63
+
32
64
  /**
33
65
  * Compute per-file change statuses between an original and current file set.
34
66
  *
@@ -115,6 +147,7 @@ export function useRuntime(props: CodeSandboxProps) {
115
147
  files: {},
116
148
  originalFiles: {},
117
149
  fileChanges: {},
150
+ previewReloadKey: 0,
118
151
  error: null,
119
152
  errors: [],
120
153
  });
@@ -340,12 +373,14 @@ export function useRuntime(props: CodeSandboxProps) {
340
373
  // Runtime exists — diff and write changed files
341
374
  const currentFiles = runtime.getCurrentFiles();
342
375
  let changed = false;
376
+ const changedPaths: string[] = [];
343
377
 
344
378
  for (const [path, content] of Object.entries(newFiles)) {
345
379
  if (currentFiles[path] !== content) {
346
380
  try {
347
381
  await runtime.writeFile(path, content);
348
382
  changed = true;
383
+ changedPaths.push(path);
349
384
  console.log(DBG, `updated file: ${path}`);
350
385
  } catch (err) {
351
386
  console.warn(DBG, `failed to write ${path}:`, err);
@@ -356,25 +391,54 @@ export function useRuntime(props: CodeSandboxProps) {
356
391
  // Compute change status — "original" is what we had before this update
357
392
  const fileChanges = computeFileChanges(currentFiles, newFiles);
358
393
 
359
- // Update React state with new file set + diff info
360
- setState((prev) => ({
361
- ...prev,
362
- originalFiles: currentFiles,
363
- files: newFiles,
364
- fileChanges,
365
- }));
394
+ // Decide: hot reload (iframe refresh) vs cold restart (kill + rerun server).
395
+ // Hot reload when ALL changed files are static assets (CSS, HTML, images).
396
+ // Cold restart when ANY changed file is JS/TS/JSON/etc. that affects the server.
397
+ const canHotReload =
398
+ changed &&
399
+ changedPaths.length > 0 &&
400
+ changedPaths.every(isHotReloadable);
366
401
 
367
- // Restart server if any files changed
368
402
  if (changed && shouldRestart) {
369
- console.log(
370
- DBG,
371
- `files changed — restarting server (${Object.keys(fileChanges).length} files differ)`,
372
- );
373
- try {
374
- await runtime.restart();
375
- } catch (err) {
376
- console.error(DBG, "restart failed:", err);
403
+ if (canHotReload) {
404
+ // Hot reload — just bump the preview reload key to refresh the iframe
405
+ console.log(
406
+ DBG,
407
+ `hot reload: ${changedPaths.length} static file(s) changed — refreshing preview`,
408
+ );
409
+ setState((prev) => ({
410
+ ...prev,
411
+ originalFiles: currentFiles,
412
+ files: newFiles,
413
+ fileChanges,
414
+ previewReloadKey: prev.previewReloadKey + 1,
415
+ }));
416
+ } else {
417
+ // Cold restart — server code changed, need full process restart
418
+ console.log(
419
+ DBG,
420
+ `cold restart: ${changedPaths.length} file(s) changed — restarting server`,
421
+ );
422
+ setState((prev) => ({
423
+ ...prev,
424
+ originalFiles: currentFiles,
425
+ files: newFiles,
426
+ fileChanges,
427
+ }));
428
+ try {
429
+ await runtime.restart();
430
+ } catch (err) {
431
+ console.error(DBG, "restart failed:", err);
432
+ }
377
433
  }
434
+ } else {
435
+ // No changes or restart disabled — just update state
436
+ setState((prev) => ({
437
+ ...prev,
438
+ originalFiles: currentFiles,
439
+ files: newFiles,
440
+ fileChanges,
441
+ }));
378
442
  }
379
443
 
380
444
  // Notify host that files have been processed
package/src/styles.css CHANGED
@@ -67,7 +67,7 @@
67
67
  }
68
68
 
69
69
  /* ---------------------------------------------------------------------------
70
- * Terminal theme git-bash inspired colors and styling
70
+ * Terminal — clean monochrome styling
71
71
  * --------------------------------------------------------------------------- */
72
72
 
73
73
  .sb-terminal-output {
@@ -81,47 +81,6 @@
81
81
  -moz-osx-font-smoothing: grayscale;
82
82
  }
83
83
 
84
- /* Terminal line colors via data attribute */
85
- .sb-term-line {
86
- color: #c9d1d9;
87
- }
88
- .sb-term-prompt {
89
- color: var(--sb-success);
90
- font-weight: 600;
91
- }
92
- .sb-term-cmd {
93
- color: #f0f6fc;
94
- font-weight: 500;
95
- }
96
- .sb-term-stderr {
97
- color: var(--sb-error);
98
- }
99
- .sb-term-success {
100
- color: var(--sb-success);
101
- }
102
- .sb-term-warning {
103
- color: var(--sb-warning);
104
- }
105
- .sb-term-info {
106
- color: var(--sb-info);
107
- }
108
- .sb-term-dim {
109
- color: #484f58;
110
- }
111
- .sb-term-path {
112
- color: var(--sb-cyan);
113
- }
114
- .sb-term-pkg {
115
- color: var(--sb-magenta);
116
- }
117
- .sb-term-exit-ok {
118
- color: var(--sb-success);
119
- }
120
- .sb-term-exit-fail {
121
- color: var(--sb-error);
122
- font-weight: 600;
123
- }
124
-
125
84
  /*
126
85
  * Ranger Theme Variables — bridged from ranger/client/src/style.css.
127
86
  *
package/src/types.ts CHANGED
@@ -150,6 +150,12 @@ export interface RuntimeState {
150
150
  * Missing keys should be treated as "unchanged".
151
151
  */
152
152
  fileChanges: Record<string, FileChangeStatus>;
153
+ /**
154
+ * Monotonically increasing counter that triggers an iframe reload
155
+ * without restarting the server process. Incremented when only
156
+ * hot-reloadable files change (CSS, HTML, static assets).
157
+ */
158
+ previewReloadKey: number;
153
159
  /** Error message if status is 'error' */
154
160
  error: string | null;
155
161
  /**
@@ -343,6 +349,10 @@ export interface CodeEditorProps {
343
349
  export interface TerminalProps {
344
350
  output: string[];
345
351
  className?: string;
352
+ /** Whether the terminal is collapsed to just its header bar */
353
+ minimized?: boolean;
354
+ /** Called when the user clicks the minimize/expand toggle */
355
+ onToggleMinimize?: () => void;
346
356
  }
347
357
 
348
358
  export interface PreviewProps {
@@ -351,6 +361,12 @@ export interface PreviewProps {
351
361
  onRefresh?: () => void;
352
362
  /** Called when a JavaScript error occurs inside the preview iframe */
353
363
  onBrowserError?: (error: SandboxError) => void;
364
+ /**
365
+ * Monotonically increasing counter. When this changes, the iframe
366
+ * is soft-reloaded (location.reload) without restarting the server.
367
+ * Used for hot-reloading static asset changes (CSS, HTML, images).
368
+ */
369
+ reloadKey?: number;
354
370
  }
355
371
 
356
372
  export interface BootOverlayProps {