@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,13 +7,15 @@
|
|
|
7
7
|
* It is framework-agnostic (no React) — consumed by the useRuntime hook.
|
|
8
8
|
*/
|
|
9
9
|
import { Nodepod } from "@illuma-ai/nodepod";
|
|
10
|
-
import type { BootProgress, BootStage, FileMap, RuntimeConfig } from "../types";
|
|
10
|
+
import type { BootProgress, BootStage, FileMap, RuntimeConfig, SandboxError } from "../types";
|
|
11
11
|
/** Callback type for progress updates */
|
|
12
12
|
type ProgressCallback = (progress: BootProgress) => void;
|
|
13
13
|
/** Callback type for terminal output lines */
|
|
14
14
|
type OutputCallback = (line: string) => void;
|
|
15
15
|
/** Callback type for server ready */
|
|
16
16
|
type ServerReadyCallback = (port: number, url: string) => void;
|
|
17
|
+
/** Callback type for structured errors */
|
|
18
|
+
type ErrorCallback = (error: SandboxError) => void;
|
|
17
19
|
/**
|
|
18
20
|
* Manages the Nodepod runtime lifecycle.
|
|
19
21
|
*
|
|
@@ -35,9 +37,12 @@ export declare class NodepodRuntime {
|
|
|
35
37
|
private status;
|
|
36
38
|
private error;
|
|
37
39
|
private previewUrl;
|
|
40
|
+
private errors;
|
|
41
|
+
private errorIdCounter;
|
|
38
42
|
private onProgress;
|
|
39
43
|
private onOutput;
|
|
40
44
|
private onServerReady;
|
|
45
|
+
private onSandboxError;
|
|
41
46
|
constructor(config: RuntimeConfig);
|
|
42
47
|
/** Register a progress callback */
|
|
43
48
|
setProgressCallback(cb: ProgressCallback): void;
|
|
@@ -45,6 +50,8 @@ export declare class NodepodRuntime {
|
|
|
45
50
|
setOutputCallback(cb: OutputCallback): void;
|
|
46
51
|
/** Register a server ready callback */
|
|
47
52
|
setServerReadyCallback(cb: ServerReadyCallback): void;
|
|
53
|
+
/** Register a structured error callback */
|
|
54
|
+
setErrorCallback(cb: ErrorCallback): void;
|
|
48
55
|
/** Get the current preview URL (null if server not ready) */
|
|
49
56
|
getPreviewUrl(): string | null;
|
|
50
57
|
/** Get current status */
|
|
@@ -59,6 +66,17 @@ export declare class NodepodRuntime {
|
|
|
59
66
|
getChangedFiles(): FileMap;
|
|
60
67
|
/** Get the error message if status is 'error' */
|
|
61
68
|
getError(): string | null;
|
|
69
|
+
/** Get all structured errors collected during this session */
|
|
70
|
+
getErrors(): SandboxError[];
|
|
71
|
+
/**
|
|
72
|
+
* Report an external error (e.g., from the preview iframe).
|
|
73
|
+
*
|
|
74
|
+
* This is the public entry point for errors that originate outside
|
|
75
|
+
* the runtime — browser console errors, unhandled rejections, etc.
|
|
76
|
+
* The runtime enriches them with source context from the virtual FS
|
|
77
|
+
* and emits them via the onSandboxError callback.
|
|
78
|
+
*/
|
|
79
|
+
reportError(error: SandboxError): Promise<void>;
|
|
62
80
|
/**
|
|
63
81
|
* Boot the runtime: initialize Nodepod → write files → install → start server.
|
|
64
82
|
*
|
|
@@ -115,5 +133,44 @@ export declare class NodepodRuntime {
|
|
|
115
133
|
private emitProgress;
|
|
116
134
|
/** Append a line to terminal output */
|
|
117
135
|
private appendOutput;
|
|
136
|
+
/** Generate a unique error ID */
|
|
137
|
+
private nextErrorId;
|
|
138
|
+
/**
|
|
139
|
+
* Create and record a structured error from a process or boot event.
|
|
140
|
+
*
|
|
141
|
+
* Parses the error message to extract file path and line number when
|
|
142
|
+
* possible, then enriches with surrounding source context.
|
|
143
|
+
*/
|
|
144
|
+
private emitError;
|
|
145
|
+
/**
|
|
146
|
+
* Filter out common non-error stderr output.
|
|
147
|
+
*
|
|
148
|
+
* Many Node.js tools write informational messages to stderr
|
|
149
|
+
* (deprecation warnings, experimental feature notices, etc.).
|
|
150
|
+
* We don't want to flood the agent with these.
|
|
151
|
+
*/
|
|
152
|
+
private isStderrNoise;
|
|
153
|
+
/**
|
|
154
|
+
* Parse file path and line/column from common error message formats.
|
|
155
|
+
*
|
|
156
|
+
* Handles:
|
|
157
|
+
* - Node.js stack frames: "at func (/app/file.js:12:5)"
|
|
158
|
+
* - Direct references: "/app/file.js:12:5"
|
|
159
|
+
* - SyntaxError: "/app/file.js: Unexpected token (12:5)"
|
|
160
|
+
*/
|
|
161
|
+
private parseErrorLocation;
|
|
162
|
+
/**
|
|
163
|
+
* Get surrounding source code lines from the virtual filesystem.
|
|
164
|
+
*
|
|
165
|
+
* Returns ~10 lines centered on the target line, formatted as
|
|
166
|
+
* "lineNum: content" for each line. This gives the AI agent enough
|
|
167
|
+
* context to construct a surgical fix.
|
|
168
|
+
*/
|
|
169
|
+
private getSourceContext;
|
|
170
|
+
/**
|
|
171
|
+
* Format source code lines centered on a target line.
|
|
172
|
+
* Output: "lineNum: content\n" for each line in the window.
|
|
173
|
+
*/
|
|
174
|
+
private formatSourceContext;
|
|
118
175
|
}
|
|
119
176
|
export {};
|
package/dist/types.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* @illuma-ai/code-sandbox — Type definitions
|
|
3
3
|
*
|
|
4
4
|
* Core types for the browser-native code sandbox.
|
|
5
|
-
* Covers file maps, runtime states,
|
|
5
|
+
* Covers file maps, runtime states, imperative handle, and component props.
|
|
6
6
|
*/
|
|
7
7
|
/** A flat map of file paths to their string contents */
|
|
8
8
|
export type FileMap = Record<string, string>;
|
|
@@ -13,6 +13,62 @@ export interface FileNode {
|
|
|
13
13
|
type: "file" | "directory";
|
|
14
14
|
children?: FileNode[];
|
|
15
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* Change status for a file relative to the baseline (original) snapshot.
|
|
18
|
+
*
|
|
19
|
+
* - `new`: File exists in current but not in original
|
|
20
|
+
* - `modified`: File exists in both but content differs
|
|
21
|
+
* - `deleted`: File exists in original but not in current
|
|
22
|
+
* - `unchanged`: File content is identical
|
|
23
|
+
*/
|
|
24
|
+
export type FileChangeStatus = "new" | "modified" | "deleted" | "unchanged";
|
|
25
|
+
/**
|
|
26
|
+
* Categories of errors that can occur in the sandbox.
|
|
27
|
+
*
|
|
28
|
+
* - `process-stderr`: Output written to stderr by the Node.js process
|
|
29
|
+
* - `process-exit`: Process exited with a non-zero code
|
|
30
|
+
* - `runtime-exception`: Uncaught exception in the Node.js runtime (Nodepod)
|
|
31
|
+
* - `browser-error`: JavaScript error in the preview iframe (window.onerror)
|
|
32
|
+
* - `browser-unhandled-rejection`: Unhandled promise rejection in the iframe
|
|
33
|
+
* - `browser-console-error`: console.error() calls in the iframe
|
|
34
|
+
* - `compilation`: Syntax/import errors detected at module load time
|
|
35
|
+
* - `network`: Failed HTTP requests from the preview (fetch/XHR errors)
|
|
36
|
+
* - `boot`: Error during Nodepod boot, file writing, or dependency install
|
|
37
|
+
*/
|
|
38
|
+
export type SandboxErrorCategory = "process-stderr" | "process-exit" | "runtime-exception" | "browser-error" | "browser-unhandled-rejection" | "browser-console-error" | "compilation" | "network" | "boot";
|
|
39
|
+
/**
|
|
40
|
+
* A structured error emitted by the sandbox.
|
|
41
|
+
*
|
|
42
|
+
* Designed for AI agent consumption — includes enough context for the
|
|
43
|
+
* agent to construct a targeted fix prompt (file path, line number,
|
|
44
|
+
* surrounding code, stack trace).
|
|
45
|
+
*
|
|
46
|
+
* @see Ranger's auto-fix: client/src/components/Artifacts/Artifacts.tsx
|
|
47
|
+
*/
|
|
48
|
+
export interface SandboxError {
|
|
49
|
+
/** Unique ID for deduplication */
|
|
50
|
+
id: string;
|
|
51
|
+
/** Error category — determines how the agent should interpret it */
|
|
52
|
+
category: SandboxErrorCategory;
|
|
53
|
+
/** Human-readable error message (the "what") */
|
|
54
|
+
message: string;
|
|
55
|
+
/** Full stack trace if available */
|
|
56
|
+
stack?: string;
|
|
57
|
+
/** File path where the error occurred (relative to workdir) */
|
|
58
|
+
filePath?: string;
|
|
59
|
+
/** Line number in the file (1-indexed) */
|
|
60
|
+
line?: number;
|
|
61
|
+
/** Column number in the file (1-indexed) */
|
|
62
|
+
column?: number;
|
|
63
|
+
/** ISO 8601 timestamp */
|
|
64
|
+
timestamp: string;
|
|
65
|
+
/**
|
|
66
|
+
* Surrounding source code lines for context.
|
|
67
|
+
* Format: "lineNum: content" per line (e.g., "42: const x = foo();")
|
|
68
|
+
* Typically ~10 lines centered on the error line.
|
|
69
|
+
*/
|
|
70
|
+
sourceContext?: string;
|
|
71
|
+
}
|
|
16
72
|
/** Boot stages shown during the loading animation */
|
|
17
73
|
export type BootStage = "initializing" | "writing-files" | "installing" | "starting" | "ready" | "error";
|
|
18
74
|
export interface BootProgress {
|
|
@@ -44,8 +100,28 @@ export interface RuntimeState {
|
|
|
44
100
|
terminalOutput: string[];
|
|
45
101
|
/** Current files in the virtual FS (may differ from original after edits) */
|
|
46
102
|
files: FileMap;
|
|
103
|
+
/**
|
|
104
|
+
* Baseline file snapshot — the files as they were before the latest
|
|
105
|
+
* update (via updateFiles). Used by the diff viewer to show what changed.
|
|
106
|
+
* Empty on first load (everything is "new"), populated after the first
|
|
107
|
+
* file update.
|
|
108
|
+
*/
|
|
109
|
+
originalFiles: FileMap;
|
|
110
|
+
/**
|
|
111
|
+
* Per-file change status relative to originalFiles.
|
|
112
|
+
*
|
|
113
|
+
* Only includes files that have a non-"unchanged" status.
|
|
114
|
+
* Missing keys should be treated as "unchanged".
|
|
115
|
+
*/
|
|
116
|
+
fileChanges: Record<string, FileChangeStatus>;
|
|
47
117
|
/** Error message if status is 'error' */
|
|
48
118
|
error: string | null;
|
|
119
|
+
/**
|
|
120
|
+
* All structured errors collected during this session.
|
|
121
|
+
* New errors are appended — the array grows monotonically.
|
|
122
|
+
* Use `errors[errors.length - 1]` to get the latest.
|
|
123
|
+
*/
|
|
124
|
+
errors: SandboxError[];
|
|
49
125
|
}
|
|
50
126
|
export interface GitHubRepo {
|
|
51
127
|
owner: string;
|
|
@@ -76,13 +152,101 @@ export interface GitPRResult {
|
|
|
76
152
|
url: string;
|
|
77
153
|
branch: string;
|
|
78
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* Imperative handle exposed by CodeSandbox via React.forwardRef.
|
|
157
|
+
*
|
|
158
|
+
* This is the primary API for host applications (like Ranger) to
|
|
159
|
+
* control the sandbox programmatically. The sandbox is a "dumb renderer"
|
|
160
|
+
* — the host pushes files in and reads state out.
|
|
161
|
+
*
|
|
162
|
+
* @example
|
|
163
|
+
* ```tsx
|
|
164
|
+
* const ref = useRef<CodeSandboxHandle>(null);
|
|
165
|
+
*
|
|
166
|
+
* // Push files from any source (GitHub, DB, S3, agent output)
|
|
167
|
+
* ref.current?.updateFiles(fileMap);
|
|
168
|
+
*
|
|
169
|
+
* // Push a single file (e.g., agent modified one file)
|
|
170
|
+
* ref.current?.updateFile('server.js', newContent);
|
|
171
|
+
*
|
|
172
|
+
* // Read current state
|
|
173
|
+
* const files = ref.current?.getFiles();
|
|
174
|
+
* const changes = ref.current?.getChangedFiles();
|
|
175
|
+
* const errors = ref.current?.getErrors();
|
|
176
|
+
* ```
|
|
177
|
+
*/
|
|
178
|
+
export interface CodeSandboxHandle {
|
|
179
|
+
/**
|
|
180
|
+
* Replace the entire file set. Diffs against current files, writes only
|
|
181
|
+
* changed files to Nodepod FS, and restarts the server if anything changed.
|
|
182
|
+
*
|
|
183
|
+
* The previous file set becomes `originalFiles` for diff tracking.
|
|
184
|
+
*
|
|
185
|
+
* @param files - Complete new file set
|
|
186
|
+
* @param options - Optional: restartServer (default true)
|
|
187
|
+
*/
|
|
188
|
+
updateFiles: (files: FileMap, options?: {
|
|
189
|
+
restartServer?: boolean;
|
|
190
|
+
}) => Promise<void>;
|
|
191
|
+
/**
|
|
192
|
+
* Update a single file. Writes to Nodepod FS and updates state.
|
|
193
|
+
* Does NOT restart the server — call `restart()` manually if needed,
|
|
194
|
+
* or use `updateFiles()` for bulk updates with auto-restart.
|
|
195
|
+
*
|
|
196
|
+
* @param path - File path (e.g., "server.js", "public/index.html")
|
|
197
|
+
* @param content - New file content
|
|
198
|
+
*/
|
|
199
|
+
updateFile: (path: string, content: string) => Promise<void>;
|
|
200
|
+
/**
|
|
201
|
+
* Force restart the server process. Kills the current process
|
|
202
|
+
* and re-runs the entry command.
|
|
203
|
+
*/
|
|
204
|
+
restart: () => Promise<void>;
|
|
205
|
+
/**
|
|
206
|
+
* Get the current file map (all files including edits).
|
|
207
|
+
*/
|
|
208
|
+
getFiles: () => FileMap;
|
|
209
|
+
/**
|
|
210
|
+
* Get only files that have been modified relative to originalFiles.
|
|
211
|
+
* Returns a FileMap containing only the changed files.
|
|
212
|
+
*/
|
|
213
|
+
getChangedFiles: () => FileMap;
|
|
214
|
+
/**
|
|
215
|
+
* Get the per-file change status map (new/modified/deleted).
|
|
216
|
+
* Missing keys should be treated as "unchanged".
|
|
217
|
+
*/
|
|
218
|
+
getFileChanges: () => Record<string, FileChangeStatus>;
|
|
219
|
+
/**
|
|
220
|
+
* Get all structured errors collected during this session.
|
|
221
|
+
*/
|
|
222
|
+
getErrors: () => SandboxError[];
|
|
223
|
+
/**
|
|
224
|
+
* Get the current runtime state snapshot.
|
|
225
|
+
*/
|
|
226
|
+
getState: () => RuntimeState;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Props for the CodeSandbox component.
|
|
230
|
+
*
|
|
231
|
+
* The sandbox is a pure renderer — it receives files via props and
|
|
232
|
+
* exposes an imperative handle for programmatic updates. It does NOT
|
|
233
|
+
* interact with any storage backend (GitHub, S3, DB) directly.
|
|
234
|
+
*
|
|
235
|
+
* File sources (for initial load, pick ONE):
|
|
236
|
+
* - `files` — pass a FileMap directly
|
|
237
|
+
* - `template` — use a built-in template name (e.g., "fullstack-starter")
|
|
238
|
+
*
|
|
239
|
+
* For live updates after mount, use the imperative handle:
|
|
240
|
+
* ```tsx
|
|
241
|
+
* const ref = useRef<CodeSandboxHandle>(null);
|
|
242
|
+
* <CodeSandbox ref={ref} files={initialFiles} />
|
|
243
|
+
* // Later:
|
|
244
|
+
* ref.current.updateFiles(newFiles);
|
|
245
|
+
* ```
|
|
246
|
+
*/
|
|
79
247
|
export interface CodeSandboxProps {
|
|
80
|
-
/** Pass files directly */
|
|
248
|
+
/** Pass files directly (from any source — GitHub, DB, S3, agent, etc.) */
|
|
81
249
|
files?: FileMap;
|
|
82
|
-
/** OR load from GitHub */
|
|
83
|
-
github?: GitHubRepo;
|
|
84
|
-
/** GitHub personal access token (for private repos and write-back) */
|
|
85
|
-
gitToken?: string;
|
|
86
250
|
/** OR use a built-in template name */
|
|
87
251
|
template?: string;
|
|
88
252
|
/** Command to start the dev server (default inferred from package.json or template) */
|
|
@@ -91,14 +255,30 @@ export interface CodeSandboxProps {
|
|
|
91
255
|
port?: number;
|
|
92
256
|
/** Environment variables */
|
|
93
257
|
env?: Record<string, string>;
|
|
94
|
-
/** Fires when a file is modified in the editor */
|
|
258
|
+
/** Fires when a file is modified in the editor by the user */
|
|
95
259
|
onFileChange?: (path: string, content: string) => void;
|
|
96
|
-
/** Fires when the dev server is ready */
|
|
260
|
+
/** Fires when the dev server is ready (initial boot or after restart) */
|
|
97
261
|
onServerReady?: (port: number, url: string) => void;
|
|
98
262
|
/** Fires on boot progress changes */
|
|
99
263
|
onProgress?: (progress: BootProgress) => void;
|
|
100
|
-
/** Fires on errors */
|
|
264
|
+
/** Fires on errors (legacy — simple string message) */
|
|
101
265
|
onError?: (error: string) => void;
|
|
266
|
+
/**
|
|
267
|
+
* Fires when a structured error is detected.
|
|
268
|
+
*
|
|
269
|
+
* This is the primary error channel for AI agent integration.
|
|
270
|
+
* Errors include file path, line number, source context, and category
|
|
271
|
+
* so the agent can construct a targeted fix prompt.
|
|
272
|
+
*
|
|
273
|
+
* Max 2 auto-fix attempts per error is recommended (see Ranger pattern).
|
|
274
|
+
*/
|
|
275
|
+
onSandboxError?: (error: SandboxError) => void;
|
|
276
|
+
/**
|
|
277
|
+
* Fires after updateFiles() completes (files written + server restarted).
|
|
278
|
+
* Useful for the host to know when the sandbox has finished processing
|
|
279
|
+
* a file push.
|
|
280
|
+
*/
|
|
281
|
+
onFilesUpdated?: (fileChanges: Record<string, FileChangeStatus>) => void;
|
|
102
282
|
/** CSS class name for the root element */
|
|
103
283
|
className?: string;
|
|
104
284
|
/** Height of the sandbox (default: '100vh') */
|
|
@@ -108,6 +288,8 @@ export interface FileTreeProps {
|
|
|
108
288
|
files: FileNode[];
|
|
109
289
|
selectedFile: string | null;
|
|
110
290
|
onSelectFile: (path: string) => void;
|
|
291
|
+
/** Per-file change status for visual indicators (colored dots) */
|
|
292
|
+
fileChanges?: Record<string, FileChangeStatus>;
|
|
111
293
|
onCreateFile?: (path: string) => void;
|
|
112
294
|
onCreateFolder?: (path: string) => void;
|
|
113
295
|
onDeleteFile?: (path: string) => void;
|
|
@@ -115,6 +297,10 @@ export interface FileTreeProps {
|
|
|
115
297
|
}
|
|
116
298
|
export interface CodeEditorProps {
|
|
117
299
|
files: FileMap;
|
|
300
|
+
/** Baseline files for diff comparison (empty = no diff available) */
|
|
301
|
+
originalFiles: FileMap;
|
|
302
|
+
/** Per-file change status for tab indicators */
|
|
303
|
+
fileChanges: Record<string, FileChangeStatus>;
|
|
118
304
|
activeFile: string | null;
|
|
119
305
|
openFiles: string[];
|
|
120
306
|
onSelectFile: (path: string) => void;
|
|
@@ -130,6 +316,8 @@ export interface PreviewProps {
|
|
|
130
316
|
url: string | null;
|
|
131
317
|
className?: string;
|
|
132
318
|
onRefresh?: () => void;
|
|
319
|
+
/** Called when a JavaScript error occurs inside the preview iframe */
|
|
320
|
+
onBrowserError?: (error: SandboxError) => void;
|
|
133
321
|
}
|
|
134
322
|
export interface BootOverlayProps {
|
|
135
323
|
progress: BootProgress;
|
package/package.json
CHANGED
|
@@ -1,27 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CodeEditor — Monaco editor with file tabs.
|
|
2
|
+
* CodeEditor — Monaco editor with file tabs, diff mode, and change indicators.
|
|
3
3
|
*
|
|
4
|
-
* Uses @monaco-editor/react for the editor and
|
|
5
|
-
* of open files
|
|
4
|
+
* Uses @monaco-editor/react for both the regular editor and diff editor.
|
|
5
|
+
* Renders a tab bar of open files with change status indicators:
|
|
6
|
+
* - Green dot: new file
|
|
7
|
+
* - Orange dot: modified file
|
|
8
|
+
* - Red dot: deleted file (shown as read-only)
|
|
9
|
+
*
|
|
10
|
+
* When diff mode is active, displays Monaco's side-by-side DiffEditor
|
|
11
|
+
* comparing the original file content (left) with the current content (right).
|
|
12
|
+
*
|
|
13
|
+
* @see @monaco-editor/react DiffEditor: https://github.com/suren-atoyan/monaco-react
|
|
6
14
|
*/
|
|
7
15
|
|
|
8
|
-
import React, { useCallback, useMemo } from "react";
|
|
9
|
-
import type { CodeEditorProps } from "../types";
|
|
16
|
+
import React, { useCallback, useMemo, useState } from "react";
|
|
17
|
+
import type { CodeEditorProps, FileChangeStatus } from "../types";
|
|
10
18
|
|
|
11
19
|
// Lazy import Monaco to keep the bundle splittable
|
|
12
20
|
let MonacoEditor: React.ComponentType<any> | null = null;
|
|
21
|
+
let MonacoDiffEditor: React.ComponentType<any> | null = null;
|
|
13
22
|
let monacoLoadPromise: Promise<void> | null = null;
|
|
14
23
|
|
|
15
24
|
/**
|
|
16
|
-
* Load Monaco editor dynamically.
|
|
17
|
-
* Returns
|
|
25
|
+
* Load Monaco editor (regular + diff) dynamically.
|
|
26
|
+
* Returns both Editor and DiffEditor from @monaco-editor/react.
|
|
18
27
|
*/
|
|
19
28
|
function loadMonaco(): Promise<void> {
|
|
20
|
-
if (MonacoEditor) return Promise.resolve();
|
|
29
|
+
if (MonacoEditor && MonacoDiffEditor) return Promise.resolve();
|
|
21
30
|
if (monacoLoadPromise) return monacoLoadPromise;
|
|
22
31
|
|
|
23
32
|
monacoLoadPromise = import("@monaco-editor/react").then((mod) => {
|
|
24
33
|
MonacoEditor = mod.default || mod.Editor;
|
|
34
|
+
MonacoDiffEditor = mod.DiffEditor;
|
|
25
35
|
});
|
|
26
36
|
|
|
27
37
|
return monacoLoadPromise;
|
|
@@ -59,11 +69,34 @@ function getLanguage(path: string): string {
|
|
|
59
69
|
return langMap[ext] || "plaintext";
|
|
60
70
|
}
|
|
61
71
|
|
|
72
|
+
/** Color for each file change status indicator dot */
|
|
73
|
+
const STATUS_COLORS: Record<FileChangeStatus, string> = {
|
|
74
|
+
new: "#4ade80", // green-400
|
|
75
|
+
modified: "#fb923c", // orange-400
|
|
76
|
+
deleted: "#f87171", // red-400
|
|
77
|
+
unchanged: "transparent",
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** Tooltip for each file change status */
|
|
81
|
+
const STATUS_LABELS: Record<FileChangeStatus, string> = {
|
|
82
|
+
new: "New file",
|
|
83
|
+
modified: "Modified",
|
|
84
|
+
deleted: "Deleted",
|
|
85
|
+
unchanged: "",
|
|
86
|
+
};
|
|
87
|
+
|
|
62
88
|
/**
|
|
63
|
-
* CodeEditor component — Monaco editor with file tabs
|
|
89
|
+
* CodeEditor component — Monaco editor with file tabs, diff view, and
|
|
90
|
+
* change status indicators.
|
|
91
|
+
*
|
|
92
|
+
* When `originalFiles` has content for the active file (meaning a prior
|
|
93
|
+
* version exists), a "Diff" toggle button appears in the tab bar. Clicking
|
|
94
|
+
* it switches to Monaco's DiffEditor showing the old (left) vs new (right).
|
|
64
95
|
*/
|
|
65
96
|
export function CodeEditor({
|
|
66
97
|
files,
|
|
98
|
+
originalFiles,
|
|
99
|
+
fileChanges,
|
|
67
100
|
activeFile,
|
|
68
101
|
openFiles,
|
|
69
102
|
onSelectFile,
|
|
@@ -72,6 +105,7 @@ export function CodeEditor({
|
|
|
72
105
|
readOnly = false,
|
|
73
106
|
}: CodeEditorProps) {
|
|
74
107
|
const [loaded, setLoaded] = React.useState(!!MonacoEditor);
|
|
108
|
+
const [diffMode, setDiffMode] = useState(false);
|
|
75
109
|
|
|
76
110
|
// Load Monaco on mount
|
|
77
111
|
React.useEffect(() => {
|
|
@@ -81,8 +115,29 @@ export function CodeEditor({
|
|
|
81
115
|
}, []);
|
|
82
116
|
|
|
83
117
|
const activeContent = activeFile ? files[activeFile] || "" : "";
|
|
118
|
+
const activeOriginalContent = activeFile
|
|
119
|
+
? (originalFiles[activeFile] ?? "")
|
|
120
|
+
: "";
|
|
84
121
|
const language = activeFile ? getLanguage(activeFile) : "plaintext";
|
|
85
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Whether the active file has a diff available.
|
|
125
|
+
* A diff is available when:
|
|
126
|
+
* - The file is marked as "modified" (content differs from original), OR
|
|
127
|
+
* - The file is marked as "new" (no original — diff shows empty left side)
|
|
128
|
+
*/
|
|
129
|
+
const hasDiff = activeFile
|
|
130
|
+
? fileChanges[activeFile] === "modified" ||
|
|
131
|
+
fileChanges[activeFile] === "new"
|
|
132
|
+
: false;
|
|
133
|
+
|
|
134
|
+
// Auto-disable diff mode when switching to a file with no diff
|
|
135
|
+
React.useEffect(() => {
|
|
136
|
+
if (!hasDiff && diffMode) {
|
|
137
|
+
setDiffMode(false);
|
|
138
|
+
}
|
|
139
|
+
}, [hasDiff, diffMode, activeFile]);
|
|
140
|
+
|
|
86
141
|
const handleEditorChange = useCallback(
|
|
87
142
|
(value: string | undefined) => {
|
|
88
143
|
if (activeFile && value !== undefined) {
|
|
@@ -92,6 +147,23 @@ export function CodeEditor({
|
|
|
92
147
|
[activeFile, onFileChange],
|
|
93
148
|
);
|
|
94
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Handler for Monaco DiffEditor's modified content changes.
|
|
152
|
+
* The DiffEditor fires `onMount` with the editor instance — we
|
|
153
|
+
* listen to the modified editor's onDidChangeModelContent event.
|
|
154
|
+
*/
|
|
155
|
+
const handleDiffEditorMount = useCallback(
|
|
156
|
+
(editor: any) => {
|
|
157
|
+
if (!editor || !activeFile) return;
|
|
158
|
+
const modifiedEditor = editor.getModifiedEditor();
|
|
159
|
+
modifiedEditor.onDidChangeModelContent(() => {
|
|
160
|
+
const value = modifiedEditor.getValue();
|
|
161
|
+
onFileChange(activeFile, value);
|
|
162
|
+
});
|
|
163
|
+
},
|
|
164
|
+
[activeFile, onFileChange],
|
|
165
|
+
);
|
|
166
|
+
|
|
95
167
|
return (
|
|
96
168
|
<div className="flex flex-col h-full bg-sb-editor">
|
|
97
169
|
{/* Tab bar */}
|
|
@@ -99,6 +171,8 @@ export function CodeEditor({
|
|
|
99
171
|
{openFiles.map((path) => {
|
|
100
172
|
const fileName = path.split("/").pop() || path;
|
|
101
173
|
const isActive = path === activeFile;
|
|
174
|
+
const changeStatus: FileChangeStatus =
|
|
175
|
+
fileChanges[path] || "unchanged";
|
|
102
176
|
|
|
103
177
|
return (
|
|
104
178
|
<div
|
|
@@ -111,6 +185,14 @@ export function CodeEditor({
|
|
|
111
185
|
}`}
|
|
112
186
|
onClick={() => onSelectFile(path)}
|
|
113
187
|
>
|
|
188
|
+
{/* Change status indicator dot */}
|
|
189
|
+
{changeStatus !== "unchanged" && (
|
|
190
|
+
<span
|
|
191
|
+
className="w-2 h-2 rounded-full shrink-0"
|
|
192
|
+
style={{ backgroundColor: STATUS_COLORS[changeStatus] }}
|
|
193
|
+
title={STATUS_LABELS[changeStatus]}
|
|
194
|
+
/>
|
|
195
|
+
)}
|
|
114
196
|
<span className="truncate max-w-[120px]">{fileName}</span>
|
|
115
197
|
<button
|
|
116
198
|
className="opacity-0 group-hover:opacity-100 hover:text-sb-text-active ml-1 text-[10px]"
|
|
@@ -125,9 +207,27 @@ export function CodeEditor({
|
|
|
125
207
|
</div>
|
|
126
208
|
);
|
|
127
209
|
})}
|
|
210
|
+
|
|
211
|
+
{/* Spacer */}
|
|
212
|
+
<div className="flex-1" />
|
|
213
|
+
|
|
214
|
+
{/* Diff toggle button — only shown when a diff is available */}
|
|
215
|
+
{hasDiff && (
|
|
216
|
+
<button
|
|
217
|
+
className={`shrink-0 px-2 py-1 mr-1 text-[10px] font-medium rounded transition-colors ${
|
|
218
|
+
diffMode
|
|
219
|
+
? "bg-sb-accent text-white"
|
|
220
|
+
: "text-sb-text-muted hover:text-sb-text-active hover:bg-sb-bg-hover"
|
|
221
|
+
}`}
|
|
222
|
+
onClick={() => setDiffMode((prev) => !prev)}
|
|
223
|
+
title={diffMode ? "Switch to editor" : "Show diff view"}
|
|
224
|
+
>
|
|
225
|
+
{diffMode ? "Editor" : "Diff"}
|
|
226
|
+
</button>
|
|
227
|
+
)}
|
|
128
228
|
</div>
|
|
129
229
|
|
|
130
|
-
{/* Editor */}
|
|
230
|
+
{/* Editor / DiffEditor / placeholder */}
|
|
131
231
|
<div className="flex-1 overflow-hidden">
|
|
132
232
|
{!activeFile && (
|
|
133
233
|
<div className="flex items-center justify-center h-full text-sb-text-muted text-sm">
|
|
@@ -135,7 +235,37 @@ export function CodeEditor({
|
|
|
135
235
|
</div>
|
|
136
236
|
)}
|
|
137
237
|
|
|
138
|
-
{
|
|
238
|
+
{/* Diff mode: Monaco DiffEditor (side-by-side) */}
|
|
239
|
+
{activeFile && loaded && diffMode && MonacoDiffEditor && (
|
|
240
|
+
<MonacoDiffEditor
|
|
241
|
+
height="100%"
|
|
242
|
+
language={language}
|
|
243
|
+
original={activeOriginalContent}
|
|
244
|
+
modified={activeContent}
|
|
245
|
+
theme="vs-dark"
|
|
246
|
+
onMount={handleDiffEditorMount}
|
|
247
|
+
options={{
|
|
248
|
+
readOnly,
|
|
249
|
+
minimap: { enabled: false },
|
|
250
|
+
fontSize: 13,
|
|
251
|
+
wordWrap: "on",
|
|
252
|
+
scrollBeyondLastLine: false,
|
|
253
|
+
padding: { top: 8 },
|
|
254
|
+
renderWhitespace: "selection",
|
|
255
|
+
automaticLayout: true,
|
|
256
|
+
tabSize: 2,
|
|
257
|
+
// DiffEditor-specific options
|
|
258
|
+
renderSideBySide: true,
|
|
259
|
+
enableSplitViewResizing: true,
|
|
260
|
+
renderIndicators: true,
|
|
261
|
+
renderMarginRevertIcon: false,
|
|
262
|
+
originalEditable: false,
|
|
263
|
+
}}
|
|
264
|
+
/>
|
|
265
|
+
)}
|
|
266
|
+
|
|
267
|
+
{/* Regular mode: Monaco Editor */}
|
|
268
|
+
{activeFile && loaded && !diffMode && MonacoEditor && (
|
|
139
269
|
<MonacoEditor
|
|
140
270
|
height="100%"
|
|
141
271
|
language={language}
|