@illuma-ai/code-sandbox 1.0.0 → 1.1.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/hooks/useRuntime.d.ts +24 -1
- package/dist/index.cjs +351 -83
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +8698 -7956
- package/dist/index.js.map +1 -1
- package/dist/services/runtime.d.ts +58 -1
- package/dist/types.d.ts +97 -1
- 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 +12 -2
- package/src/hooks/useRuntime.ts +450 -79
- package/src/index.ts +3 -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 +113 -1
|
@@ -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
|
@@ -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,30 @@ 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 (initial load, or the version before the last GitHub poll).
|
|
106
|
+
*
|
|
107
|
+
* Used by the diff viewer to show what changed. Empty on first load
|
|
108
|
+
* (everything is "new"), populated after the first file update
|
|
109
|
+
* (GitHub poll, updateFiles, etc.).
|
|
110
|
+
*/
|
|
111
|
+
originalFiles: FileMap;
|
|
112
|
+
/**
|
|
113
|
+
* Per-file change status relative to originalFiles.
|
|
114
|
+
*
|
|
115
|
+
* Only includes files that have a non-"unchanged" status.
|
|
116
|
+
* Missing keys should be treated as "unchanged".
|
|
117
|
+
*/
|
|
118
|
+
fileChanges: Record<string, FileChangeStatus>;
|
|
47
119
|
/** Error message if status is 'error' */
|
|
48
120
|
error: string | null;
|
|
121
|
+
/**
|
|
122
|
+
* All structured errors collected during this session.
|
|
123
|
+
* New errors are appended — the array grows monotonically.
|
|
124
|
+
* Use `errors[errors.length - 1]` to get the latest.
|
|
125
|
+
*/
|
|
126
|
+
errors: SandboxError[];
|
|
49
127
|
}
|
|
50
128
|
export interface GitHubRepo {
|
|
51
129
|
owner: string;
|
|
@@ -97,8 +175,18 @@ export interface CodeSandboxProps {
|
|
|
97
175
|
onServerReady?: (port: number, url: string) => void;
|
|
98
176
|
/** Fires on boot progress changes */
|
|
99
177
|
onProgress?: (progress: BootProgress) => void;
|
|
100
|
-
/** Fires on errors */
|
|
178
|
+
/** Fires on errors (legacy — simple string message) */
|
|
101
179
|
onError?: (error: string) => void;
|
|
180
|
+
/**
|
|
181
|
+
* Fires when a structured error is detected.
|
|
182
|
+
*
|
|
183
|
+
* This is the primary error channel for AI agent integration.
|
|
184
|
+
* Errors include file path, line number, source context, and category
|
|
185
|
+
* so the agent can construct a targeted fix prompt.
|
|
186
|
+
*
|
|
187
|
+
* Max 2 auto-fix attempts per error is recommended (see Ranger pattern).
|
|
188
|
+
*/
|
|
189
|
+
onSandboxError?: (error: SandboxError) => void;
|
|
102
190
|
/** CSS class name for the root element */
|
|
103
191
|
className?: string;
|
|
104
192
|
/** Height of the sandbox (default: '100vh') */
|
|
@@ -108,6 +196,8 @@ export interface FileTreeProps {
|
|
|
108
196
|
files: FileNode[];
|
|
109
197
|
selectedFile: string | null;
|
|
110
198
|
onSelectFile: (path: string) => void;
|
|
199
|
+
/** Per-file change status for visual indicators (colored dots) */
|
|
200
|
+
fileChanges?: Record<string, FileChangeStatus>;
|
|
111
201
|
onCreateFile?: (path: string) => void;
|
|
112
202
|
onCreateFolder?: (path: string) => void;
|
|
113
203
|
onDeleteFile?: (path: string) => void;
|
|
@@ -115,6 +205,10 @@ export interface FileTreeProps {
|
|
|
115
205
|
}
|
|
116
206
|
export interface CodeEditorProps {
|
|
117
207
|
files: FileMap;
|
|
208
|
+
/** Baseline files for diff comparison (empty = no diff available) */
|
|
209
|
+
originalFiles: FileMap;
|
|
210
|
+
/** Per-file change status for tab indicators */
|
|
211
|
+
fileChanges: Record<string, FileChangeStatus>;
|
|
118
212
|
activeFile: string | null;
|
|
119
213
|
openFiles: string[];
|
|
120
214
|
onSelectFile: (path: string) => void;
|
|
@@ -130,6 +224,8 @@ export interface PreviewProps {
|
|
|
130
224
|
url: string | null;
|
|
131
225
|
className?: string;
|
|
132
226
|
onRefresh?: () => void;
|
|
227
|
+
/** Called when a JavaScript error occurs inside the preview iframe */
|
|
228
|
+
onBrowserError?: (error: SandboxError) => void;
|
|
133
229
|
}
|
|
134
230
|
export interface BootOverlayProps {
|
|
135
231
|
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}
|
|
@@ -7,7 +7,28 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import React, { useState } from "react";
|
|
10
|
-
import type {
|
|
10
|
+
import type {
|
|
11
|
+
FileMap,
|
|
12
|
+
FileNode,
|
|
13
|
+
FileTreeProps,
|
|
14
|
+
FileChangeStatus,
|
|
15
|
+
} from "../types";
|
|
16
|
+
|
|
17
|
+
/** Color for each file change status indicator dot */
|
|
18
|
+
const STATUS_COLORS: Record<FileChangeStatus, string> = {
|
|
19
|
+
new: "#4ade80", // green-400
|
|
20
|
+
modified: "#fb923c", // orange-400
|
|
21
|
+
deleted: "#f87171", // red-400
|
|
22
|
+
unchanged: "transparent",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Short label for each file change status (one character) */
|
|
26
|
+
const STATUS_LETTERS: Record<FileChangeStatus, string> = {
|
|
27
|
+
new: "N",
|
|
28
|
+
modified: "M",
|
|
29
|
+
deleted: "D",
|
|
30
|
+
unchanged: "",
|
|
31
|
+
};
|
|
11
32
|
|
|
12
33
|
/**
|
|
13
34
|
* Build a tree structure from a flat FileMap.
|
|
@@ -201,8 +222,14 @@ function FileIcon({ name }: { name: string }) {
|
|
|
201
222
|
|
|
202
223
|
/**
|
|
203
224
|
* FileTree component — renders a collapsible tree of files and folders.
|
|
225
|
+
* Shows change status indicators (N/M/D) next to modified files.
|
|
204
226
|
*/
|
|
205
|
-
export function FileTree({
|
|
227
|
+
export function FileTree({
|
|
228
|
+
files,
|
|
229
|
+
selectedFile,
|
|
230
|
+
onSelectFile,
|
|
231
|
+
fileChanges,
|
|
232
|
+
}: FileTreeProps) {
|
|
206
233
|
return (
|
|
207
234
|
<div className="h-full overflow-auto bg-sb-sidebar text-sm select-none">
|
|
208
235
|
<div className="px-3 py-2 text-[11px] font-semibold text-sb-text-muted uppercase tracking-wider border-b border-sb-border">
|
|
@@ -216,6 +243,7 @@ export function FileTree({ files, selectedFile, onSelectFile }: FileTreeProps) {
|
|
|
216
243
|
depth={0}
|
|
217
244
|
selectedFile={selectedFile}
|
|
218
245
|
onSelectFile={onSelectFile}
|
|
246
|
+
fileChanges={fileChanges}
|
|
219
247
|
/>
|
|
220
248
|
))}
|
|
221
249
|
</div>
|
|
@@ -229,18 +257,36 @@ function TreeNode({
|
|
|
229
257
|
depth,
|
|
230
258
|
selectedFile,
|
|
231
259
|
onSelectFile,
|
|
260
|
+
fileChanges,
|
|
232
261
|
}: {
|
|
233
262
|
node: FileNode;
|
|
234
263
|
depth: number;
|
|
235
264
|
selectedFile: string | null;
|
|
236
265
|
onSelectFile: (path: string) => void;
|
|
266
|
+
fileChanges?: Record<string, FileChangeStatus>;
|
|
237
267
|
}) {
|
|
238
268
|
const [expanded, setExpanded] = useState(depth < 2);
|
|
239
269
|
|
|
240
270
|
const isSelected = node.path === selectedFile;
|
|
241
271
|
const paddingLeft = 8 + depth * 16;
|
|
272
|
+
const changeStatus: FileChangeStatus =
|
|
273
|
+
fileChanges?.[node.path] ?? "unchanged";
|
|
242
274
|
|
|
243
275
|
if (node.type === "directory") {
|
|
276
|
+
// Check if any child has changes (to show a subtle indicator on folders)
|
|
277
|
+
const hasChangedChild =
|
|
278
|
+
fileChanges &&
|
|
279
|
+
node.children?.some((c) => {
|
|
280
|
+
if (fileChanges[c.path]) return true;
|
|
281
|
+
// For directories, check recursively by prefix
|
|
282
|
+
if (c.type === "directory") {
|
|
283
|
+
return Object.keys(fileChanges).some(
|
|
284
|
+
(p) => p.startsWith(c.path + "/") && fileChanges[p] !== "unchanged",
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
return false;
|
|
288
|
+
});
|
|
289
|
+
|
|
244
290
|
return (
|
|
245
291
|
<div>
|
|
246
292
|
<button
|
|
@@ -252,7 +298,13 @@ function TreeNode({
|
|
|
252
298
|
>
|
|
253
299
|
<ChevronIcon expanded={expanded} />
|
|
254
300
|
<FolderIcon open={expanded} />
|
|
255
|
-
<span className="truncate ml-0.5">{node.name}</span>
|
|
301
|
+
<span className="truncate ml-0.5 flex-1">{node.name}</span>
|
|
302
|
+
{hasChangedChild && (
|
|
303
|
+
<span
|
|
304
|
+
className="w-1.5 h-1.5 rounded-full shrink-0 mr-2"
|
|
305
|
+
style={{ backgroundColor: "#fb923c" }}
|
|
306
|
+
/>
|
|
307
|
+
)}
|
|
256
308
|
</button>
|
|
257
309
|
{expanded && node.children && (
|
|
258
310
|
<div>
|
|
@@ -263,6 +315,7 @@ function TreeNode({
|
|
|
263
315
|
depth={depth + 1}
|
|
264
316
|
selectedFile={selectedFile}
|
|
265
317
|
onSelectFile={onSelectFile}
|
|
318
|
+
fileChanges={fileChanges}
|
|
266
319
|
/>
|
|
267
320
|
))}
|
|
268
321
|
</div>
|
|
@@ -280,7 +333,16 @@ function TreeNode({
|
|
|
280
333
|
onClick={() => onSelectFile(node.path)}
|
|
281
334
|
>
|
|
282
335
|
<FileIcon name={node.name} />
|
|
283
|
-
<span className="truncate ml-0.5">{node.name}</span>
|
|
336
|
+
<span className="truncate ml-0.5 flex-1">{node.name}</span>
|
|
337
|
+
{changeStatus !== "unchanged" && (
|
|
338
|
+
<span
|
|
339
|
+
className="text-[9px] font-bold shrink-0 mr-2"
|
|
340
|
+
style={{ color: STATUS_COLORS[changeStatus] }}
|
|
341
|
+
title={changeStatus}
|
|
342
|
+
>
|
|
343
|
+
{STATUS_LETTERS[changeStatus]}
|
|
344
|
+
</span>
|
|
345
|
+
)}
|
|
284
346
|
</button>
|
|
285
347
|
);
|
|
286
348
|
}
|