@run0/jiki-ui 0.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.
@@ -0,0 +1,370 @@
1
+ import { useRef, useEffect, useCallback, useState } from "react";
2
+
3
+ export interface InspectedElement {
4
+ tagName: string;
5
+ className: string;
6
+ textContent: string;
7
+ outerHTML: string;
8
+ }
9
+
10
+ export interface BrowserWindowProps {
11
+ htmlSrc: string;
12
+ url: string;
13
+ canGoBack: boolean;
14
+ canGoForward: boolean;
15
+ onBack: () => void;
16
+ onForward: () => void;
17
+ onRefresh: () => void;
18
+ onNavigate: (path: string) => void;
19
+ port?: number;
20
+ title?: string;
21
+ previewUrl?: string;
22
+ /** When provided, shows an inspect button. Called with element info on pick. */
23
+ onInspectElement?: (element: InspectedElement) => void;
24
+ }
25
+
26
+ export function BrowserWindow({
27
+ htmlSrc,
28
+ url,
29
+ canGoBack,
30
+ canGoForward,
31
+ onBack,
32
+ onForward,
33
+ onRefresh,
34
+ onNavigate,
35
+ port = 3000,
36
+ title = "App Preview",
37
+ previewUrl,
38
+ onInspectElement,
39
+ }: BrowserWindowProps) {
40
+ const iframeRef = useRef<HTMLIFrameElement>(null);
41
+ const [docTitle, setDocTitle] = useState<string | null>(null);
42
+ const [isFocused, setIsFocused] = useState(false);
43
+ const [inspecting, setInspecting] = useState(false);
44
+ const inspectCleanupRef = useRef<(() => void) | null>(null);
45
+
46
+ const displayUrl = previewUrl
47
+ ? `${previewUrl}${url === "/" ? "" : url}`
48
+ : `http://localhost:${port}${url === "/" ? "" : url}`;
49
+
50
+ // Track the last written srcdoc to avoid redundant rewrites that cause flash.
51
+ const lastSrcdocRef = useRef("");
52
+
53
+ useEffect(() => {
54
+ const iframe = iframeRef.current;
55
+ if (!iframe) return;
56
+
57
+ if (previewUrl) {
58
+ iframe.src = `${previewUrl}${url === "/" ? "/" : url}`;
59
+ } else if (htmlSrc !== lastSrcdocRef.current) {
60
+ lastSrcdocRef.current = htmlSrc;
61
+ iframe.srcdoc = htmlSrc;
62
+ }
63
+ }, [htmlSrc, previewUrl]);
64
+
65
+ useEffect(() => {
66
+ const iframe = iframeRef.current;
67
+ if (!iframe || previewUrl) return;
68
+ try {
69
+ iframe.contentWindow?.postMessage({ type: "navigate", path: url }, "*");
70
+ } catch {
71
+ // cross-origin or iframe not ready
72
+ }
73
+ }, [url, previewUrl]);
74
+
75
+ // Listen for <title> changes inside the iframe
76
+ useEffect(() => {
77
+ const iframe = iframeRef.current;
78
+ if (!iframe) return;
79
+
80
+ let observer: MutationObserver | undefined;
81
+
82
+ const observe = () => {
83
+ try {
84
+ const doc = iframe.contentDocument;
85
+ if (!doc) return;
86
+
87
+ const readTitle = () => {
88
+ const t = doc.title;
89
+ if (t) setDocTitle(t);
90
+ };
91
+
92
+ readTitle();
93
+
94
+ const head = doc.head ?? doc.documentElement;
95
+ if (!head) return;
96
+
97
+ observer = new MutationObserver(readTitle);
98
+ observer.observe(head, {
99
+ childList: true,
100
+ subtree: true,
101
+ characterData: true,
102
+ });
103
+ } catch {
104
+ // cross-origin
105
+ }
106
+ };
107
+
108
+ iframe.addEventListener("load", observe);
109
+ return () => {
110
+ iframe.removeEventListener("load", observe);
111
+ observer?.disconnect();
112
+ };
113
+ }, [htmlSrc, previewUrl]);
114
+
115
+ // Clean up inspect mode when htmlSrc changes (page rebuild)
116
+ useEffect(() => {
117
+ if (inspecting) {
118
+ inspectCleanupRef.current?.();
119
+ inspectCleanupRef.current = null;
120
+ // Re-inject after a tick so the iframe has loaded
121
+ const timer = setTimeout(() => {
122
+ if (inspecting) injectInspectMode();
123
+ }, 100);
124
+ return () => clearTimeout(timer);
125
+ }
126
+ }, [htmlSrc]);
127
+
128
+ // Inject inspect mode handlers into the iframe DOM
129
+ const injectInspectMode = useCallback(() => {
130
+ const iframe = iframeRef.current;
131
+ if (!iframe) return;
132
+
133
+ try {
134
+ const doc = iframe.contentDocument;
135
+ if (!doc) return;
136
+
137
+ let currentHighlight: HTMLElement | null = null;
138
+ const OUTLINE = "2px solid #3b82f6";
139
+ const OUTLINE_OFFSET = "-2px";
140
+
141
+ const onMouseOver = (e: Event) => {
142
+ const target = e.target as HTMLElement;
143
+ if (target === doc.body || target === doc.documentElement) return;
144
+ if (currentHighlight) {
145
+ currentHighlight.style.outline = "";
146
+ currentHighlight.style.outlineOffset = "";
147
+ }
148
+ target.style.outline = OUTLINE;
149
+ target.style.outlineOffset = OUTLINE_OFFSET;
150
+ currentHighlight = target;
151
+ };
152
+
153
+ const onMouseOut = (e: Event) => {
154
+ const target = e.target as HTMLElement;
155
+ target.style.outline = "";
156
+ target.style.outlineOffset = "";
157
+ if (currentHighlight === target) currentHighlight = null;
158
+ };
159
+
160
+ const onClick = (e: Event) => {
161
+ e.preventDefault();
162
+ e.stopPropagation();
163
+ const target = e.target as HTMLElement;
164
+ target.style.outline = "";
165
+ target.style.outlineOffset = "";
166
+
167
+ const text = (target.textContent || "").trim().slice(0, 200);
168
+ const html = target.outerHTML.slice(0, 500);
169
+
170
+ onInspectElement?.({
171
+ tagName: target.tagName.toLowerCase(),
172
+ className: target.className || "",
173
+ textContent: text,
174
+ outerHTML: html,
175
+ });
176
+
177
+ cleanup();
178
+ setInspecting(false);
179
+ };
180
+
181
+ doc.addEventListener("mouseover", onMouseOver, true);
182
+ doc.addEventListener("mouseout", onMouseOut, true);
183
+ doc.addEventListener("click", onClick, true);
184
+
185
+ // Set cursor on body
186
+ doc.body.style.cursor = "crosshair";
187
+
188
+ const cleanup = () => {
189
+ doc.removeEventListener("mouseover", onMouseOver, true);
190
+ doc.removeEventListener("mouseout", onMouseOut, true);
191
+ doc.removeEventListener("click", onClick, true);
192
+ doc.body.style.cursor = "";
193
+ if (currentHighlight) {
194
+ currentHighlight.style.outline = "";
195
+ currentHighlight.style.outlineOffset = "";
196
+ currentHighlight = null;
197
+ }
198
+ };
199
+
200
+ inspectCleanupRef.current = cleanup;
201
+ } catch {
202
+ // cross-origin
203
+ }
204
+ }, [onInspectElement]);
205
+
206
+ const toggleInspect = useCallback(() => {
207
+ if (inspecting) {
208
+ inspectCleanupRef.current?.();
209
+ inspectCleanupRef.current = null;
210
+ setInspecting(false);
211
+ } else {
212
+ setInspecting(true);
213
+ // Wait a tick for the iframe to be ready
214
+ requestAnimationFrame(() => injectInspectMode());
215
+ }
216
+ }, [inspecting, injectInspectMode]);
217
+
218
+ const handleAddressKeyDown = useCallback(
219
+ (e: React.KeyboardEvent<HTMLInputElement>) => {
220
+ if (e.key === "Enter") {
221
+ const val = e.currentTarget.value.trim();
222
+ try {
223
+ const parsed = new URL(
224
+ val.startsWith("http") ? val : `http://localhost:${port}${val}`,
225
+ );
226
+ onNavigate(parsed.pathname || "/");
227
+ } catch {
228
+ onNavigate(val.startsWith("/") ? val : `/${val}`);
229
+ }
230
+ }
231
+ },
232
+ [onNavigate, port],
233
+ );
234
+
235
+ const tabLabel = docTitle || title;
236
+
237
+ return (
238
+ <div className="h-full flex flex-col rounded-xl overflow-hidden border border-zinc-800 bg-zinc-950 shadow-[0_0_0_1px_rgba(255,255,255,0.03),0_8px_40px_-12px_rgba(0,0,0,0.6)]">
239
+ {/* Toolbar */}
240
+ <div className="flex-shrink-0 flex items-center gap-2 h-10 px-3 bg-zinc-900/80 border-b border-zinc-800/80 select-none">
241
+ {/* Traffic lights */}
242
+ <div className="flex items-center gap-[6px] mr-1">
243
+ <span className="block h-[10px] w-[10px] rounded-full bg-[#ff5f57] ring-1 ring-black/10" />
244
+ <span className="block h-[10px] w-[10px] rounded-full bg-[#febc2e] ring-1 ring-black/10" />
245
+ <span className="block h-[10px] w-[10px] rounded-full bg-[#28c840] ring-1 ring-black/10" />
246
+ </div>
247
+
248
+ {/* Navigation buttons */}
249
+ <div className="flex items-center">
250
+ <button
251
+ onClick={onBack}
252
+ disabled={!canGoBack}
253
+ className="p-1 rounded-md text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800 disabled:text-zinc-700 disabled:hover:bg-transparent transition-colors"
254
+ title="Back">
255
+ <svg
256
+ className="w-3.5 h-3.5"
257
+ viewBox="0 0 16 16"
258
+ fill="currentColor">
259
+ <path d="M10.354 3.354a.5.5 0 0 0-.708-.708l-5 5a.5.5 0 0 0 0 .708l5 5a.5.5 0 0 0 .708-.708L5.707 8l4.647-4.646z" />
260
+ </svg>
261
+ </button>
262
+ <button
263
+ onClick={onForward}
264
+ disabled={!canGoForward}
265
+ className="p-1 rounded-md text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800 disabled:text-zinc-700 disabled:hover:bg-transparent transition-colors"
266
+ title="Forward">
267
+ <svg
268
+ className="w-3.5 h-3.5"
269
+ viewBox="0 0 16 16"
270
+ fill="currentColor">
271
+ <path d="M5.646 3.354a.5.5 0 0 1 .708-.708l5 5a.5.5 0 0 1 0 .708l-5 5a.5.5 0 0 1-.708-.708L10.293 8 5.646 3.354z" />
272
+ </svg>
273
+ </button>
274
+ <button
275
+ onClick={onRefresh}
276
+ className="p-1 rounded-md text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800 transition-colors"
277
+ title="Refresh">
278
+ <svg
279
+ className="w-3.5 h-3.5"
280
+ viewBox="0 0 16 16"
281
+ fill="none"
282
+ stroke="currentColor"
283
+ strokeWidth={1.5}
284
+ strokeLinecap="round"
285
+ strokeLinejoin="round">
286
+ <path d="M2.5 8a5.5 5.5 0 0 1 9.22-4.05M13.5 8a5.5 5.5 0 0 1-9.22 4.05" />
287
+ <path d="M13.5 2.5v3h-3M2.5 13.5v-3h3" />
288
+ </svg>
289
+ </button>
290
+ {/* Inspect element button */}
291
+ {onInspectElement && (
292
+ <button
293
+ onClick={toggleInspect}
294
+ className={`p-1 rounded-md transition-colors ${
295
+ inspecting
296
+ ? "text-blue-400 bg-blue-500/15"
297
+ : "text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800"
298
+ }`}
299
+ title={inspecting ? "Cancel inspect" : "Inspect element"}>
300
+ <svg
301
+ className="w-3.5 h-3.5"
302
+ viewBox="0 0 24 24"
303
+ fill="none"
304
+ stroke="currentColor"
305
+ strokeWidth={2}
306
+ strokeLinecap="round"
307
+ strokeLinejoin="round">
308
+ <path d="M3 12l3 0" />
309
+ <path d="M12 3l0 3" />
310
+ <path d="M7.8 7.8l-2.2 -2.2" />
311
+ <path d="M16.2 7.8l2.2 -2.2" />
312
+ <path d="M7.8 16.2l-2.2 2.2" />
313
+ <path d="M12 12l9 3l-4 2l-2 4l-3 -9" />
314
+ </svg>
315
+ </button>
316
+ )}
317
+ </div>
318
+
319
+ {/* Address bar */}
320
+ <div
321
+ className={`flex-1 flex items-center gap-2 h-[26px] rounded-md px-2.5 transition-colors ${
322
+ isFocused
323
+ ? "bg-zinc-950 ring-1 ring-zinc-600"
324
+ : "bg-zinc-800/60 hover:bg-zinc-800"
325
+ }`}>
326
+ <svg
327
+ className="w-3 h-3 flex-shrink-0 text-zinc-500"
328
+ viewBox="0 0 16 16"
329
+ fill="currentColor">
330
+ <path
331
+ fillRule="evenodd"
332
+ d="M8 1a4.5 4.5 0 0 0-4.5 4.5V7H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V8a1 1 0 0 0-1-1h-.5V5.5A4.5 4.5 0 0 0 8 1zm2.5 6V5.5a2.5 2.5 0 0 0-5 0V7h5z"
333
+ />
334
+ </svg>
335
+ <input
336
+ type="text"
337
+ defaultValue={displayUrl}
338
+ key={displayUrl}
339
+ onKeyDown={handleAddressKeyDown}
340
+ onFocus={() => setIsFocused(true)}
341
+ onBlur={() => setIsFocused(false)}
342
+ className="flex-1 bg-transparent text-[11px] text-zinc-400 outline-none font-mono leading-none placeholder:text-zinc-600"
343
+ spellCheck={false}
344
+ />
345
+ {tabLabel && (
346
+ <span className="hidden sm:block text-[10px] text-zinc-600 truncate max-w-[120px] leading-none">
347
+ {tabLabel}
348
+ </span>
349
+ )}
350
+ </div>
351
+ </div>
352
+
353
+ {/* Viewport */}
354
+ <div className="flex-1 bg-white relative">
355
+ <iframe
356
+ ref={iframeRef}
357
+ title={docTitle || title}
358
+ sandbox="allow-scripts allow-forms allow-popups allow-same-origin"
359
+ className="absolute inset-0 w-full h-full border-0"
360
+ />
361
+ {/* Inspect mode overlay indicator */}
362
+ {inspecting && (
363
+ <div className="absolute top-2 left-1/2 -translate-x-1/2 z-10 px-3 py-1 rounded-full bg-blue-500/90 text-white text-[10px] font-mono shadow-lg pointer-events-none">
364
+ Click an element to inspect
365
+ </div>
366
+ )}
367
+ </div>
368
+ </div>
369
+ );
370
+ }
@@ -0,0 +1,185 @@
1
+ import { useState, useEffect, useRef, useCallback, useMemo } from "react";
2
+ import type { AccentColor } from "./types";
3
+ import { getEditorTheme } from "./theme";
4
+ import { getLanguageLabel } from "./language-labels";
5
+ import { useShikiHighlighter } from "./use-shiki-highlighter";
6
+
7
+ export interface CodeEditorProps {
8
+ filename: string | null;
9
+ content: string;
10
+ onSave: (path: string, content: string) => void;
11
+ accentColor?: AccentColor;
12
+ }
13
+
14
+ export function CodeEditor({
15
+ filename,
16
+ content,
17
+ onSave,
18
+ accentColor = "emerald",
19
+ }: CodeEditorProps) {
20
+ const [localContent, setLocalContent] = useState(content);
21
+ const [isDirty, setIsDirty] = useState(false);
22
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
23
+ const lineCountRef = useRef<HTMLDivElement>(null);
24
+ const highlightRef = useRef<HTMLDivElement>(null);
25
+ const theme = getEditorTheme(accentColor);
26
+ useShikiHighlighter(localContent, filename, highlightRef);
27
+
28
+ useEffect(() => {
29
+ setLocalContent(content);
30
+ setIsDirty(false);
31
+ }, [content, filename]);
32
+
33
+ const handleChange = useCallback(
34
+ (e: React.ChangeEvent<HTMLTextAreaElement>) => {
35
+ setLocalContent(e.target.value);
36
+ setIsDirty(e.target.value !== content);
37
+ },
38
+ [content],
39
+ );
40
+
41
+ const handleSave = useCallback(() => {
42
+ if (filename && isDirty) {
43
+ onSave(filename, localContent);
44
+ setIsDirty(false);
45
+ }
46
+ }, [filename, isDirty, localContent, onSave]);
47
+
48
+ const handleKeyDown = useCallback(
49
+ (e: React.KeyboardEvent) => {
50
+ if ((e.metaKey || e.ctrlKey) && e.key === "s") {
51
+ e.preventDefault();
52
+ handleSave();
53
+ }
54
+ if (e.key === "Tab") {
55
+ e.preventDefault();
56
+ const ta = textareaRef.current;
57
+ if (!ta) return;
58
+ const start = ta.selectionStart;
59
+ const end = ta.selectionEnd;
60
+ const val = ta.value;
61
+ const newVal = val.substring(0, start) + " " + val.substring(end);
62
+ setLocalContent(newVal);
63
+ setIsDirty(newVal !== content);
64
+ requestAnimationFrame(() => {
65
+ ta.selectionStart = ta.selectionEnd = start + 2;
66
+ });
67
+ }
68
+ },
69
+ [handleSave, content],
70
+ );
71
+
72
+ const handleScroll = useCallback(() => {
73
+ if (textareaRef.current) {
74
+ if (lineCountRef.current) {
75
+ lineCountRef.current.scrollTop = textareaRef.current.scrollTop;
76
+ }
77
+ if (highlightRef.current) {
78
+ highlightRef.current.scrollTop = textareaRef.current.scrollTop;
79
+ highlightRef.current.scrollLeft = textareaRef.current.scrollLeft;
80
+ }
81
+ }
82
+ }, []);
83
+
84
+ if (!filename) {
85
+ return (
86
+ <div className="h-full flex items-center justify-center text-zinc-600 text-sm">
87
+ Select a file to edit
88
+ </div>
89
+ );
90
+ }
91
+
92
+ const lineCount = localContent.split("\n").length;
93
+ const lineNumbers = useMemo(
94
+ () =>
95
+ Array.from({ length: lineCount }, (_, i) => (
96
+ <div
97
+ key={i}
98
+ className="px-3 text-right text-zinc-600 text-[12px] leading-5"
99
+ style={{ minWidth: "3rem" }}>
100
+ {i + 1}
101
+ </div>
102
+ )),
103
+ [lineCount],
104
+ );
105
+
106
+ return (
107
+ <div className="h-full flex flex-col">
108
+ {/* Tab bar */}
109
+ <div className="flex items-center justify-between px-3 py-1.5 bg-zinc-900/50 border-b border-zinc-800">
110
+ <div className="flex items-center gap-2">
111
+ <span className="text-[13px] font-mono text-zinc-300">
112
+ {filename}
113
+ </span>
114
+ {isDirty && (
115
+ <span
116
+ className="inline-block h-2 w-2 rounded-full bg-amber-400"
117
+ title="Unsaved changes"
118
+ />
119
+ )}
120
+ </div>
121
+ <div className="flex items-center gap-3">
122
+ <span className="text-[11px] text-zinc-600">
123
+ {getLanguageLabel(filename)}
124
+ </span>
125
+ <button
126
+ onClick={handleSave}
127
+ disabled={!isDirty}
128
+ className={`
129
+ text-[11px] px-2.5 py-1 rounded font-medium transition-colors
130
+ ${
131
+ isDirty
132
+ ? theme.saveButtonActive
133
+ : "bg-zinc-800 text-zinc-600 border border-zinc-700 cursor-not-allowed"
134
+ }
135
+ `}>
136
+ Save
137
+ </button>
138
+ </div>
139
+ </div>
140
+
141
+ {/* Editor area */}
142
+ <div className="flex-1 flex overflow-hidden relative font-mono text-[13px] leading-5">
143
+ {/* Line numbers */}
144
+ <div
145
+ ref={lineCountRef}
146
+ className="flex-shrink-0 overflow-hidden bg-zinc-900/30 select-none">
147
+ {lineNumbers}
148
+ </div>
149
+
150
+ {/* Code container — overlay of highlight layer + textarea */}
151
+ <div className="flex-1 relative min-w-0">
152
+ {/* Highlight layer (behind textarea, renders Shiki HTML) */}
153
+ <div
154
+ ref={highlightRef}
155
+ className="absolute inset-0 overflow-hidden p-2 pointer-events-none [&_pre]:!bg-transparent [&_pre]:!m-0 [&_pre]:!p-0 [&_pre]:!font-[inherit] [&_pre]:![font-size:inherit] [&_pre]:![line-height:inherit] [&_pre]:![letter-spacing:inherit] [&_pre]:![white-space:pre] [&_pre]:![word-wrap:normal] [&_code]:!font-[inherit] [&_code]:![font-size:inherit] [&_code]:![line-height:inherit] [&_.shiki]:!overflow-visible"
156
+ style={{ tabSize: 2 }}
157
+ />
158
+
159
+ {/* Textarea (transparent text, captures all input) */}
160
+ <textarea
161
+ ref={textareaRef}
162
+ value={localContent}
163
+ onChange={handleChange}
164
+ onKeyDown={handleKeyDown}
165
+ onScroll={handleScroll}
166
+ wrap="off"
167
+ spellCheck={false}
168
+ className={`
169
+ absolute inset-0 w-full h-full resize-none bg-transparent p-2
170
+ text-transparent outline-none overflow-auto whitespace-pre
171
+ ${theme.caret} ${theme.selection}
172
+ `}
173
+ style={{ tabSize: 2, overflowWrap: "normal", wordBreak: "normal" }}
174
+ />
175
+ </div>
176
+ </div>
177
+
178
+ {/* Status bar */}
179
+ <div className="flex items-center justify-between px-3 py-1 bg-zinc-900/30 border-t border-zinc-800 text-[11px] text-zinc-600">
180
+ <span>{lineCount} lines</span>
181
+ <span>Ctrl+S / Cmd+S to save</span>
182
+ </div>
183
+ </div>
184
+ );
185
+ }
@@ -0,0 +1,157 @@
1
+ import { useState } from "react";
2
+ import type { AccentColor, FileEntry } from "./types";
3
+ import { getFileExplorerTheme } from "./theme";
4
+
5
+ export interface FileExplorerProps {
6
+ files: FileEntry[];
7
+ selectedFile: string | null;
8
+ onSelect: (path: string) => void;
9
+ accentColor?: AccentColor;
10
+ variant?: "default" | "compact";
11
+ }
12
+
13
+ const FILE_COLOR_MAP: Record<string, string> = {
14
+ js: "text-yellow-400",
15
+ mjs: "text-yellow-400",
16
+ cjs: "text-yellow-400",
17
+ jsx: "text-cyan-400",
18
+ ts: "text-blue-400",
19
+ tsx: "text-cyan-400",
20
+ json: "text-amber-300",
21
+ html: "text-orange-400",
22
+ css: "text-pink-400",
23
+ md: "text-blue-400",
24
+ vue: "text-emerald-400",
25
+ svelte: "text-orange-400",
26
+ astro: "text-orange-400",
27
+ yaml: "text-blue-400",
28
+ yml: "text-blue-400",
29
+ toml: "text-blue-400",
30
+ sh: "text-green-400",
31
+ svg: "text-emerald-400",
32
+ };
33
+
34
+ function FileIcon({
35
+ isDir,
36
+ name,
37
+ compact,
38
+ }: {
39
+ isDir: boolean;
40
+ name: string;
41
+ compact: boolean;
42
+ }) {
43
+ const iconSize = compact ? "text-[11px] w-3.5" : "text-xs w-4";
44
+ if (isDir) {
45
+ return (
46
+ <span
47
+ className={`text-amber-400 ${iconSize} inline-block text-center mr-1.5`}>
48
+ &#x1F4C1;
49
+ </span>
50
+ );
51
+ }
52
+ const ext = name.split(".").pop()?.toLowerCase();
53
+ const color = FILE_COLOR_MAP[ext || ""] || "text-zinc-400";
54
+ return (
55
+ <span className={`${color} ${iconSize} inline-block text-center mr-1.5`}>
56
+ &#x1F4C4;
57
+ </span>
58
+ );
59
+ }
60
+
61
+ function TreeNode({
62
+ entry,
63
+ depth,
64
+ selectedFile,
65
+ onSelect,
66
+ theme,
67
+ compact,
68
+ }: {
69
+ entry: FileEntry;
70
+ depth: number;
71
+ selectedFile: string | null;
72
+ onSelect: (path: string) => void;
73
+ theme: { selected: string };
74
+ compact: boolean;
75
+ }) {
76
+ const [expanded, setExpanded] = useState(true);
77
+
78
+ const isSelected = entry.path === selectedFile;
79
+ const py = compact ? "py-[3px]" : "py-0.5";
80
+ const px = compact ? "px-1.5" : "px-2";
81
+ const fontSize = compact ? "text-[12px]" : "text-[13px]";
82
+ const arrowSize = compact ? "text-[9px] w-2.5" : "text-[10px] w-3";
83
+ const indent = compact ? depth * 10 + 6 : depth * 12 + 8;
84
+
85
+ return (
86
+ <div>
87
+ <button
88
+ onClick={() =>
89
+ entry.isDir ? setExpanded(!expanded) : onSelect(entry.path)
90
+ }
91
+ className={`
92
+ w-full text-left flex items-center ${py} ${px} rounded ${fontSize} font-mono
93
+ transition-colors duration-75
94
+ ${isSelected ? theme.selected : "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200"}
95
+ `}
96
+ style={{ paddingLeft: `${indent}px` }}>
97
+ {entry.isDir && (
98
+ <span className={`${arrowSize} mr-1 text-zinc-500 inline-block`}>
99
+ {expanded ? "\u25BE" : "\u25B8"}
100
+ </span>
101
+ )}
102
+ {!entry.isDir && <span className={`${arrowSize} inline-block`} />}
103
+ <FileIcon isDir={entry.isDir} name={entry.name} compact={compact} />
104
+ <span className="truncate">{entry.name}</span>
105
+ </button>
106
+ {entry.isDir &&
107
+ expanded &&
108
+ entry.children?.map(child => (
109
+ <TreeNode
110
+ key={child.path}
111
+ entry={child}
112
+ depth={depth + 1}
113
+ selectedFile={selectedFile}
114
+ onSelect={onSelect}
115
+ theme={theme}
116
+ compact={compact}
117
+ />
118
+ ))}
119
+ </div>
120
+ );
121
+ }
122
+
123
+ export function FileExplorer({
124
+ files,
125
+ selectedFile,
126
+ onSelect,
127
+ accentColor = "emerald",
128
+ variant = "default",
129
+ }: FileExplorerProps) {
130
+ const theme = getFileExplorerTheme(accentColor);
131
+ const compact = variant === "compact";
132
+
133
+ if (files.length === 0) {
134
+ return (
135
+ <div
136
+ className={`p-2 ${compact ? "text-[11px]" : "text-xs"} text-zinc-600 italic`}>
137
+ No files
138
+ </div>
139
+ );
140
+ }
141
+
142
+ return (
143
+ <div className={compact ? "py-0.5" : "py-1"}>
144
+ {files.map(entry => (
145
+ <TreeNode
146
+ key={entry.path}
147
+ entry={entry}
148
+ depth={0}
149
+ selectedFile={selectedFile}
150
+ onSelect={onSelect}
151
+ theme={theme}
152
+ compact={compact}
153
+ />
154
+ ))}
155
+ </div>
156
+ );
157
+ }