@illuma-ai/code-sandbox 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +18 -12
- package/README.md +10 -11
- package/dist/index.cjs +91 -96
- package/dist/index.js +11895 -17954
- package/dist/styles.css +1 -1
- package/package.json +9 -12
- package/dist/index.cjs.map +0 -1
- package/dist/index.js.map +0 -1
- package/src/components/BootOverlay.tsx +0 -145
- package/src/components/CodeEditor.tsx +0 -298
- package/src/components/FileTree.tsx +0 -678
- package/src/components/Preview.tsx +0 -262
- package/src/components/Terminal.tsx +0 -111
- package/src/components/ViewSlider.tsx +0 -87
- package/src/components/Workbench.tsx +0 -382
- package/src/hooks/useRuntime.ts +0 -637
- package/src/index.ts +0 -51
- package/src/services/runtime.ts +0 -775
- package/src/styles.css +0 -178
- package/src/templates/fullstack-starter.ts +0 -3507
- package/src/templates/index.ts +0 -607
- package/src/types.ts +0 -375
|
@@ -1,678 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* FileTree — Collapsible file/folder tree with icons.
|
|
3
|
-
*
|
|
4
|
-
* Renders a recursive tree of FileNodes. Folders expand/collapse on click.
|
|
5
|
-
* Files are selectable and show the appropriate icon based on extension.
|
|
6
|
-
* Uses inline SVG icons for reliable cross-platform rendering.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import React, { useState } from "react";
|
|
10
|
-
import type {
|
|
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
|
-
};
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Build a tree structure from a flat FileMap.
|
|
35
|
-
*
|
|
36
|
-
* Converts { "routes/api.js": "...", "server.js": "..." }
|
|
37
|
-
* into a nested FileNode[] tree.
|
|
38
|
-
*/
|
|
39
|
-
export function buildFileTree(files: FileMap): FileNode[] {
|
|
40
|
-
const root: FileNode[] = [];
|
|
41
|
-
const dirs = new Map<string, FileNode>();
|
|
42
|
-
|
|
43
|
-
// Sort paths so directories come before files at each level
|
|
44
|
-
const sortedPaths = Object.keys(files).sort((a, b) => {
|
|
45
|
-
const aParts = a.split("/");
|
|
46
|
-
const bParts = b.split("/");
|
|
47
|
-
for (let i = 0; i < Math.min(aParts.length, bParts.length); i++) {
|
|
48
|
-
if (aParts[i] !== bParts[i]) {
|
|
49
|
-
const aIsDir = i < aParts.length - 1;
|
|
50
|
-
const bIsDir = i < bParts.length - 1;
|
|
51
|
-
if (aIsDir && !bIsDir) return -1;
|
|
52
|
-
if (!aIsDir && bIsDir) return 1;
|
|
53
|
-
return aParts[i].localeCompare(bParts[i]);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
return aParts.length - bParts.length;
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
for (const filePath of sortedPaths) {
|
|
60
|
-
const parts = filePath.split("/");
|
|
61
|
-
|
|
62
|
-
// Ensure all parent directories exist
|
|
63
|
-
for (let i = 0; i < parts.length - 1; i++) {
|
|
64
|
-
const dirPath = parts.slice(0, i + 1).join("/");
|
|
65
|
-
if (!dirs.has(dirPath)) {
|
|
66
|
-
const dirNode: FileNode = {
|
|
67
|
-
name: parts[i],
|
|
68
|
-
path: dirPath,
|
|
69
|
-
type: "directory",
|
|
70
|
-
children: [],
|
|
71
|
-
};
|
|
72
|
-
dirs.set(dirPath, dirNode);
|
|
73
|
-
|
|
74
|
-
if (i === 0) {
|
|
75
|
-
root.push(dirNode);
|
|
76
|
-
} else {
|
|
77
|
-
const parentPath = parts.slice(0, i).join("/");
|
|
78
|
-
const parent = dirs.get(parentPath);
|
|
79
|
-
parent?.children?.push(dirNode);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const fileNode: FileNode = {
|
|
85
|
-
name: parts[parts.length - 1],
|
|
86
|
-
path: filePath,
|
|
87
|
-
type: "file",
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
if (parts.length === 1) {
|
|
91
|
-
root.push(fileNode);
|
|
92
|
-
} else {
|
|
93
|
-
const parentPath = parts.slice(0, -1).join("/");
|
|
94
|
-
const parent = dirs.get(parentPath);
|
|
95
|
-
parent?.children?.push(fileNode);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
return root;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// ---------------------------------------------------------------------------
|
|
103
|
-
// SVG Icons — inline for reliable rendering (no emoji issues)
|
|
104
|
-
// ---------------------------------------------------------------------------
|
|
105
|
-
|
|
106
|
-
/** Chevron right arrow for folder expand/collapse */
|
|
107
|
-
function ChevronIcon({ expanded }: { expanded: boolean }) {
|
|
108
|
-
return (
|
|
109
|
-
<svg
|
|
110
|
-
width="16"
|
|
111
|
-
height="16"
|
|
112
|
-
viewBox="0 0 16 16"
|
|
113
|
-
fill="none"
|
|
114
|
-
className="shrink-0 transition-transform duration-150"
|
|
115
|
-
style={{ transform: expanded ? "rotate(90deg)" : "none" }}
|
|
116
|
-
>
|
|
117
|
-
<path
|
|
118
|
-
d="M6 4l4 4-4 4"
|
|
119
|
-
stroke="currentColor"
|
|
120
|
-
strokeWidth="1.5"
|
|
121
|
-
strokeLinecap="round"
|
|
122
|
-
strokeLinejoin="round"
|
|
123
|
-
/>
|
|
124
|
-
</svg>
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** Folder icon (open or closed) */
|
|
129
|
-
function FolderIcon({ open }: { open: boolean }) {
|
|
130
|
-
if (open) {
|
|
131
|
-
return (
|
|
132
|
-
<svg
|
|
133
|
-
width="16"
|
|
134
|
-
height="16"
|
|
135
|
-
viewBox="0 0 16 16"
|
|
136
|
-
fill="none"
|
|
137
|
-
className="shrink-0"
|
|
138
|
-
>
|
|
139
|
-
<path
|
|
140
|
-
d="M1.5 3.5A1 1 0 012.5 2.5h3.172a1 1 0 01.707.293L7.5 3.914a1 1 0 00.707.293H13.5a1 1 0 011 1v1.293H3.207a1 1 0 00-.966.741L1.5 10.5V3.5z"
|
|
141
|
-
fill="#dcb67a"
|
|
142
|
-
/>
|
|
143
|
-
<path
|
|
144
|
-
d="M2.241 7.241A1 1 0 013.207 6.5H14.5a1 1 0 01.966 1.259l-1.5 5.5A1 1 0 0113 14H2.5a1 1 0 01-1-1V8.5a1 1 0 01.741-.966l0-.293z"
|
|
145
|
-
fill="#e8c87a"
|
|
146
|
-
/>
|
|
147
|
-
</svg>
|
|
148
|
-
);
|
|
149
|
-
}
|
|
150
|
-
return (
|
|
151
|
-
<svg
|
|
152
|
-
width="16"
|
|
153
|
-
height="16"
|
|
154
|
-
viewBox="0 0 16 16"
|
|
155
|
-
fill="none"
|
|
156
|
-
className="shrink-0"
|
|
157
|
-
>
|
|
158
|
-
<path
|
|
159
|
-
d="M1.5 3A1.5 1.5 0 013 1.5h3.172a1.5 1.5 0 011.06.44L8.354 3.06a.5.5 0 00.354.147H13A1.5 1.5 0 0114.5 4.707V12A1.5 1.5 0 0113 13.5H3A1.5 1.5 0 011.5 12V3z"
|
|
160
|
-
fill="#dcb67a"
|
|
161
|
-
/>
|
|
162
|
-
</svg>
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// ---------------------------------------------------------------------------
|
|
167
|
-
// Per-extension file icons — compact 16x16 SVGs with distinct shapes/labels.
|
|
168
|
-
// Inspired by VS Code's Seti/Material icon themes.
|
|
169
|
-
// ---------------------------------------------------------------------------
|
|
170
|
-
|
|
171
|
-
/** SVG wrapper — common props for all file icons */
|
|
172
|
-
function IconSvg({
|
|
173
|
-
children,
|
|
174
|
-
className = "",
|
|
175
|
-
}: {
|
|
176
|
-
children: React.ReactNode;
|
|
177
|
-
className?: string;
|
|
178
|
-
}) {
|
|
179
|
-
return (
|
|
180
|
-
<svg
|
|
181
|
-
width="16"
|
|
182
|
-
height="16"
|
|
183
|
-
viewBox="0 0 16 16"
|
|
184
|
-
fill="none"
|
|
185
|
-
className={`shrink-0 ${className}`}
|
|
186
|
-
>
|
|
187
|
-
{children}
|
|
188
|
-
</svg>
|
|
189
|
-
);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/** Base document shape used by most file icons */
|
|
193
|
-
function DocShape({ color }: { color: string }) {
|
|
194
|
-
return (
|
|
195
|
-
<>
|
|
196
|
-
<path
|
|
197
|
-
d="M4 1.5h5.5L13 5v9.5a1 1 0 01-1 1H4a1 1 0 01-1-1v-13a1 1 0 011-1z"
|
|
198
|
-
fill={color}
|
|
199
|
-
opacity="0.15"
|
|
200
|
-
/>
|
|
201
|
-
<path
|
|
202
|
-
d="M4 1.5h5.5L13 5v9.5a1 1 0 01-1 1H4a1 1 0 01-1-1v-13a1 1 0 011-1z"
|
|
203
|
-
stroke={color}
|
|
204
|
-
strokeWidth="0.8"
|
|
205
|
-
/>
|
|
206
|
-
<path d="M9.5 1.5V5H13" stroke={color} strokeWidth="0.8" />
|
|
207
|
-
</>
|
|
208
|
-
);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/** Label text centered on the document icon */
|
|
212
|
-
function DocLabel({
|
|
213
|
-
label,
|
|
214
|
-
color,
|
|
215
|
-
y = "11.5",
|
|
216
|
-
fontSize = "5",
|
|
217
|
-
}: {
|
|
218
|
-
label: string;
|
|
219
|
-
color: string;
|
|
220
|
-
y?: string;
|
|
221
|
-
fontSize?: string;
|
|
222
|
-
}) {
|
|
223
|
-
return (
|
|
224
|
-
<text
|
|
225
|
-
x="8"
|
|
226
|
-
y={y}
|
|
227
|
-
textAnchor="middle"
|
|
228
|
-
fill={color}
|
|
229
|
-
fontSize={fontSize}
|
|
230
|
-
fontWeight="700"
|
|
231
|
-
fontFamily="Arial, sans-serif"
|
|
232
|
-
>
|
|
233
|
-
{label}
|
|
234
|
-
</text>
|
|
235
|
-
);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/** JavaScript (.js) */
|
|
239
|
-
function JsIcon() {
|
|
240
|
-
return (
|
|
241
|
-
<IconSvg>
|
|
242
|
-
<DocShape color="#f0db4f" />
|
|
243
|
-
<DocLabel label="JS" color="#f0db4f" />
|
|
244
|
-
</IconSvg>
|
|
245
|
-
);
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/** JSX (.jsx) */
|
|
249
|
-
function JsxIcon() {
|
|
250
|
-
return (
|
|
251
|
-
<IconSvg>
|
|
252
|
-
<DocShape color="#61dafb" />
|
|
253
|
-
<DocLabel label="JSX" color="#61dafb" fontSize="4.5" />
|
|
254
|
-
</IconSvg>
|
|
255
|
-
);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/** TypeScript (.ts) */
|
|
259
|
-
function TsIcon() {
|
|
260
|
-
return (
|
|
261
|
-
<IconSvg>
|
|
262
|
-
<DocShape color="#3178c6" />
|
|
263
|
-
<DocLabel label="TS" color="#3178c6" />
|
|
264
|
-
</IconSvg>
|
|
265
|
-
);
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/** TSX (.tsx) */
|
|
269
|
-
function TsxIcon() {
|
|
270
|
-
return (
|
|
271
|
-
<IconSvg>
|
|
272
|
-
<DocShape color="#3178c6" />
|
|
273
|
-
<DocLabel label="TSX" color="#61dafb" fontSize="4.5" />
|
|
274
|
-
</IconSvg>
|
|
275
|
-
);
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
/** JSON (.json) */
|
|
279
|
-
function JsonIcon() {
|
|
280
|
-
return (
|
|
281
|
-
<IconSvg>
|
|
282
|
-
<DocShape color="#a8b065" />
|
|
283
|
-
<text
|
|
284
|
-
x="8"
|
|
285
|
-
y="11"
|
|
286
|
-
textAnchor="middle"
|
|
287
|
-
fill="#a8b065"
|
|
288
|
-
fontSize="7"
|
|
289
|
-
fontWeight="700"
|
|
290
|
-
fontFamily="Arial, sans-serif"
|
|
291
|
-
>
|
|
292
|
-
{"{}"}
|
|
293
|
-
</text>
|
|
294
|
-
</IconSvg>
|
|
295
|
-
);
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
/** HTML (.html) */
|
|
299
|
-
function HtmlIcon() {
|
|
300
|
-
return (
|
|
301
|
-
<IconSvg>
|
|
302
|
-
<DocShape color="#e44d26" />
|
|
303
|
-
<text
|
|
304
|
-
x="8"
|
|
305
|
-
y="11.5"
|
|
306
|
-
textAnchor="middle"
|
|
307
|
-
fill="#e44d26"
|
|
308
|
-
fontSize="4.5"
|
|
309
|
-
fontWeight="700"
|
|
310
|
-
fontFamily="Arial, sans-serif"
|
|
311
|
-
>
|
|
312
|
-
{"</>"}
|
|
313
|
-
</text>
|
|
314
|
-
</IconSvg>
|
|
315
|
-
);
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/** CSS (.css) */
|
|
319
|
-
function CssIcon() {
|
|
320
|
-
return (
|
|
321
|
-
<IconSvg>
|
|
322
|
-
<DocShape color="#264de4" />
|
|
323
|
-
<DocLabel label="#" color="#264de4" fontSize="7" />
|
|
324
|
-
</IconSvg>
|
|
325
|
-
);
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
/** SCSS (.scss) */
|
|
329
|
-
function ScssIcon() {
|
|
330
|
-
return (
|
|
331
|
-
<IconSvg>
|
|
332
|
-
<DocShape color="#cd6799" />
|
|
333
|
-
<DocLabel label="S" color="#cd6799" fontSize="7" />
|
|
334
|
-
</IconSvg>
|
|
335
|
-
);
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
/** Markdown (.md) */
|
|
339
|
-
function MdIcon() {
|
|
340
|
-
return (
|
|
341
|
-
<IconSvg>
|
|
342
|
-
<DocShape color="#9da5b4" />
|
|
343
|
-
<DocLabel label="M↓" color="#9da5b4" fontSize="4.5" />
|
|
344
|
-
</IconSvg>
|
|
345
|
-
);
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
/** Python (.py) */
|
|
349
|
-
function PyIcon() {
|
|
350
|
-
return (
|
|
351
|
-
<IconSvg>
|
|
352
|
-
<DocShape color="#3776ab" />
|
|
353
|
-
<DocLabel label="PY" color="#3776ab" />
|
|
354
|
-
</IconSvg>
|
|
355
|
-
);
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
/** Ruby (.rb) */
|
|
359
|
-
function RbIcon() {
|
|
360
|
-
return (
|
|
361
|
-
<IconSvg>
|
|
362
|
-
<DocShape color="#cc342d" />
|
|
363
|
-
<text
|
|
364
|
-
x="8"
|
|
365
|
-
y="11.5"
|
|
366
|
-
textAnchor="middle"
|
|
367
|
-
fill="#cc342d"
|
|
368
|
-
fontSize="6"
|
|
369
|
-
fontWeight="700"
|
|
370
|
-
fontFamily="Arial, sans-serif"
|
|
371
|
-
>
|
|
372
|
-
{"◆"}
|
|
373
|
-
</text>
|
|
374
|
-
</IconSvg>
|
|
375
|
-
);
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
/** Go (.go) */
|
|
379
|
-
function GoIcon() {
|
|
380
|
-
return (
|
|
381
|
-
<IconSvg>
|
|
382
|
-
<DocShape color="#00add8" />
|
|
383
|
-
<DocLabel label="GO" color="#00add8" />
|
|
384
|
-
</IconSvg>
|
|
385
|
-
);
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
/** Rust (.rs) */
|
|
389
|
-
function RsIcon() {
|
|
390
|
-
return (
|
|
391
|
-
<IconSvg>
|
|
392
|
-
<DocShape color="#dea584" />
|
|
393
|
-
<DocLabel label="RS" color="#dea584" />
|
|
394
|
-
</IconSvg>
|
|
395
|
-
);
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
/** YAML (.yml, .yaml) */
|
|
399
|
-
function YmlIcon() {
|
|
400
|
-
return (
|
|
401
|
-
<IconSvg>
|
|
402
|
-
<DocShape color="#cb171e" />
|
|
403
|
-
<DocLabel label="YML" color="#cb171e" fontSize="4" />
|
|
404
|
-
</IconSvg>
|
|
405
|
-
);
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
/** Env (.env) */
|
|
409
|
-
function EnvIcon() {
|
|
410
|
-
return (
|
|
411
|
-
<IconSvg>
|
|
412
|
-
<DocShape color="#ecd53f" />
|
|
413
|
-
<text
|
|
414
|
-
x="8"
|
|
415
|
-
y="11.5"
|
|
416
|
-
textAnchor="middle"
|
|
417
|
-
fill="#ecd53f"
|
|
418
|
-
fontSize="6"
|
|
419
|
-
fontWeight="700"
|
|
420
|
-
fontFamily="Arial, sans-serif"
|
|
421
|
-
>
|
|
422
|
-
{"⚙"}
|
|
423
|
-
</text>
|
|
424
|
-
</IconSvg>
|
|
425
|
-
);
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
/** Shell (.sh, .bash) */
|
|
429
|
-
function ShIcon() {
|
|
430
|
-
return (
|
|
431
|
-
<IconSvg>
|
|
432
|
-
<DocShape color="#89e051" />
|
|
433
|
-
<DocLabel label="$_" color="#89e051" fontSize="5" />
|
|
434
|
-
</IconSvg>
|
|
435
|
-
);
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
/** SQL (.sql) */
|
|
439
|
-
function SqlIcon() {
|
|
440
|
-
return (
|
|
441
|
-
<IconSvg>
|
|
442
|
-
<DocShape color="#e38c00" />
|
|
443
|
-
<DocLabel label="SQL" color="#e38c00" fontSize="4" />
|
|
444
|
-
</IconSvg>
|
|
445
|
-
);
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
/** SVG (.svg) */
|
|
449
|
-
function SvgIcon() {
|
|
450
|
-
return (
|
|
451
|
-
<IconSvg>
|
|
452
|
-
<DocShape color="#ffb13b" />
|
|
453
|
-
<DocLabel label="SVG" color="#ffb13b" fontSize="4" />
|
|
454
|
-
</IconSvg>
|
|
455
|
-
);
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
/** Lock files (package-lock.json, yarn.lock, etc.) */
|
|
459
|
-
function LockIcon() {
|
|
460
|
-
return (
|
|
461
|
-
<IconSvg>
|
|
462
|
-
<DocShape color="#6b7280" />
|
|
463
|
-
<text
|
|
464
|
-
x="8"
|
|
465
|
-
y="11.5"
|
|
466
|
-
textAnchor="middle"
|
|
467
|
-
fill="#6b7280"
|
|
468
|
-
fontSize="6"
|
|
469
|
-
fontWeight="700"
|
|
470
|
-
fontFamily="Arial, sans-serif"
|
|
471
|
-
>
|
|
472
|
-
{"🔒"}
|
|
473
|
-
</text>
|
|
474
|
-
</IconSvg>
|
|
475
|
-
);
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
/** Generic fallback file icon */
|
|
479
|
-
function DefaultFileIcon() {
|
|
480
|
-
return (
|
|
481
|
-
<IconSvg>
|
|
482
|
-
<DocShape color="#8c8c8c" />
|
|
483
|
-
</IconSvg>
|
|
484
|
-
);
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
/**
|
|
488
|
-
* Map of file extensions to their icon components.
|
|
489
|
-
* Falls back to DefaultFileIcon for unrecognized extensions.
|
|
490
|
-
*/
|
|
491
|
-
const ICON_BY_EXT: Record<string, React.FC> = {
|
|
492
|
-
js: JsIcon,
|
|
493
|
-
mjs: JsIcon,
|
|
494
|
-
cjs: JsIcon,
|
|
495
|
-
jsx: JsxIcon,
|
|
496
|
-
ts: TsIcon,
|
|
497
|
-
mts: TsIcon,
|
|
498
|
-
cts: TsIcon,
|
|
499
|
-
tsx: TsxIcon,
|
|
500
|
-
json: JsonIcon,
|
|
501
|
-
html: HtmlIcon,
|
|
502
|
-
htm: HtmlIcon,
|
|
503
|
-
css: CssIcon,
|
|
504
|
-
scss: ScssIcon,
|
|
505
|
-
sass: ScssIcon,
|
|
506
|
-
less: ScssIcon,
|
|
507
|
-
md: MdIcon,
|
|
508
|
-
mdx: MdIcon,
|
|
509
|
-
py: PyIcon,
|
|
510
|
-
rb: RbIcon,
|
|
511
|
-
go: GoIcon,
|
|
512
|
-
rs: RsIcon,
|
|
513
|
-
yml: YmlIcon,
|
|
514
|
-
yaml: YmlIcon,
|
|
515
|
-
env: EnvIcon,
|
|
516
|
-
sh: ShIcon,
|
|
517
|
-
bash: ShIcon,
|
|
518
|
-
zsh: ShIcon,
|
|
519
|
-
sql: SqlIcon,
|
|
520
|
-
svg: SvgIcon,
|
|
521
|
-
lock: LockIcon,
|
|
522
|
-
};
|
|
523
|
-
|
|
524
|
-
/**
|
|
525
|
-
* Special filename overrides — some files are identified by their full name
|
|
526
|
-
* rather than extension (e.g., Dockerfile, .gitignore).
|
|
527
|
-
*/
|
|
528
|
-
const ICON_BY_NAME: Record<string, React.FC> = {
|
|
529
|
-
"package-lock.json": LockIcon,
|
|
530
|
-
"yarn.lock": LockIcon,
|
|
531
|
-
"pnpm-lock.yaml": LockIcon,
|
|
532
|
-
};
|
|
533
|
-
|
|
534
|
-
/** Resolve the correct icon component for a filename */
|
|
535
|
-
function FileIcon({ name }: { name: string }) {
|
|
536
|
-
// Check full filename first (for special cases)
|
|
537
|
-
const lowerName = name.toLowerCase();
|
|
538
|
-
const ByName = ICON_BY_NAME[lowerName];
|
|
539
|
-
if (ByName) return <ByName />;
|
|
540
|
-
|
|
541
|
-
// Then check by extension
|
|
542
|
-
const ext = lowerName.split(".").pop() || "";
|
|
543
|
-
// Handle dotfiles like .env, .gitignore — use the part after the dot
|
|
544
|
-
const dotfileExt = lowerName.startsWith(".") ? lowerName.slice(1) : "";
|
|
545
|
-
const Icon = ICON_BY_EXT[ext] || ICON_BY_EXT[dotfileExt] || DefaultFileIcon;
|
|
546
|
-
return <Icon />;
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
// ---------------------------------------------------------------------------
|
|
550
|
-
// Components
|
|
551
|
-
// ---------------------------------------------------------------------------
|
|
552
|
-
|
|
553
|
-
/**
|
|
554
|
-
* FileTree component — renders a collapsible tree of files and folders.
|
|
555
|
-
* Shows change status indicators (N/M/D) next to modified files.
|
|
556
|
-
*/
|
|
557
|
-
export function FileTree({
|
|
558
|
-
files,
|
|
559
|
-
selectedFile,
|
|
560
|
-
onSelectFile,
|
|
561
|
-
fileChanges,
|
|
562
|
-
}: FileTreeProps) {
|
|
563
|
-
return (
|
|
564
|
-
<div className="h-full overflow-auto bg-sb-sidebar text-sm select-none overscroll-contain">
|
|
565
|
-
<div className="px-3 py-2 text-[11px] font-semibold text-sb-text-muted uppercase tracking-wider border-b border-sb-border">
|
|
566
|
-
Explorer
|
|
567
|
-
</div>
|
|
568
|
-
<div className="py-1">
|
|
569
|
-
{files.map((node) => (
|
|
570
|
-
<TreeNode
|
|
571
|
-
key={node.path}
|
|
572
|
-
node={node}
|
|
573
|
-
depth={0}
|
|
574
|
-
selectedFile={selectedFile}
|
|
575
|
-
onSelectFile={onSelectFile}
|
|
576
|
-
fileChanges={fileChanges}
|
|
577
|
-
/>
|
|
578
|
-
))}
|
|
579
|
-
</div>
|
|
580
|
-
</div>
|
|
581
|
-
);
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
/** Individual tree node (file or folder) */
|
|
585
|
-
function TreeNode({
|
|
586
|
-
node,
|
|
587
|
-
depth,
|
|
588
|
-
selectedFile,
|
|
589
|
-
onSelectFile,
|
|
590
|
-
fileChanges,
|
|
591
|
-
}: {
|
|
592
|
-
node: FileNode;
|
|
593
|
-
depth: number;
|
|
594
|
-
selectedFile: string | null;
|
|
595
|
-
onSelectFile: (path: string) => void;
|
|
596
|
-
fileChanges?: Record<string, FileChangeStatus>;
|
|
597
|
-
}) {
|
|
598
|
-
const [expanded, setExpanded] = useState(depth < 2);
|
|
599
|
-
|
|
600
|
-
const isSelected = node.path === selectedFile;
|
|
601
|
-
const paddingLeft = 8 + depth * 16;
|
|
602
|
-
const changeStatus: FileChangeStatus =
|
|
603
|
-
fileChanges?.[node.path] ?? "unchanged";
|
|
604
|
-
|
|
605
|
-
if (node.type === "directory") {
|
|
606
|
-
// Check if any child has changes (to show a subtle indicator on folders)
|
|
607
|
-
const hasChangedChild =
|
|
608
|
-
fileChanges &&
|
|
609
|
-
node.children?.some((c) => {
|
|
610
|
-
if (fileChanges[c.path]) return true;
|
|
611
|
-
// For directories, check recursively by prefix
|
|
612
|
-
if (c.type === "directory") {
|
|
613
|
-
return Object.keys(fileChanges).some(
|
|
614
|
-
(p) => p.startsWith(c.path + "/") && fileChanges[p] !== "unchanged",
|
|
615
|
-
);
|
|
616
|
-
}
|
|
617
|
-
return false;
|
|
618
|
-
});
|
|
619
|
-
|
|
620
|
-
return (
|
|
621
|
-
<div>
|
|
622
|
-
<button
|
|
623
|
-
className={`w-full flex items-center gap-1 py-[3px] text-left hover:bg-sb-bg-hover transition-colors ${
|
|
624
|
-
isSelected ? "bg-sb-bg-active text-sb-text-active" : "text-sb-text"
|
|
625
|
-
}`}
|
|
626
|
-
style={{ paddingLeft }}
|
|
627
|
-
onClick={() => setExpanded(!expanded)}
|
|
628
|
-
>
|
|
629
|
-
<ChevronIcon expanded={expanded} />
|
|
630
|
-
<FolderIcon open={expanded} />
|
|
631
|
-
<span className="truncate ml-0.5 flex-1">{node.name}</span>
|
|
632
|
-
{hasChangedChild && (
|
|
633
|
-
<span
|
|
634
|
-
className="w-1.5 h-1.5 rounded-full shrink-0 mr-2"
|
|
635
|
-
style={{ backgroundColor: "#fb923c" }}
|
|
636
|
-
/>
|
|
637
|
-
)}
|
|
638
|
-
</button>
|
|
639
|
-
{expanded && node.children && (
|
|
640
|
-
<div>
|
|
641
|
-
{node.children.map((child) => (
|
|
642
|
-
<TreeNode
|
|
643
|
-
key={child.path}
|
|
644
|
-
node={child}
|
|
645
|
-
depth={depth + 1}
|
|
646
|
-
selectedFile={selectedFile}
|
|
647
|
-
onSelectFile={onSelectFile}
|
|
648
|
-
fileChanges={fileChanges}
|
|
649
|
-
/>
|
|
650
|
-
))}
|
|
651
|
-
</div>
|
|
652
|
-
)}
|
|
653
|
-
</div>
|
|
654
|
-
);
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
return (
|
|
658
|
-
<button
|
|
659
|
-
className={`w-full flex items-center gap-1 py-[3px] text-left hover:bg-sb-bg-hover transition-colors ${
|
|
660
|
-
isSelected ? "bg-sb-bg-active text-sb-text-active" : "text-sb-text"
|
|
661
|
-
}`}
|
|
662
|
-
style={{ paddingLeft: paddingLeft + 20 }}
|
|
663
|
-
onClick={() => onSelectFile(node.path)}
|
|
664
|
-
>
|
|
665
|
-
<FileIcon name={node.name} />
|
|
666
|
-
<span className="truncate ml-0.5 flex-1">{node.name}</span>
|
|
667
|
-
{changeStatus !== "unchanged" && (
|
|
668
|
-
<span
|
|
669
|
-
className="text-[9px] font-bold shrink-0 mr-2"
|
|
670
|
-
style={{ color: STATUS_COLORS[changeStatus] }}
|
|
671
|
-
title={changeStatus}
|
|
672
|
-
>
|
|
673
|
-
{STATUS_LETTERS[changeStatus]}
|
|
674
|
-
</span>
|
|
675
|
-
)}
|
|
676
|
-
</button>
|
|
677
|
-
);
|
|
678
|
-
}
|