@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.
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "@illuma-ai/code-sandbox",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Illuma AI (https://github.com/illuma-ai)",
@@ -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 renders a tab bar
5
- * of open files. Supports dark theme that matches the sandbox.
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 the Editor component from @monaco-editor/react.
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
- {activeFile && loaded && MonacoEditor && (
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 { FileMap, FileNode, FileTreeProps } from "../types";
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({ files, selectedFile, onSelectFile }: FileTreeProps) {
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
  }