@moloodev/cli 0.0.0-stage → 0.3.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,688 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
2
+ import {
3
+ IconFile,
4
+ IconFileImport,
5
+ IconFolderOpen,
6
+ IconMoon,
7
+ IconPlus,
8
+ IconRefresh,
9
+ IconSearch,
10
+ IconSun,
11
+ IconTrash,
12
+ IconX,
13
+ } from "@tabler/icons-react";
14
+ import { KindIcon } from "@moloodev/ui/kind-icon";
15
+ import { EditorHostChrome, type HostChromeHandlers } from "@moloodev/editor-sdk/host";
16
+ import { EditorLoading } from "@moloodev/editor-sdk/layout";
17
+ import { editorFrameAttributes, type EditorAsset, type EditorSession } from "@moloodev/editor-sdk/protocol";
18
+ import {
19
+ Badge,
20
+ Button,
21
+ Dialog,
22
+ DialogContent,
23
+ DialogDescription,
24
+ DialogHeader,
25
+ DialogTitle,
26
+ Input,
27
+ Kbd,
28
+ NativeSelect,
29
+ NativeSelectOption,
30
+ Popover,
31
+ PopoverContent,
32
+ PopoverTrigger,
33
+ Switch,
34
+ Tabs,
35
+ TabsContent,
36
+ TabsList,
37
+ TabsTrigger,
38
+ Tooltip,
39
+ TooltipContent,
40
+ TooltipProvider,
41
+ TooltipTrigger,
42
+ cn,
43
+ } from "@moloodev/ui";
44
+ import {
45
+ INSPECTOR_STORAGE_KEY,
46
+ simulatorIsDefault,
47
+ type DevHostController,
48
+ type DevHostSnapshot,
49
+ } from "./dev-host-client";
50
+
51
+ const MODES: readonly EditorSession["mode"][] = ["edit", "read", "loading", "idle"];
52
+ const SYNC: readonly EditorSession["syncStatus"][] = ["live", "pending", "offline", "error"];
53
+
54
+ function useSnapshot(controller: DevHostController): DevHostSnapshot {
55
+ return useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
56
+ }
57
+
58
+ function HeaderIconButton({
59
+ label,
60
+ onClick,
61
+ children,
62
+ }: {
63
+ label: string;
64
+ onClick: () => void;
65
+ children: React.ReactNode;
66
+ }) {
67
+ return (
68
+ <Tooltip>
69
+ <TooltipTrigger
70
+ render={
71
+ <Button
72
+ variant="ghost"
73
+ size="icon-sm"
74
+ aria-label={label}
75
+ onClick={onClick}
76
+ />
77
+ }
78
+ >
79
+ {children}
80
+ </TooltipTrigger>
81
+ <TooltipContent side="bottom">
82
+ {label}
83
+ </TooltipContent>
84
+ </Tooltip>
85
+ );
86
+ }
87
+
88
+ function AssetChip({
89
+ snapshot,
90
+ controller,
91
+ onImport,
92
+ }: {
93
+ snapshot: DevHostSnapshot;
94
+ controller: DevHostController;
95
+ onImport: () => void;
96
+ }) {
97
+ const asset = snapshot.activeAsset;
98
+ const [open, setOpen] = useState(false);
99
+ const [draft, setDraft] = useState(asset.name);
100
+ useEffect(() => {
101
+ if (open) setDraft(asset.name);
102
+ }, [open, asset.name]);
103
+ const commit = () => controller.renameAsset(draft);
104
+ const rows: [string, string][] = [
105
+ ["type", asset.type],
106
+ ["storageMode", asset.storageMode ?? "—"],
107
+ ["contentType", asset.contentType ?? "—"],
108
+ ["schema", asset.schema ?? "—"],
109
+ ["id", asset.id],
110
+ ];
111
+ return (
112
+ <Popover open={open} onOpenChange={setOpen}>
113
+ <PopoverTrigger
114
+ render={
115
+ <Button variant="ghost" size="sm" className="max-w-[22rem] gap-1.5" aria-label="현재 에셋" />
116
+ }
117
+ >
118
+ <IconFile className="text-muted-foreground" />
119
+ <span className="truncate">{asset.name}</span>
120
+ <Badge variant="outline" className="h-4 px-1 font-mono text-[10px]">
121
+ {asset.storageMode}
122
+ </Badge>
123
+ </PopoverTrigger>
124
+ <PopoverContent align="center" className="w-80">
125
+ <form
126
+ className="flex flex-col gap-1"
127
+ onSubmit={(event) => {
128
+ event.preventDefault();
129
+ commit();
130
+ setOpen(false);
131
+ }}
132
+ >
133
+ <label className="text-xs font-semibold text-muted-foreground" htmlFor="dev-asset-name">
134
+ 이름
135
+ </label>
136
+ <Input
137
+ id="dev-asset-name"
138
+ value={draft}
139
+ onChange={(event) => setDraft(event.target.value)}
140
+ onBlur={commit}
141
+ />
142
+ </form>
143
+ <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
144
+ {rows.map(([key, value]) => (
145
+ <div key={key} className="contents">
146
+ <dt className="text-muted-foreground">{key}</dt>
147
+ <dd className="truncate font-mono" title={value}>
148
+ {value}
149
+ </dd>
150
+ </div>
151
+ ))}
152
+ </dl>
153
+ <div className="flex flex-col gap-0.5 border-t border-border pt-2">
154
+ <Button
155
+ variant="ghost"
156
+ size="sm"
157
+ className="justify-start"
158
+ onClick={() => {
159
+ setOpen(false);
160
+ controller.openDialog({ kind: "start" });
161
+ }}
162
+ >
163
+ <IconPlus /> 템플릿으로 새로 만들기...
164
+ </Button>
165
+ <Button
166
+ variant="ghost"
167
+ size="sm"
168
+ className="justify-start"
169
+ onClick={() => {
170
+ setOpen(false);
171
+ onImport();
172
+ }}
173
+ >
174
+ <IconFileImport /> 가져오기...
175
+ </Button>
176
+ <Button
177
+ variant="ghost"
178
+ size="sm"
179
+ className="justify-start"
180
+ onClick={() => {
181
+ setOpen(false);
182
+ controller.openDialog({ kind: "browser" });
183
+ }}
184
+ >
185
+ <IconFolderOpen /> 에셋 브라우저
186
+ </Button>
187
+ </div>
188
+ </PopoverContent>
189
+ </Popover>
190
+ );
191
+ }
192
+
193
+ function Rail({ snapshot }: { snapshot: DevHostSnapshot }) {
194
+ const iconName = snapshot.manifest.icon ?? snapshot.manifest.fileTypes?.[0]?.icon;
195
+ return (
196
+ <aside
197
+ data-slot="dev-rail"
198
+ className="flex w-12 shrink-0 flex-col items-center border-r border-border bg-sidebar py-2"
199
+ >
200
+ <Tooltip>
201
+ <TooltipTrigger
202
+ render={
203
+ <span
204
+ aria-current="page"
205
+ className="flex size-8 rotate-[-1deg] items-center justify-center rounded-md bg-accent text-accent-foreground shadow-moloo-sm"
206
+ />
207
+ }
208
+ >
209
+ {iconName ? <KindIcon icon={iconName} className="size-4" /> : <IconFile className="size-4" />}
210
+ </TooltipTrigger>
211
+ <TooltipContent side="right">{snapshot.manifest.title}</TooltipContent>
212
+ </Tooltip>
213
+ </aside>
214
+ );
215
+ }
216
+
217
+ function AssetRow({ asset, onSelect, active }: { asset: EditorAsset; onSelect?: () => void; active?: boolean }) {
218
+ return (
219
+ <button
220
+ type="button"
221
+ disabled={!onSelect}
222
+ onClick={onSelect}
223
+ className={cn(
224
+ "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-muted disabled:opacity-60",
225
+ active && "bg-muted",
226
+ )}
227
+ >
228
+ <IconFile className="size-4 shrink-0 text-muted-foreground" />
229
+ <span className="min-w-0 flex-1 truncate">{asset.name}</span>
230
+ <span className="truncate font-mono text-[10px] text-muted-foreground">{asset.type}</span>
231
+ </button>
232
+ );
233
+ }
234
+
235
+ function Dialogs({ snapshot, controller }: { snapshot: DevHostSnapshot; controller: DevHostController }) {
236
+ const dialog = snapshot.dialog;
237
+ const onOpenChange = (open: boolean) => {
238
+ if (!open) controller.closeDialog();
239
+ };
240
+ return (
241
+ <>
242
+ <Dialog open={dialog?.kind === "start"} onOpenChange={onOpenChange}>
243
+ <DialogContent className="sm:max-w-lg">
244
+ <DialogHeader>
245
+ <DialogTitle>새로 만들기</DialogTitle>
246
+ <DialogDescription>템플릿을 골라 현재 문서를 새로 시작해요.</DialogDescription>
247
+ </DialogHeader>
248
+ <div className="grid grid-cols-2 gap-2">
249
+ {snapshot.templates.map((template) => (
250
+ <button
251
+ key={template.id}
252
+ type="button"
253
+ onClick={() => controller.newFromTemplate(template.id)}
254
+ className="flex flex-col gap-1 rounded-md border-2 border-border bg-card p-3 text-left hover:border-primary"
255
+ >
256
+ <span className="font-display text-sm font-bold">{template.label}</span>
257
+ {template.description ? (
258
+ <span className="text-xs text-muted-foreground">{template.description}</span>
259
+ ) : null}
260
+ <span className="font-mono text-[10px] text-muted-foreground">{template.defaultName}</span>
261
+ </button>
262
+ ))}
263
+ </div>
264
+ </DialogContent>
265
+ </Dialog>
266
+ <Dialog open={dialog?.kind === "browser" || dialog?.kind === "pick"} onOpenChange={onOpenChange}>
267
+ <DialogContent className="sm:max-w-md">
268
+ <DialogHeader>
269
+ <DialogTitle>{dialog?.kind === "pick" ? "에셋 선택" : "에셋 브라우저"}</DialogTitle>
270
+ <DialogDescription>로컬 샌드박스 에셋이에요.</DialogDescription>
271
+ </DialogHeader>
272
+ <div className="flex flex-col gap-0.5">
273
+ {dialog?.kind === "pick" ? (
274
+ dialog.candidates.length === 0 ? (
275
+ <p className="px-2 py-4 text-center text-xs text-muted-foreground">선택할 수 있는 에셋이 없어요.</p>
276
+ ) : (
277
+ dialog.candidates.map((asset) => (
278
+ <AssetRow key={asset.id} asset={asset} onSelect={() => controller.resolvePick(asset.id)} />
279
+ ))
280
+ )
281
+ ) : (
282
+ <>
283
+ <AssetRow asset={snapshot.activeAsset} active />
284
+ {snapshot.companions.map((asset) => (
285
+ <AssetRow key={asset.id} asset={asset} onSelect={() => controller.openCompanion(asset.id)} />
286
+ ))}
287
+ </>
288
+ )}
289
+ </div>
290
+ </DialogContent>
291
+ </Dialog>
292
+ </>
293
+ );
294
+ }
295
+
296
+ function SearchDialog({ snapshot, controller }: { snapshot: DevHostSnapshot; controller: DevHostController }) {
297
+ const [query, setQuery] = useState("");
298
+ const open = snapshot.dialog?.kind === "search";
299
+ useEffect(() => {
300
+ if (open) setQuery("");
301
+ }, [open]);
302
+ const commands = snapshot.chrome?.contributes?.commands ?? [];
303
+ const matches = commands.filter((command) => {
304
+ const state = snapshot.commandState[command.id];
305
+ if (state && !state.visible) return false;
306
+ const q = query.trim().toLowerCase();
307
+ return !q || command.title.toLowerCase().includes(q) || command.id.toLowerCase().includes(q);
308
+ });
309
+ return (
310
+ <Dialog open={open} onOpenChange={(next) => !next && controller.closeDialog()}>
311
+ <DialogContent className="top-24 translate-y-0 gap-2 p-2 sm:max-w-md" showCloseButton={false}>
312
+ <DialogTitle className="sr-only">명령 검색</DialogTitle>
313
+ <Input
314
+ autoFocus
315
+ placeholder="명령 검색..."
316
+ value={query}
317
+ onChange={(event) => setQuery(event.target.value)}
318
+ onKeyDown={(event) => {
319
+ const first = matches.find((command) => !snapshot.commandState[command.id]?.disabled);
320
+ if (event.key === "Enter" && first) {
321
+ controller.closeDialog();
322
+ controller.invokeCommand(first.id);
323
+ }
324
+ }}
325
+ />
326
+ <div className="flex max-h-72 flex-col overflow-auto">
327
+ {matches.length === 0 ? (
328
+ <p className="px-2 py-3 text-center text-xs text-muted-foreground">명령이 없어요.</p>
329
+ ) : (
330
+ matches.map((command) => (
331
+ <button
332
+ key={command.id}
333
+ type="button"
334
+ disabled={snapshot.commandState[command.id]?.disabled}
335
+ onClick={() => {
336
+ controller.closeDialog();
337
+ controller.invokeCommand(command.id);
338
+ }}
339
+ className="flex items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-muted disabled:opacity-50"
340
+ >
341
+ <span className="truncate">{snapshot.commandState[command.id]?.label || command.title}</span>
342
+ <span className="font-mono text-[10px] text-muted-foreground">{command.id}</span>
343
+ </button>
344
+ ))
345
+ )}
346
+ </div>
347
+ </DialogContent>
348
+ </Dialog>
349
+ );
350
+ }
351
+
352
+ function JsonBlock({ value }: { value: unknown }) {
353
+ return (
354
+ <pre className="m-0 min-h-0 flex-1 overflow-auto p-3 font-mono text-[11px] leading-relaxed text-foreground">
355
+ {JSON.stringify(value, null, 2) ?? "undefined"}
356
+ </pre>
357
+ );
358
+ }
359
+
360
+ const INSPECTOR_MIN_WIDTH = 240;
361
+ const INSPECTOR_MAX_WIDTH = 720;
362
+
363
+ type InspectorLayout = { open: boolean; width: number };
364
+
365
+ function clampWidth(width: number): number {
366
+ return Math.min(INSPECTOR_MAX_WIDTH, Math.max(INSPECTOR_MIN_WIDTH, Math.round(width)));
367
+ }
368
+
369
+ function readInspectorLayout(): InspectorLayout {
370
+ try {
371
+ const saved = JSON.parse(window.localStorage.getItem(INSPECTOR_STORAGE_KEY) ?? "null") as Partial<InspectorLayout> | null;
372
+ return { open: saved?.open === true, width: clampWidth(saved?.width ?? 360) };
373
+ } catch {
374
+ return { open: false, width: 360 };
375
+ }
376
+ }
377
+
378
+ function useInspectorLayout() {
379
+ const [layout, setLayout] = useState(readInspectorLayout);
380
+ useEffect(() => {
381
+ window.localStorage.setItem(INSPECTOR_STORAGE_KEY, JSON.stringify(layout));
382
+ }, [layout]);
383
+ return [layout, setLayout] as const;
384
+ }
385
+
386
+ function SimulatorRow({ label, uiOnly, children }: { label: string; uiOnly?: boolean; children: React.ReactNode }) {
387
+ return (
388
+ <label className="flex items-center justify-between gap-2 text-xs">
389
+ <span className="flex items-center gap-1.5 font-mono text-muted-foreground">
390
+ {label}
391
+ {uiOnly ? (
392
+ <Badge variant="outline" className="h-4 px-1 font-sans text-[10px]">
393
+ UI만 바뀜
394
+ </Badge>
395
+ ) : null}
396
+ </span>
397
+ {children}
398
+ </label>
399
+ );
400
+ }
401
+
402
+ function SimulatorTab({ snapshot, controller }: { snapshot: DevHostSnapshot; controller: DevHostController }) {
403
+ return (
404
+ <div className="flex flex-col gap-3 p-3">
405
+ <SimulatorRow label="mode">
406
+ <NativeSelect
407
+ size="sm"
408
+ value={snapshot.mode}
409
+ onChange={(event) => controller.setMode(event.target.value as EditorSession["mode"])}
410
+ >
411
+ {MODES.map((m) => (
412
+ <NativeSelectOption key={m} value={m}>
413
+ {m}
414
+ </NativeSelectOption>
415
+ ))}
416
+ </NativeSelect>
417
+ </SimulatorRow>
418
+ <SimulatorRow label="syncStatus">
419
+ <NativeSelect
420
+ size="sm"
421
+ value={snapshot.syncStatus}
422
+ onChange={(event) => controller.setSyncStatus(event.target.value as EditorSession["syncStatus"])}
423
+ >
424
+ {SYNC.map((s) => (
425
+ <NativeSelectOption key={s} value={s}>
426
+ {s}
427
+ </NativeSelectOption>
428
+ ))}
429
+ </NativeSelect>
430
+ </SimulatorRow>
431
+ <SimulatorRow label="canManage" uiOnly>
432
+ <Switch checked={snapshot.canManage} onCheckedChange={(checked) => controller.setCanManage(checked)} />
433
+ </SimulatorRow>
434
+ <p className="m-0 text-[11px] text-muted-foreground">
435
+ 값은 뷰에 보내는 세션만 바꿔요. 서버 권한이나 동기화는 바뀌지 않아요.
436
+ </p>
437
+ <div className="flex flex-col gap-0.5 border-t border-border pt-2">
438
+ <Button variant="ghost" size="sm" className="justify-start" onClick={() => controller.restartView()}>
439
+ <IconRefresh /> 뷰 재시작
440
+ </Button>
441
+ <Button variant="ghost" size="sm" className="justify-start" onClick={() => controller.resetStorage()}>
442
+ <IconTrash /> localStorage 초기화
443
+ </Button>
444
+ </div>
445
+ </div>
446
+ );
447
+ }
448
+
449
+ function Inspector({
450
+ snapshot,
451
+ controller,
452
+ layout,
453
+ setLayout,
454
+ }: {
455
+ snapshot: DevHostSnapshot;
456
+ controller: DevHostController;
457
+ layout: InspectorLayout;
458
+ setLayout: React.Dispatch<React.SetStateAction<InspectorLayout>>;
459
+ }) {
460
+ const drag = useRef<{ x: number; width: number; moved: boolean } | null>(null);
461
+ const changed = !simulatorIsDefault(snapshot);
462
+ return (
463
+ <div data-slot="dev-inspector" className="flex shrink-0">
464
+ <button
465
+ type="button"
466
+ data-slot="dev-inspector-handle"
467
+ aria-label="인스펙터"
468
+ aria-expanded={layout.open}
469
+ aria-controls="dev-inspector-panel"
470
+ title="인스펙터 (클릭: 열기/닫기, 드래그: 폭 조절)"
471
+ className="moloo-focus relative flex w-3 shrink-0 cursor-col-resize touch-none items-center justify-center border-l border-border bg-card hover:bg-muted"
472
+ onPointerDown={(event) => {
473
+ event.currentTarget.setPointerCapture(event.pointerId);
474
+ drag.current = { x: event.clientX, width: layout.width, moved: false };
475
+ }}
476
+ onPointerMove={(event) => {
477
+ const start = drag.current;
478
+ if (!start) return;
479
+ const delta = start.x - event.clientX;
480
+ if (!start.moved && Math.abs(delta) < 4) return;
481
+ start.moved = true;
482
+ setLayout({ open: true, width: clampWidth(start.width + delta) });
483
+ }}
484
+ onPointerUp={() => {
485
+ const start = drag.current;
486
+ drag.current = null;
487
+ if (start && !start.moved) setLayout((prev) => ({ ...prev, open: !prev.open }));
488
+ }}
489
+ onPointerCancel={() => {
490
+ drag.current = null;
491
+ }}
492
+ onKeyDown={(event) => {
493
+ if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
494
+ event.preventDefault();
495
+ const step = event.key === "ArrowLeft" ? 24 : -24;
496
+ setLayout((prev) => ({ open: true, width: clampWidth(prev.width + step) }));
497
+ }
498
+ }}
499
+ onClick={(event) => {
500
+ // Pointer clicks toggle in onPointerUp; detail 0 means keyboard activation.
501
+ if (event.detail === 0) setLayout((prev) => ({ ...prev, open: !prev.open }));
502
+ }}
503
+ >
504
+ <span className="h-8 w-1 rounded-full bg-border" />
505
+ {changed ? (
506
+ <span
507
+ data-slot="simulator-dot"
508
+ className="absolute top-2 left-1/2 size-2 -translate-x-1/2 rounded-full bg-primary"
509
+ />
510
+ ) : null}
511
+ </button>
512
+ <aside
513
+ id="dev-inspector-panel"
514
+ aria-label="인스펙터"
515
+ hidden={!layout.open}
516
+ style={{ width: layout.width }}
517
+ className="flex flex-col bg-card"
518
+ >
519
+ <Tabs defaultValue="doc" className="flex min-h-0 flex-1 flex-col gap-0">
520
+ <div className="flex h-10 shrink-0 items-center border-b border-border px-2">
521
+ <TabsList variant="line">
522
+ <TabsTrigger value="doc">Y.Doc</TabsTrigger>
523
+ <TabsTrigger value="log">로그</TabsTrigger>
524
+ <TabsTrigger value="manifest">Manifest</TabsTrigger>
525
+ <TabsTrigger value="simulator">시뮬레이터</TabsTrigger>
526
+ </TabsList>
527
+ </div>
528
+ <TabsContent value="doc" className="flex min-h-0 flex-1 flex-col">
529
+ <JsonBlock value={snapshot.docJson} />
530
+ </TabsContent>
531
+ <TabsContent value="log" className="flex min-h-0 flex-1 flex-col">
532
+ <pre className="m-0 min-h-0 flex-1 overflow-auto p-3 font-mono text-[11px] leading-relaxed text-muted-foreground">
533
+ {snapshot.logs.join("\n") || "(아직 이벤트 없음)"}
534
+ </pre>
535
+ </TabsContent>
536
+ <TabsContent value="manifest" className="flex min-h-0 flex-1 flex-col">
537
+ <JsonBlock value={{ manifest: snapshot.manifest, chrome: snapshot.chrome }} />
538
+ </TabsContent>
539
+ <TabsContent value="simulator" className="min-h-0 flex-1 overflow-auto">
540
+ <SimulatorTab snapshot={snapshot} controller={controller} />
541
+ </TabsContent>
542
+ </Tabs>
543
+ </aside>
544
+ </div>
545
+ );
546
+ }
547
+
548
+ function ViewFrame({ snapshot, controller }: { snapshot: DevHostSnapshot; controller: DevHostController }) {
549
+ const attach = useCallback((frame: HTMLIFrameElement | null) => controller.attachFrame(frame), [controller]);
550
+ return (
551
+ <div className="relative flex min-h-0 min-w-0 flex-1">
552
+ <iframe
553
+ key={snapshot.frameKey}
554
+ ref={attach}
555
+ src={controller.frameUrl()}
556
+ sandbox={editorFrameAttributes.sandbox}
557
+ referrerPolicy={editorFrameAttributes.referrerPolicy}
558
+ title={snapshot.manifest.id}
559
+ className="size-full flex-1 border-0"
560
+ />
561
+ {snapshot.chrome ? null : (
562
+ <div className="absolute inset-0 flex">
563
+ <EditorLoading />
564
+ </div>
565
+ )}
566
+ {snapshot.buildError ? (
567
+ <div role="alert" className="absolute inset-0 z-10 flex items-start justify-center bg-background/80 p-6">
568
+ <div className="flex max-h-full w-full max-w-3xl flex-col gap-2 rounded-md border-2 border-destructive bg-card p-3 shadow-moloo-lg">
569
+ <div className="flex items-center justify-between">
570
+ <span className="font-display text-sm font-bold text-destructive">빌드 에러</span>
571
+ <Button variant="ghost" size="icon-xs" aria-label="닫기" onClick={() => controller.dismissBuildError()}>
572
+ <IconX />
573
+ </Button>
574
+ </div>
575
+ <pre className="m-0 overflow-auto whitespace-pre-wrap font-mono text-xs">{snapshot.buildError}</pre>
576
+ </div>
577
+ </div>
578
+ ) : null}
579
+ </div>
580
+ );
581
+ }
582
+
583
+ export function DevHostApp({ controller }: { controller: DevHostController }) {
584
+ const snapshot = useSnapshot(controller);
585
+ const fileInput = useRef<HTMLInputElement>(null);
586
+ const openImport = useCallback(() => fileInput.current?.click(), []);
587
+ const [inspector, setInspector] = useInspectorLayout();
588
+
589
+ const handlers = useMemo<HostChromeHandlers>(
590
+ () => ({
591
+ openStartDialog: () => controller.openDialog({ kind: "start" }),
592
+ openBrowser: () => controller.openDialog({ kind: "browser" }),
593
+ openImport,
594
+ resetLayout: () => controller.resetLayout(),
595
+ }),
596
+ [controller, openImport],
597
+ );
598
+ const onViewCommand = useCallback((commandId: string) => controller.invokeCommand(commandId), [controller]);
599
+
600
+ useEffect(() => {
601
+ function onKey(event: KeyboardEvent) {
602
+ if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return;
603
+ const key = event.key.toLowerCase();
604
+ if (key === "k") {
605
+ event.preventDefault();
606
+ controller.openDialog({ kind: "search" });
607
+ } else if (key === "i") {
608
+ event.preventDefault();
609
+ setInspector((prev) => ({ ...prev, open: !prev.open }));
610
+ }
611
+ }
612
+ window.addEventListener("keydown", onKey);
613
+ return () => window.removeEventListener("keydown", onKey);
614
+ }, [controller, setInspector]);
615
+
616
+ const dark = snapshot.theme === "dark";
617
+ return (
618
+ <TooltipProvider>
619
+ <div data-slot="dev-host" className="flex h-svh flex-col overflow-hidden bg-background pt-12 font-sans text-foreground">
620
+ <header className="fixed inset-x-0 top-0 z-40 grid h-12 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3 border-b-2 border-border bg-card px-3">
621
+ <div className="flex min-w-0 items-center gap-1.5">
622
+ <span className="shrink-0 rotate-[-2deg] bg-accent px-[0.45rem] py-[0.18rem] font-display text-[0.8rem] font-extrabold tracking-[-0.03em] text-accent-foreground shadow-moloo-sm">
623
+ moloo
624
+ </span>
625
+ <span className="ml-1 truncate font-display text-[0.8rem] font-bold tracking-[-0.02em]">{snapshot.manifest.title}</span>
626
+ <Badge variant="secondary" className="h-4 px-1 text-[10px]">
627
+ DEV
628
+ </Badge>
629
+ </div>
630
+ <div className="flex min-w-0 items-center justify-center gap-1.5">
631
+ <AssetChip snapshot={snapshot} controller={controller} onImport={openImport} />
632
+ </div>
633
+ <div className="flex min-w-0 items-center justify-end gap-1">
634
+ <Button
635
+ variant="outline"
636
+ size="sm"
637
+ className="w-44 justify-between font-sans font-normal text-muted-foreground shadow-none"
638
+ onClick={() => controller.openDialog({ kind: "search" })}
639
+ >
640
+ <span className="flex items-center gap-1.5">
641
+ <IconSearch /> Search...
642
+ </span>
643
+ <Kbd>⌘K</Kbd>
644
+ </Button>
645
+ <HeaderIconButton
646
+ label={dark ? "라이트 모드" : "다크 모드"}
647
+ onClick={() => controller.setTheme(dark ? "light" : "dark")}
648
+ >
649
+ {dark ? <IconSun /> : <IconMoon />}
650
+ </HeaderIconButton>
651
+ </div>
652
+ </header>
653
+ <div className="flex min-h-0 flex-1">
654
+ <Rail snapshot={snapshot} />
655
+ <main className="flex min-h-0 min-w-0 flex-1 flex-col">
656
+ {snapshot.chrome ? (
657
+ <EditorHostChrome
658
+ value={snapshot.chrome}
659
+ host={handlers}
660
+ onViewCommand={onViewCommand}
661
+ commandState={snapshot.commandState}
662
+ />
663
+ ) : (
664
+ <nav className="h-8 shrink-0 border-b border-border bg-card" aria-label="에디터 메뉴" />
665
+ )}
666
+ <div className="flex min-h-0 flex-1">
667
+ <ViewFrame snapshot={snapshot} controller={controller} />
668
+ <Inspector snapshot={snapshot} controller={controller} layout={inspector} setLayout={setInspector} />
669
+ </div>
670
+ </main>
671
+ </div>
672
+ <input
673
+ ref={fileInput}
674
+ type="file"
675
+ hidden
676
+ accept={snapshot.manifest.importAccept}
677
+ onChange={(event) => {
678
+ const file = event.target.files?.[0];
679
+ event.target.value = "";
680
+ if (file) void controller.importFile(file);
681
+ }}
682
+ />
683
+ <Dialogs snapshot={snapshot} controller={controller} />
684
+ <SearchDialog snapshot={snapshot} controller={controller} />
685
+ </div>
686
+ </TooltipProvider>
687
+ );
688
+ }