@moloodev/cli 0.3.0 → 0.4.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/README.md +3 -0
- package/package.json +14 -15
- package/src/auth.ts +158 -0
- package/src/cli.ts +88 -34
- package/src/config.ts +36 -0
- package/src/credentials.ts +54 -0
- package/src/dev-server.ts +40 -141
- package/src/editor-dev.ts +38 -0
- package/src/editor-views.ts +23 -43
- package/src/index.ts +0 -2
- package/src/oauth.ts +190 -0
- package/src/publish.ts +6 -5
- package/src/workspace.ts +68 -0
- package/src/dev-host-app.tsx +0 -688
- package/src/dev-host-client.ts +0 -940
- package/src/dev-host-entry.tsx +0 -12
- package/src/dev-host.css +0 -19
- package/src/view-page.ts +0 -57
package/src/dev-host-app.tsx
DELETED
|
@@ -1,688 +0,0 @@
|
|
|
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
|
-
}
|