@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.
- package/dist/AIChatPanel.d.ts +26 -0
- package/dist/BrowserWindow.d.ts +22 -0
- package/dist/CodeEditor.d.ts +8 -0
- package/dist/FileExplorer.d.ts +9 -0
- package/dist/MobileTabBar.d.ts +28 -0
- package/dist/PanelToggle.d.ts +9 -0
- package/dist/Terminal.d.ts +11 -0
- package/dist/chat-theme.d.ts +11 -0
- package/dist/index.cjs +1742 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.mjs +1720 -0
- package/dist/index.mjs.map +1 -0
- package/dist/language-labels.d.ts +1 -0
- package/dist/shiki-languages.d.ts +1 -0
- package/dist/theme.d.ts +19 -0
- package/dist/types.d.ts +12 -0
- package/dist/use-shiki-highlighter.d.ts +7 -0
- package/dist/useMediaQuery.d.ts +1 -0
- package/package.json +52 -0
- package/src/AIChatPanel.tsx +577 -0
- package/src/BrowserWindow.tsx +370 -0
- package/src/CodeEditor.tsx +185 -0
- package/src/FileExplorer.tsx +157 -0
- package/src/MobileTabBar.tsx +133 -0
- package/src/PanelToggle.tsx +101 -0
- package/src/Terminal.tsx +178 -0
- package/src/chat-theme.ts +81 -0
- package/src/index.ts +33 -0
- package/src/language-labels.ts +26 -0
- package/src/shiki-languages.ts +27 -0
- package/src/theme.ts +144 -0
- package/src/types.ts +21 -0
- package/src/use-shiki-highlighter.ts +113 -0
- package/src/useMediaQuery.ts +17 -0
|
@@ -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
|
+
📁
|
|
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
|
+
📄
|
|
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
|
+
}
|