create-interview-cockpit 0.12.0 → 0.14.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/package.json +1 -1
- package/template/client/src/App.tsx +30 -1
- package/template/client/src/api.ts +22 -0
- package/template/client/src/components/CodeContextPanel.tsx +0 -622
- package/template/client/src/components/DeploymentLabModal.tsx +1941 -0
- package/template/client/src/components/LabsPanel.tsx +626 -0
- package/template/client/src/components/Sidebar.tsx +97 -55
- package/template/client/src/reactLab.ts +408 -0
- package/template/client/src/store.ts +52 -1
- package/template/client/src/types.ts +2 -0
- package/template/cockpit.json +1 -1
- package/template/server/src/index.ts +77 -24
- package/template/server/src/storage.ts +31 -0
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { useStore } from "../store";
|
|
3
|
+
import { parseInfraLabWorkspace } from "../infraLab";
|
|
4
|
+
import {
|
|
5
|
+
parseFrontendLabWorkspace,
|
|
6
|
+
ISOLATED_MODULE_FEDERATION_LAB,
|
|
7
|
+
} from "../reactLab";
|
|
8
|
+
import type { ContextFile } from "../types";
|
|
9
|
+
import {
|
|
10
|
+
Plus,
|
|
11
|
+
Play,
|
|
12
|
+
Trash2,
|
|
13
|
+
Server,
|
|
14
|
+
Globe,
|
|
15
|
+
Atom,
|
|
16
|
+
Layout,
|
|
17
|
+
Check,
|
|
18
|
+
X,
|
|
19
|
+
Pencil,
|
|
20
|
+
FlaskConical,
|
|
21
|
+
LinkIcon,
|
|
22
|
+
Link2Off,
|
|
23
|
+
Network,
|
|
24
|
+
} from "lucide-react";
|
|
25
|
+
|
|
26
|
+
// ─── Helpers ─────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
const LAB_ORIGINS = new Set([
|
|
29
|
+
"sandbox",
|
|
30
|
+
"infra",
|
|
31
|
+
"react",
|
|
32
|
+
"nextjs",
|
|
33
|
+
"module-federation",
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
function isLabFile(cf: ContextFile) {
|
|
37
|
+
return LAB_ORIGINS.has(cf.origin ?? "");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ─── Reusable lab item row ────────────────────────────────
|
|
41
|
+
|
|
42
|
+
interface LabItemProps {
|
|
43
|
+
cf: ContextFile;
|
|
44
|
+
questionId: string;
|
|
45
|
+
accentClass: string; // e.g. "text-cyan-200"
|
|
46
|
+
bgClass: string; // e.g. "bg-cyan-500/10 border-cyan-500/20"
|
|
47
|
+
renamingId: string | null;
|
|
48
|
+
renameValue: string;
|
|
49
|
+
onStartRename: (id: string, current: string) => void;
|
|
50
|
+
onCommitRename: () => Promise<void>;
|
|
51
|
+
onCancelRename: () => void;
|
|
52
|
+
setRenameValue: (v: string) => void;
|
|
53
|
+
onOpen: () => void;
|
|
54
|
+
onDetach: () => void;
|
|
55
|
+
onAttach: () => void;
|
|
56
|
+
onDelete: () => void;
|
|
57
|
+
openTitle: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function LabItem({
|
|
61
|
+
cf,
|
|
62
|
+
accentClass,
|
|
63
|
+
bgClass,
|
|
64
|
+
renamingId,
|
|
65
|
+
renameValue,
|
|
66
|
+
onStartRename,
|
|
67
|
+
onCommitRename,
|
|
68
|
+
onCancelRename,
|
|
69
|
+
setRenameValue,
|
|
70
|
+
onOpen,
|
|
71
|
+
onDetach,
|
|
72
|
+
onAttach,
|
|
73
|
+
onDelete,
|
|
74
|
+
openTitle,
|
|
75
|
+
}: LabItemProps) {
|
|
76
|
+
const detached = cf.inContext === false;
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div
|
|
80
|
+
className={`flex items-center gap-1 text-xs border rounded px-1.5 py-1 group transition-opacity ${bgClass} ${
|
|
81
|
+
detached ? "opacity-50" : ""
|
|
82
|
+
}`}
|
|
83
|
+
>
|
|
84
|
+
{renamingId !== cf.id && (
|
|
85
|
+
<span
|
|
86
|
+
className={`${accentClass} font-medium truncate flex-1`}
|
|
87
|
+
title={cf.label || cf.originalName}
|
|
88
|
+
>
|
|
89
|
+
{cf.label || cf.originalName}
|
|
90
|
+
{detached && (
|
|
91
|
+
<span className="ml-1 text-[9px] text-slate-500 font-normal">
|
|
92
|
+
(not in context)
|
|
93
|
+
</span>
|
|
94
|
+
)}
|
|
95
|
+
</span>
|
|
96
|
+
)}
|
|
97
|
+
|
|
98
|
+
{renamingId === cf.id ? (
|
|
99
|
+
<>
|
|
100
|
+
<input
|
|
101
|
+
autoFocus
|
|
102
|
+
value={renameValue}
|
|
103
|
+
onChange={(e) => setRenameValue(e.target.value)}
|
|
104
|
+
onKeyDown={async (e) => {
|
|
105
|
+
if (e.key === "Enter") {
|
|
106
|
+
e.preventDefault();
|
|
107
|
+
await onCommitRename();
|
|
108
|
+
} else if (e.key === "Escape") {
|
|
109
|
+
onCancelRename();
|
|
110
|
+
}
|
|
111
|
+
}}
|
|
112
|
+
className="w-28 bg-slate-900 border border-cyan-600/50 rounded px-1.5 py-0.5 text-[11px] text-slate-200 outline-none focus:border-cyan-500 shrink-0"
|
|
113
|
+
/>
|
|
114
|
+
<button
|
|
115
|
+
onClick={onCommitRename}
|
|
116
|
+
className="shrink-0 p-0.5 rounded text-cyan-400 hover:bg-cyan-600/20 transition-colors"
|
|
117
|
+
title="Confirm"
|
|
118
|
+
>
|
|
119
|
+
<Check className="w-3 h-3" />
|
|
120
|
+
</button>
|
|
121
|
+
<button
|
|
122
|
+
onClick={onCancelRename}
|
|
123
|
+
className="shrink-0 p-0.5 rounded text-slate-500 hover:text-slate-300 transition-colors"
|
|
124
|
+
title="Cancel"
|
|
125
|
+
>
|
|
126
|
+
<X className="w-3 h-3" />
|
|
127
|
+
</button>
|
|
128
|
+
</>
|
|
129
|
+
) : (
|
|
130
|
+
<>
|
|
131
|
+
{/* Rename */}
|
|
132
|
+
<button
|
|
133
|
+
onClick={() => onStartRename(cf.id, cf.label || cf.originalName)}
|
|
134
|
+
className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-cyan-300 transition-all"
|
|
135
|
+
title="Rename"
|
|
136
|
+
>
|
|
137
|
+
<Pencil className="w-3 h-3" />
|
|
138
|
+
</button>
|
|
139
|
+
|
|
140
|
+
{/* Open in lab */}
|
|
141
|
+
<button
|
|
142
|
+
onClick={onOpen}
|
|
143
|
+
className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-cyan-400 transition-all"
|
|
144
|
+
title={openTitle}
|
|
145
|
+
>
|
|
146
|
+
<Play className="w-3 h-3" />
|
|
147
|
+
</button>
|
|
148
|
+
|
|
149
|
+
{/* Attach / Detach context toggle */}
|
|
150
|
+
{detached ? (
|
|
151
|
+
<button
|
|
152
|
+
onClick={onAttach}
|
|
153
|
+
className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-emerald-400 transition-all"
|
|
154
|
+
title="Add back to AI context"
|
|
155
|
+
>
|
|
156
|
+
<LinkIcon className="w-3 h-3" />
|
|
157
|
+
</button>
|
|
158
|
+
) : (
|
|
159
|
+
<button
|
|
160
|
+
onClick={onDetach}
|
|
161
|
+
className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-amber-400 transition-all"
|
|
162
|
+
title="Remove from AI context (keeps the lab saved)"
|
|
163
|
+
>
|
|
164
|
+
<Link2Off className="w-3 h-3" />
|
|
165
|
+
</button>
|
|
166
|
+
)}
|
|
167
|
+
|
|
168
|
+
{/* Permanently delete */}
|
|
169
|
+
<button
|
|
170
|
+
onClick={onDelete}
|
|
171
|
+
className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-red-400 transition-all"
|
|
172
|
+
title="Delete permanently"
|
|
173
|
+
>
|
|
174
|
+
<Trash2 className="w-3 h-3" />
|
|
175
|
+
</button>
|
|
176
|
+
</>
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ─── Main panel ──────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
export default function LabsPanel() {
|
|
185
|
+
const {
|
|
186
|
+
currentQuestion,
|
|
187
|
+
openSandbox,
|
|
188
|
+
openInfraLab,
|
|
189
|
+
openReactLab,
|
|
190
|
+
openNextLab,
|
|
191
|
+
openModuleFederationLab,
|
|
192
|
+
openDeploymentLab,
|
|
193
|
+
removeQuestionFile,
|
|
194
|
+
detachLabFile,
|
|
195
|
+
attachLabFile,
|
|
196
|
+
renameContextFile,
|
|
197
|
+
} = useStore();
|
|
198
|
+
|
|
199
|
+
const [renamingId, setRenamingId] = useState<string | null>(null);
|
|
200
|
+
const [renameValue, setRenameValue] = useState("");
|
|
201
|
+
|
|
202
|
+
const startRename = (id: string, current: string) => {
|
|
203
|
+
setRenamingId(id);
|
|
204
|
+
setRenameValue(current);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const commitRename = async (questionId: string, fileId: string) => {
|
|
208
|
+
if (renameValue.trim()) {
|
|
209
|
+
await renameContextFile(questionId, fileId, renameValue.trim());
|
|
210
|
+
}
|
|
211
|
+
setRenamingId(null);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const cancelRename = () => setRenamingId(null);
|
|
215
|
+
|
|
216
|
+
const files = (currentQuestion?.contextFiles || []).filter(isLabFile);
|
|
217
|
+
|
|
218
|
+
const byOrigin = (origin: string) => files.filter((f) => f.origin === origin);
|
|
219
|
+
|
|
220
|
+
// ── Open helpers ─────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
const openSandboxFile = async (cf: ContextFile) => {
|
|
223
|
+
try {
|
|
224
|
+
const raw = await fetch(`/api/context-files/${cf.id}/content`)
|
|
225
|
+
.then((r) => r.json())
|
|
226
|
+
.then((d) => d.content as string);
|
|
227
|
+
const parsed = JSON.parse(raw) as {
|
|
228
|
+
serverCode: string;
|
|
229
|
+
serverLang: string;
|
|
230
|
+
clientCode: string;
|
|
231
|
+
clientLang: string;
|
|
232
|
+
clientType?: "script" | "react" | "nextjs";
|
|
233
|
+
reactFiles?: Record<string, string>;
|
|
234
|
+
reactActiveFile?: string;
|
|
235
|
+
};
|
|
236
|
+
openSandbox(
|
|
237
|
+
parsed.serverCode,
|
|
238
|
+
parsed.serverLang,
|
|
239
|
+
parsed.clientCode,
|
|
240
|
+
parsed.clientLang,
|
|
241
|
+
cf.id,
|
|
242
|
+
parsed.clientType
|
|
243
|
+
? {
|
|
244
|
+
clientType: parsed.clientType,
|
|
245
|
+
reactFiles: parsed.reactFiles,
|
|
246
|
+
reactActiveFile: parsed.reactActiveFile,
|
|
247
|
+
}
|
|
248
|
+
: undefined,
|
|
249
|
+
);
|
|
250
|
+
} catch {
|
|
251
|
+
/* ignore */
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const openInfraFile = async (cf: ContextFile) => {
|
|
256
|
+
const raw = await fetch(`/api/context-files/${cf.id}/content`)
|
|
257
|
+
.then((r) => r.json())
|
|
258
|
+
.then((d) => d.content as string);
|
|
259
|
+
const parsed = parseInfraLabWorkspace(raw);
|
|
260
|
+
if (parsed) openInfraLab(parsed, cf.id);
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const openReactFile = async (cf: ContextFile) => {
|
|
264
|
+
try {
|
|
265
|
+
const raw = await fetch(`/api/context-files/${cf.id}/content`)
|
|
266
|
+
.then((r) => r.json())
|
|
267
|
+
.then((d) => d.content as string);
|
|
268
|
+
const ext = JSON.parse(raw) as {
|
|
269
|
+
clientType?: string;
|
|
270
|
+
reactFiles?: Record<string, string>;
|
|
271
|
+
reactActiveFile?: string;
|
|
272
|
+
serverCode?: string;
|
|
273
|
+
serverLang?: string;
|
|
274
|
+
};
|
|
275
|
+
if (ext?.clientType === "react" && ext.reactFiles) {
|
|
276
|
+
openReactLab(
|
|
277
|
+
{
|
|
278
|
+
version: 1,
|
|
279
|
+
type: "react",
|
|
280
|
+
label: cf.label || "React Lab",
|
|
281
|
+
activeFile:
|
|
282
|
+
ext.reactActiveFile ??
|
|
283
|
+
Object.keys(ext.reactFiles)[0] ??
|
|
284
|
+
"App.tsx",
|
|
285
|
+
files: ext.reactFiles,
|
|
286
|
+
},
|
|
287
|
+
cf.id,
|
|
288
|
+
ext.serverCode,
|
|
289
|
+
ext.serverLang,
|
|
290
|
+
);
|
|
291
|
+
} else {
|
|
292
|
+
const ws = parseFrontendLabWorkspace(raw);
|
|
293
|
+
if (ws) openReactLab(ws, cf.id);
|
|
294
|
+
}
|
|
295
|
+
} catch {
|
|
296
|
+
/* ignore */
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const openNextFile = async (cf: ContextFile) => {
|
|
301
|
+
try {
|
|
302
|
+
const raw = await fetch(`/api/context-files/${cf.id}/content`)
|
|
303
|
+
.then((r) => r.json())
|
|
304
|
+
.then((d) => d.content as string);
|
|
305
|
+
const ext = JSON.parse(raw) as {
|
|
306
|
+
clientType?: string;
|
|
307
|
+
reactFiles?: Record<string, string>;
|
|
308
|
+
reactActiveFile?: string;
|
|
309
|
+
serverCode?: string;
|
|
310
|
+
serverLang?: string;
|
|
311
|
+
};
|
|
312
|
+
if (ext?.clientType === "nextjs" && ext.reactFiles) {
|
|
313
|
+
openNextLab(
|
|
314
|
+
{
|
|
315
|
+
version: 1,
|
|
316
|
+
type: "nextjs",
|
|
317
|
+
label: cf.label || "Next.js Lab",
|
|
318
|
+
activeFile:
|
|
319
|
+
ext.reactActiveFile ??
|
|
320
|
+
Object.keys(ext.reactFiles)[0] ??
|
|
321
|
+
"app/page.tsx",
|
|
322
|
+
files: ext.reactFiles,
|
|
323
|
+
},
|
|
324
|
+
cf.id,
|
|
325
|
+
ext.serverCode,
|
|
326
|
+
ext.serverLang,
|
|
327
|
+
);
|
|
328
|
+
} else {
|
|
329
|
+
const ws = parseFrontendLabWorkspace(raw);
|
|
330
|
+
if (ws) openNextLab(ws, cf.id);
|
|
331
|
+
}
|
|
332
|
+
} catch {
|
|
333
|
+
/* ignore */
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const openMFFile = async (cf: ContextFile) => {
|
|
338
|
+
try {
|
|
339
|
+
const raw = await fetch(`/api/context-files/${cf.id}/content`)
|
|
340
|
+
.then((r) => r.json())
|
|
341
|
+
.then((d) => d.content as string);
|
|
342
|
+
const ext = JSON.parse(raw) as {
|
|
343
|
+
clientType?: string;
|
|
344
|
+
reactFiles?: Record<string, string>;
|
|
345
|
+
reactActiveFile?: string;
|
|
346
|
+
serverCode?: string;
|
|
347
|
+
serverLang?: string;
|
|
348
|
+
};
|
|
349
|
+
if (ext?.clientType === "module-federation" && ext.reactFiles) {
|
|
350
|
+
openModuleFederationLab(
|
|
351
|
+
{
|
|
352
|
+
version: 1,
|
|
353
|
+
type: "module-federation",
|
|
354
|
+
label: cf.label || "Webpack Module Federation Lab",
|
|
355
|
+
activeFile:
|
|
356
|
+
ext.reactActiveFile ??
|
|
357
|
+
Object.keys(ext.reactFiles)[0] ??
|
|
358
|
+
"apps/host/webpack.config.js",
|
|
359
|
+
files: ext.reactFiles,
|
|
360
|
+
},
|
|
361
|
+
cf.id,
|
|
362
|
+
ext.serverCode,
|
|
363
|
+
ext.serverLang,
|
|
364
|
+
);
|
|
365
|
+
} else {
|
|
366
|
+
const ws = parseFrontendLabWorkspace(raw);
|
|
367
|
+
if (ws?.type === "module-federation")
|
|
368
|
+
openModuleFederationLab(ws, cf.id);
|
|
369
|
+
}
|
|
370
|
+
} catch {
|
|
371
|
+
/* ignore */
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
// ── Section renderer ─────────────────────────────────────
|
|
376
|
+
|
|
377
|
+
function Section({
|
|
378
|
+
title,
|
|
379
|
+
icon: Icon,
|
|
380
|
+
iconColor,
|
|
381
|
+
origin,
|
|
382
|
+
emptyText,
|
|
383
|
+
onNewLab,
|
|
384
|
+
newLabTitle,
|
|
385
|
+
newLabMenu,
|
|
386
|
+
onOpen,
|
|
387
|
+
openTitle,
|
|
388
|
+
accentClass,
|
|
389
|
+
bgClass,
|
|
390
|
+
}: {
|
|
391
|
+
title: string;
|
|
392
|
+
icon: React.ElementType;
|
|
393
|
+
iconColor: string;
|
|
394
|
+
origin: string;
|
|
395
|
+
emptyText: string;
|
|
396
|
+
onNewLab?: () => void;
|
|
397
|
+
newLabTitle?: string;
|
|
398
|
+
newLabMenu?: Array<{
|
|
399
|
+
label: string;
|
|
400
|
+
description: string;
|
|
401
|
+
onClick: () => void;
|
|
402
|
+
}>;
|
|
403
|
+
onOpen: (cf: ContextFile) => void;
|
|
404
|
+
openTitle: string;
|
|
405
|
+
accentClass: string;
|
|
406
|
+
bgClass: string;
|
|
407
|
+
}) {
|
|
408
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
409
|
+
const items = byOrigin(origin);
|
|
410
|
+
if (!currentQuestion) return null;
|
|
411
|
+
|
|
412
|
+
return (
|
|
413
|
+
<div className="border-t border-slate-800 px-3 py-2">
|
|
414
|
+
<div className="flex items-center justify-between mb-1">
|
|
415
|
+
<div className="flex items-center gap-1">
|
|
416
|
+
<Icon className={`w-3 h-3 ${iconColor}`} />
|
|
417
|
+
<span className="text-[10px] uppercase tracking-wider text-slate-600">
|
|
418
|
+
{title} ({items.length})
|
|
419
|
+
</span>
|
|
420
|
+
</div>
|
|
421
|
+
{newLabMenu ? (
|
|
422
|
+
<div className="relative">
|
|
423
|
+
<button
|
|
424
|
+
onClick={() => setMenuOpen((o) => !o)}
|
|
425
|
+
className="p-0.5 rounded text-slate-600 hover:text-cyan-400 hover:bg-slate-700 transition-colors"
|
|
426
|
+
title="New lab"
|
|
427
|
+
>
|
|
428
|
+
<Plus className="w-3.5 h-3.5" />
|
|
429
|
+
</button>
|
|
430
|
+
{menuOpen && (
|
|
431
|
+
<>
|
|
432
|
+
{/* backdrop to close on outside click */}
|
|
433
|
+
<div
|
|
434
|
+
className="fixed inset-0 z-40"
|
|
435
|
+
onClick={() => setMenuOpen(false)}
|
|
436
|
+
/>
|
|
437
|
+
<div className="absolute right-0 top-full mt-1 z-50 bg-slate-800 border border-slate-600 rounded-lg shadow-xl w-52 overflow-hidden">
|
|
438
|
+
{newLabMenu.map((item) => (
|
|
439
|
+
<button
|
|
440
|
+
key={item.label}
|
|
441
|
+
onClick={() => {
|
|
442
|
+
item.onClick();
|
|
443
|
+
setMenuOpen(false);
|
|
444
|
+
}}
|
|
445
|
+
className="w-full text-left px-3 py-2 hover:bg-slate-700 transition-colors group"
|
|
446
|
+
>
|
|
447
|
+
<div className="text-[11px] font-medium text-slate-200 group-hover:text-cyan-300">
|
|
448
|
+
{item.label}
|
|
449
|
+
</div>
|
|
450
|
+
<div className="text-[10px] text-slate-500 mt-0.5 leading-snug">
|
|
451
|
+
{item.description}
|
|
452
|
+
</div>
|
|
453
|
+
</button>
|
|
454
|
+
))}
|
|
455
|
+
</div>
|
|
456
|
+
</>
|
|
457
|
+
)}
|
|
458
|
+
</div>
|
|
459
|
+
) : onNewLab ? (
|
|
460
|
+
<button
|
|
461
|
+
onClick={onNewLab}
|
|
462
|
+
className="p-0.5 rounded text-slate-600 hover:text-cyan-400 hover:bg-slate-700 transition-colors"
|
|
463
|
+
title={newLabTitle}
|
|
464
|
+
>
|
|
465
|
+
<Plus className="w-3.5 h-3.5" />
|
|
466
|
+
</button>
|
|
467
|
+
) : null}
|
|
468
|
+
</div>
|
|
469
|
+
<div className="space-y-0.5 max-h-40 overflow-y-auto">
|
|
470
|
+
{items.map((cf) => (
|
|
471
|
+
<LabItem
|
|
472
|
+
key={cf.id}
|
|
473
|
+
cf={cf}
|
|
474
|
+
questionId={currentQuestion.id}
|
|
475
|
+
accentClass={accentClass}
|
|
476
|
+
bgClass={bgClass}
|
|
477
|
+
renamingId={renamingId}
|
|
478
|
+
renameValue={renameValue}
|
|
479
|
+
setRenameValue={setRenameValue}
|
|
480
|
+
onStartRename={startRename}
|
|
481
|
+
onCommitRename={() => commitRename(currentQuestion.id, cf.id)}
|
|
482
|
+
onCancelRename={cancelRename}
|
|
483
|
+
onOpen={() => onOpen(cf)}
|
|
484
|
+
onDetach={() => detachLabFile(currentQuestion.id, cf.id)}
|
|
485
|
+
onAttach={() => attachLabFile(currentQuestion.id, cf.id)}
|
|
486
|
+
onDelete={() => {
|
|
487
|
+
if (
|
|
488
|
+
window.confirm(
|
|
489
|
+
`Permanently delete "${cf.label || cf.originalName}"?`,
|
|
490
|
+
)
|
|
491
|
+
) {
|
|
492
|
+
removeQuestionFile(currentQuestion.id, cf.id);
|
|
493
|
+
}
|
|
494
|
+
}}
|
|
495
|
+
openTitle={openTitle}
|
|
496
|
+
/>
|
|
497
|
+
))}
|
|
498
|
+
{items.length === 0 && (
|
|
499
|
+
<p className="text-[10px] text-slate-700 italic">{emptyText}</p>
|
|
500
|
+
)}
|
|
501
|
+
</div>
|
|
502
|
+
</div>
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return (
|
|
507
|
+
<div className="w-72 h-full min-h-0 border-l border-slate-800 flex flex-col bg-slate-900/30 shrink-0 overflow-hidden">
|
|
508
|
+
{/* Header */}
|
|
509
|
+
<div className="border-b border-slate-800 px-3 py-2 flex items-center gap-1.5">
|
|
510
|
+
<FlaskConical className="w-3.5 h-3.5 text-cyan-400/70" />
|
|
511
|
+
<span className="text-xs font-bold uppercase tracking-wider text-slate-500 flex-1">
|
|
512
|
+
Labs
|
|
513
|
+
</span>
|
|
514
|
+
{!currentQuestion && (
|
|
515
|
+
<span className="text-[10px] text-slate-600">Select a question</span>
|
|
516
|
+
)}
|
|
517
|
+
</div>
|
|
518
|
+
|
|
519
|
+
{currentQuestion ? (
|
|
520
|
+
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
521
|
+
<Section
|
|
522
|
+
title="Sandboxes"
|
|
523
|
+
icon={Server}
|
|
524
|
+
iconColor="text-slate-400/70"
|
|
525
|
+
origin="sandbox"
|
|
526
|
+
emptyText="Save a sandbox to see it here"
|
|
527
|
+
onNewLab={() => openSandbox("", "javascript", "", "javascript")}
|
|
528
|
+
newLabTitle="Open Sandbox"
|
|
529
|
+
onOpen={openSandboxFile}
|
|
530
|
+
openTitle="Open in Sandbox"
|
|
531
|
+
accentClass="text-slate-300"
|
|
532
|
+
bgClass="bg-slate-500/10 border border-slate-500/20"
|
|
533
|
+
/>
|
|
534
|
+
<Section
|
|
535
|
+
title="Infra Labs"
|
|
536
|
+
icon={Globe}
|
|
537
|
+
iconColor="text-cyan-400/70"
|
|
538
|
+
origin="infra"
|
|
539
|
+
emptyText="Save an infra lab to reopen it here"
|
|
540
|
+
onNewLab={() => openInfraLab()}
|
|
541
|
+
newLabTitle="Open Infrastructure Lab"
|
|
542
|
+
onOpen={openInfraFile}
|
|
543
|
+
openTitle="Open in Infrastructure Lab"
|
|
544
|
+
accentClass="text-cyan-200"
|
|
545
|
+
bgClass="bg-cyan-500/10 border border-cyan-500/20"
|
|
546
|
+
/>
|
|
547
|
+
<Section
|
|
548
|
+
title="React Labs"
|
|
549
|
+
icon={Atom}
|
|
550
|
+
iconColor="text-cyan-400/70"
|
|
551
|
+
origin="react"
|
|
552
|
+
emptyText="Save a React lab to reopen it here"
|
|
553
|
+
onNewLab={() => openReactLab()}
|
|
554
|
+
newLabTitle="Open React Lab"
|
|
555
|
+
onOpen={openReactFile}
|
|
556
|
+
openTitle="Open in React Lab"
|
|
557
|
+
accentClass="text-cyan-200"
|
|
558
|
+
bgClass="bg-cyan-500/10 border border-cyan-500/20"
|
|
559
|
+
/>
|
|
560
|
+
<Section
|
|
561
|
+
title="Next.js Labs"
|
|
562
|
+
icon={Layout}
|
|
563
|
+
iconColor="text-violet-400/70"
|
|
564
|
+
origin="nextjs"
|
|
565
|
+
emptyText="Save a Next.js lab to reopen it here"
|
|
566
|
+
onNewLab={() => openNextLab()}
|
|
567
|
+
newLabTitle="Open Next.js Lab"
|
|
568
|
+
onOpen={openNextFile}
|
|
569
|
+
openTitle="Open in Next.js Lab"
|
|
570
|
+
accentClass="text-violet-200"
|
|
571
|
+
bgClass="bg-violet-500/10 border border-violet-500/20"
|
|
572
|
+
/>
|
|
573
|
+
<Section
|
|
574
|
+
title="Webpack MF Labs"
|
|
575
|
+
icon={Server}
|
|
576
|
+
iconColor="text-emerald-400/70"
|
|
577
|
+
origin="module-federation"
|
|
578
|
+
emptyText="Save a webpack module federation lab to reopen it here"
|
|
579
|
+
newLabMenu={[
|
|
580
|
+
{
|
|
581
|
+
label: "Shared React Tree",
|
|
582
|
+
description:
|
|
583
|
+
"Shell & remote share one React runtime — classic federation",
|
|
584
|
+
onClick: () => openModuleFederationLab(),
|
|
585
|
+
},
|
|
586
|
+
{
|
|
587
|
+
label: "Isolated Mount / Unmount",
|
|
588
|
+
description:
|
|
589
|
+
"Remote owns its own React root via mount(el) / unmount(el)",
|
|
590
|
+
onClick: () =>
|
|
591
|
+
openModuleFederationLab(ISOLATED_MODULE_FEDERATION_LAB),
|
|
592
|
+
},
|
|
593
|
+
]}
|
|
594
|
+
onOpen={openMFFile}
|
|
595
|
+
openTitle="Open in Webpack Module Federation Lab"
|
|
596
|
+
accentClass="text-emerald-200"
|
|
597
|
+
bgClass="bg-emerald-500/10 border border-emerald-500/20"
|
|
598
|
+
/>
|
|
599
|
+
</div>
|
|
600
|
+
) : (
|
|
601
|
+
<div className="flex-1 flex items-center justify-center">
|
|
602
|
+
<p className="text-xs text-slate-600">Select a question first</p>
|
|
603
|
+
</div>
|
|
604
|
+
)}
|
|
605
|
+
|
|
606
|
+
{/* Deployment Lab (standalone — no context files) */}
|
|
607
|
+
<div className="border-t border-slate-700/50 p-3 shrink-0">
|
|
608
|
+
<div className="flex items-center gap-2 mb-1.5">
|
|
609
|
+
<Network className="w-3.5 h-3.5 text-violet-400/70" />
|
|
610
|
+
<span className="text-[10px] font-semibold tracking-widest text-slate-400">
|
|
611
|
+
DEPLOYMENT LAB
|
|
612
|
+
</span>
|
|
613
|
+
</div>
|
|
614
|
+
<p className="text-[10px] text-slate-600 mb-2 italic leading-snug">
|
|
615
|
+
Simulate rolling, canary, blue-green & more
|
|
616
|
+
</p>
|
|
617
|
+
<button
|
|
618
|
+
onClick={openDeploymentLab}
|
|
619
|
+
className="text-[11px] px-2.5 py-1 rounded bg-violet-500/15 border border-violet-500/25 text-violet-300 hover:bg-violet-500/25 transition-colors"
|
|
620
|
+
>
|
|
621
|
+
Open Deployment Lab
|
|
622
|
+
</button>
|
|
623
|
+
</div>
|
|
624
|
+
</div>
|
|
625
|
+
);
|
|
626
|
+
}
|