create-interview-cockpit 0.17.3 → 0.18.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,746 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import {
3
+ ChevronDown,
4
+ ChevronRight,
5
+ FilePlus,
6
+ Folder,
7
+ Loader2,
8
+ Maximize2,
9
+ Minimize2,
10
+ Play,
11
+ Save,
12
+ StopCircle,
13
+ Terminal,
14
+ Trash2,
15
+ X,
16
+ } from "lucide-react";
17
+ import MonacoEditorLib from "@monaco-editor/react";
18
+ import type { BeforeMount, Monaco, OnChange } from "@monaco-editor/react";
19
+ import { useStore } from "../store";
20
+ import {
21
+ cloneGhaLabWorkspace,
22
+ DEFAULT_GHA_LAB,
23
+ getGhaLabFileOrder,
24
+ listWorkflowPaths,
25
+ serializeGhaLabWorkspace,
26
+ } from "../githubActionsLab";
27
+ import type { GithubActionsLabWorkspace } from "../types";
28
+ import * as api from "../api";
29
+ import type { GhaStreamMessage } from "../api";
30
+
31
+ // ─── Modal layout constants ──────────────────────────────────────────────
32
+
33
+ const MIN_W = 900;
34
+ const MIN_H = 560;
35
+ const DEFAULT_W = Math.min(1280, window.innerWidth - 48);
36
+ const DEFAULT_H = Math.min(820, window.innerHeight - 48);
37
+ const EDITOR_FONT =
38
+ 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace';
39
+
40
+ const EVENTS = [
41
+ "push",
42
+ "pull_request",
43
+ "workflow_dispatch",
44
+ "release",
45
+ "schedule",
46
+ ] as const;
47
+
48
+ // ─── Helpers ─────────────────────────────────────────────────────────────
49
+
50
+ function baseName(filePath: string): string {
51
+ return filePath.split("/").pop() || filePath;
52
+ }
53
+
54
+ function getEditorLanguage(filePath: string): string {
55
+ const lower = filePath.toLowerCase();
56
+ if (lower.endsWith(".yml") || lower.endsWith(".yaml")) return "yaml";
57
+ if (lower.endsWith(".json")) return "json";
58
+ if (lower.endsWith(".md") || lower.endsWith(".markdown")) return "markdown";
59
+ if (lower.endsWith(".js") || lower.endsWith(".mjs")) return "javascript";
60
+ if (lower.endsWith(".ts") || lower.endsWith(".tsx")) return "typescript";
61
+ if (lower.endsWith(".sh") || lower.endsWith(".bash")) return "shell";
62
+ if (baseName(lower) === "dockerfile") return "dockerfile";
63
+ return "plaintext";
64
+ }
65
+
66
+ // Tiny grouped-by-folder list to keep the modal lean.
67
+ function groupByFolder(paths: string[]): { folder: string; files: string[] }[] {
68
+ const map = new Map<string, string[]>();
69
+ for (const p of paths) {
70
+ const idx = p.lastIndexOf("/");
71
+ const folder = idx === -1 ? "" : p.slice(0, idx);
72
+ if (!map.has(folder)) map.set(folder, []);
73
+ map.get(folder)!.push(p);
74
+ }
75
+ return Array.from(map.entries())
76
+ .sort(([a], [b]) => a.localeCompare(b))
77
+ .map(([folder, files]) => ({ folder, files: files.sort() }));
78
+ }
79
+
80
+ // ─── Component ───────────────────────────────────────────────────────────
81
+
82
+ interface ConsoleLine {
83
+ id: string;
84
+ kind: "stdout" | "stderr" | "info" | "input";
85
+ text: string;
86
+ }
87
+
88
+ export default function GithubActionsLabModal() {
89
+ const {
90
+ closeGhaLab,
91
+ currentQuestion,
92
+ runnerInitialGha,
93
+ runnerInitialGhaFileId,
94
+ saveCodeSnippetToQuestion,
95
+ overwriteContextFileContent,
96
+ } = useStore();
97
+
98
+ // ── Workspace state ────────────────────────────────────────────────
99
+ const [workspace, setWorkspace] = useState<GithubActionsLabWorkspace>(() =>
100
+ cloneGhaLabWorkspace(runnerInitialGha ?? DEFAULT_GHA_LAB),
101
+ );
102
+ const [labName, setLabName] = useState(
103
+ runnerInitialGha?.label ?? DEFAULT_GHA_LAB.label,
104
+ );
105
+ const [activeFile, setActiveFile] = useState(
106
+ runnerInitialGha?.activeFile ?? DEFAULT_GHA_LAB.activeFile,
107
+ );
108
+ const [activeGhaId, setActiveGhaId] = useState<string | null>(
109
+ runnerInitialGhaFileId ?? null,
110
+ );
111
+
112
+ useEffect(() => {
113
+ const next = cloneGhaLabWorkspace(runnerInitialGha ?? DEFAULT_GHA_LAB);
114
+ setWorkspace(next);
115
+ setLabName(next.label);
116
+ setActiveFile(next.activeFile);
117
+ setActiveGhaId(runnerInitialGhaFileId ?? null);
118
+ }, [runnerInitialGha, runnerInitialGhaFileId]);
119
+
120
+ // ── Save state ─────────────────────────────────────────────────────
121
+ const [saving, setSaving] = useState(false);
122
+ const [saved, setSaved] = useState(false);
123
+
124
+ // ── Run state ──────────────────────────────────────────────────────
125
+ const workflowPaths = useMemo(
126
+ () => listWorkflowPaths(workspace),
127
+ [workspace],
128
+ );
129
+ const [event, setEvent] = useState<string>(workspace.defaultEvent ?? "push");
130
+ const [workflow, setWorkflow] = useState<string>(
131
+ workspace.defaultWorkflow ?? workflowPaths[0] ?? ".github/workflows/ci.yml",
132
+ );
133
+ const [jobFilter, setJobFilter] = useState<string>("");
134
+ const [dryRun, setDryRun] = useState(false);
135
+ const [running, setRunning] = useState(false);
136
+ const [consoleLines, setConsoleLines] = useState<ConsoleLine[]>([]);
137
+ const [runError, setRunError] = useState<string | null>(null);
138
+ const abortRef = useRef<AbortController | null>(null);
139
+ const consoleEndRef = useRef<HTMLDivElement | null>(null);
140
+
141
+ useEffect(() => {
142
+ // Keep workflow selection valid when files change
143
+ if (!workflowPaths.includes(workflow)) {
144
+ setWorkflow(workflowPaths[0] ?? "");
145
+ }
146
+ }, [workflowPaths, workflow]);
147
+
148
+ useEffect(() => {
149
+ consoleEndRef.current?.scrollIntoView({ behavior: "smooth" });
150
+ }, [consoleLines.length]);
151
+
152
+ // ── Drag / resize / maximize ───────────────────────────────────────
153
+ const [pos, setPos] = useState(() => ({
154
+ x: Math.max(0, (window.innerWidth - DEFAULT_W) / 2),
155
+ y: Math.max(0, (window.innerHeight - DEFAULT_H) / 2),
156
+ }));
157
+ const [size, setSize] = useState({ w: DEFAULT_W, h: DEFAULT_H });
158
+ const [maximized, setMaximized] = useState(false);
159
+ const dragStart = useRef<{
160
+ mx: number;
161
+ my: number;
162
+ ox: number;
163
+ oy: number;
164
+ } | null>(null);
165
+
166
+ const onTitleMouseDown = useCallback(
167
+ (e: React.MouseEvent) => {
168
+ if (maximized) return;
169
+ dragStart.current = {
170
+ mx: e.clientX,
171
+ my: e.clientY,
172
+ ox: pos.x,
173
+ oy: pos.y,
174
+ };
175
+ const onMove = (ev: MouseEvent) => {
176
+ if (!dragStart.current) return;
177
+ setPos({
178
+ x: Math.max(
179
+ 0,
180
+ dragStart.current.ox + (ev.clientX - dragStart.current.mx),
181
+ ),
182
+ y: Math.max(
183
+ 0,
184
+ dragStart.current.oy + (ev.clientY - dragStart.current.my),
185
+ ),
186
+ });
187
+ };
188
+ const onUp = () => {
189
+ dragStart.current = null;
190
+ window.removeEventListener("mousemove", onMove);
191
+ window.removeEventListener("mouseup", onUp);
192
+ };
193
+ window.addEventListener("mousemove", onMove);
194
+ window.addEventListener("mouseup", onUp);
195
+ },
196
+ [pos.x, pos.y, maximized],
197
+ );
198
+
199
+ // ── File operations ───────────────────────────────────────────────
200
+ const fileOrder = useMemo(() => getGhaLabFileOrder(workspace), [workspace]);
201
+ const grouped = useMemo(() => groupByFolder(fileOrder), [fileOrder]);
202
+ const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(
203
+ () => new Set(),
204
+ );
205
+ const toggleFolder = (folder: string) => {
206
+ setCollapsedFolders((prev) => {
207
+ const next = new Set(prev);
208
+ if (next.has(folder)) next.delete(folder);
209
+ else next.add(folder);
210
+ return next;
211
+ });
212
+ };
213
+
214
+ const updateFile = (fileName: string, content: string) => {
215
+ setWorkspace((prev) => ({
216
+ ...prev,
217
+ activeFile: fileName,
218
+ files: { ...prev.files, [fileName]: content },
219
+ }));
220
+ };
221
+
222
+ const addFile = () => {
223
+ const name = window.prompt(
224
+ "New file path (e.g. .github/workflows/release.yml)",
225
+ ".github/workflows/new.yml",
226
+ );
227
+ if (!name) return;
228
+ const trimmed = name.trim();
229
+ if (!trimmed || workspace.files[trimmed]) return;
230
+ setWorkspace((prev) => ({
231
+ ...prev,
232
+ activeFile: trimmed,
233
+ files: { ...prev.files, [trimmed]: "" },
234
+ }));
235
+ setActiveFile(trimmed);
236
+ };
237
+
238
+ const deleteFile = (fileName: string) => {
239
+ if (!window.confirm(`Delete ${fileName}?`)) return;
240
+ setWorkspace((prev) => {
241
+ const { [fileName]: _, ...rest } = prev.files;
242
+ const nextActive =
243
+ prev.activeFile === fileName
244
+ ? (Object.keys(rest)[0] ?? "")
245
+ : prev.activeFile;
246
+ return { ...prev, activeFile: nextActive, files: rest };
247
+ });
248
+ if (activeFile === fileName) {
249
+ const remaining = Object.keys(workspace.files).filter(
250
+ (f) => f !== fileName,
251
+ );
252
+ setActiveFile(remaining[0] ?? "");
253
+ }
254
+ };
255
+
256
+ // ── Save lab as context file ──────────────────────────────────────
257
+ const handleSave = useCallback(async () => {
258
+ if (!currentQuestion) return;
259
+ setSaving(true);
260
+ try {
261
+ const payload = serializeGhaLabWorkspace({
262
+ ...workspace,
263
+ label: labName || workspace.label,
264
+ activeFile,
265
+ defaultEvent: event,
266
+ defaultWorkflow: workflow,
267
+ });
268
+ if (activeGhaId) {
269
+ await overwriteContextFileContent(
270
+ currentQuestion.id,
271
+ activeGhaId,
272
+ payload,
273
+ );
274
+ } else {
275
+ const cf = await saveCodeSnippetToQuestion(
276
+ currentQuestion.id,
277
+ payload,
278
+ "json",
279
+ labName || workspace.label,
280
+ "github-actions",
281
+ );
282
+ setActiveGhaId(cf.id);
283
+ }
284
+ setSaved(true);
285
+ window.setTimeout(() => setSaved(false), 1500);
286
+ } finally {
287
+ setSaving(false);
288
+ }
289
+ }, [
290
+ currentQuestion,
291
+ workspace,
292
+ labName,
293
+ activeFile,
294
+ event,
295
+ workflow,
296
+ activeGhaId,
297
+ overwriteContextFileContent,
298
+ saveCodeSnippetToQuestion,
299
+ ]);
300
+
301
+ // ── Run act ──────────────────────────────────────────────────────
302
+ const buildCommand = useCallback(() => {
303
+ const parts = ["act", event];
304
+ if (workflow) {
305
+ parts.push("-W", workflow);
306
+ }
307
+ if (jobFilter.trim()) {
308
+ parts.push("-j", jobFilter.trim());
309
+ }
310
+ if (dryRun) parts.push("-n");
311
+ return parts.join(" ");
312
+ }, [event, workflow, jobFilter, dryRun]);
313
+
314
+ const appendConsole = useCallback((line: Omit<ConsoleLine, "id">) => {
315
+ setConsoleLines((prev) => [
316
+ ...prev,
317
+ { ...line, id: `${Date.now()}-${Math.random().toString(36).slice(2)}` },
318
+ ]);
319
+ }, []);
320
+
321
+ const runCommand = useCallback(
322
+ async (command: string) => {
323
+ setRunning(true);
324
+ setRunError(null);
325
+ appendConsole({ kind: "input", text: `$ ${command}\n` });
326
+
327
+ try {
328
+ await api.streamGhaCommand(
329
+ {
330
+ command,
331
+ workspace: {
332
+ ...workspace,
333
+ activeFile,
334
+ label: labName || workspace.label,
335
+ defaultEvent: event,
336
+ defaultWorkflow: workflow,
337
+ },
338
+ ...(activeGhaId ? { fileId: activeGhaId } : {}),
339
+ ...(currentQuestion ? { questionId: currentQuestion.id } : {}),
340
+ label: labName || workspace.label,
341
+ },
342
+ (message: GhaStreamMessage) => {
343
+ if (message.type === "output") {
344
+ appendConsole({ kind: message.kind, text: message.text });
345
+ } else if (message.type === "error") {
346
+ setRunError(message.error);
347
+ appendConsole({
348
+ kind: "stderr",
349
+ text: `\n[error] ${message.error}\n`,
350
+ });
351
+ } else if (message.type === "complete") {
352
+ appendConsole({
353
+ kind: "info",
354
+ text: `\n[done] exit=${message.exitCode} • ${message.durationMs}ms\n`,
355
+ });
356
+ }
357
+ },
358
+ );
359
+ } catch (err: any) {
360
+ const msg = err?.message || "Failed to start run";
361
+ setRunError(msg);
362
+ appendConsole({ kind: "stderr", text: `\n[error] ${msg}\n` });
363
+ } finally {
364
+ setRunning(false);
365
+ }
366
+ },
367
+ [
368
+ workspace,
369
+ activeFile,
370
+ labName,
371
+ event,
372
+ workflow,
373
+ activeGhaId,
374
+ currentQuestion,
375
+ appendConsole,
376
+ ],
377
+ );
378
+
379
+ const handleRun = () => runCommand(buildCommand());
380
+ const handleListJobs = () =>
381
+ runCommand(workflow ? `act -W ${workflow} -l` : "act -l");
382
+
383
+ const clearConsole = () => setConsoleLines([]);
384
+
385
+ // ── Console input ─────────────────────────────────────────────────
386
+ const [consoleInput, setConsoleInput] = useState("");
387
+ const handleConsoleSubmit = (e: React.FormEvent) => {
388
+ e.preventDefault();
389
+ const cmd = consoleInput.trim();
390
+ if (!cmd || running) return;
391
+ setConsoleInput("");
392
+ runCommand(cmd);
393
+ };
394
+
395
+ // ── Monaco config ─────────────────────────────────────────────────
396
+ const handleBeforeMount = useCallback<BeforeMount>((monaco: Monaco) => {
397
+ monaco.editor.defineTheme("gha-lab-dark", {
398
+ base: "vs-dark",
399
+ inherit: true,
400
+ rules: [
401
+ { token: "comment", foreground: "64748b", fontStyle: "italic" },
402
+ { token: "keyword", foreground: "f59e0b", fontStyle: "bold" },
403
+ { token: "string", foreground: "86efac" },
404
+ { token: "type", foreground: "a78bfa" },
405
+ ],
406
+ colors: {
407
+ "editor.background": "#0b0f1a",
408
+ "editor.foreground": "#e2e8f0",
409
+ "editorLineNumber.foreground": "#475569",
410
+ "editorLineNumber.activeForeground": "#fbbf24",
411
+ "editorCursor.foreground": "#fbbf24",
412
+ "editor.lineHighlightBackground": "#111827",
413
+ "editor.selectionBackground": "#92400eAA",
414
+ },
415
+ });
416
+ }, []);
417
+
418
+ const handleEditorChange = useCallback<OnChange>(
419
+ (next) => {
420
+ updateFile(activeFile, next ?? "");
421
+ },
422
+ [activeFile],
423
+ );
424
+
425
+ // ── Render ────────────────────────────────────────────────────────
426
+ const containerStyle: React.CSSProperties = maximized
427
+ ? { top: 0, left: 0, width: "100vw", height: "100vh" }
428
+ : { top: pos.y, left: pos.x, width: size.w, height: size.h };
429
+
430
+ return (
431
+ <div className="fixed inset-0 z-40 bg-black/40 backdrop-blur-sm">
432
+ <div
433
+ className="absolute flex flex-col rounded-2xl border border-slate-800 bg-slate-950 shadow-2xl overflow-hidden"
434
+ style={containerStyle}
435
+ >
436
+ {/* Title bar */}
437
+ <div
438
+ onMouseDown={onTitleMouseDown}
439
+ className="flex items-center gap-2 border-b border-slate-800 bg-slate-900/80 px-4 py-2 cursor-move select-none"
440
+ >
441
+ <div className="flex items-center gap-2">
442
+ <span className="inline-block h-2 w-2 rounded-full bg-amber-400" />
443
+ <span className="text-xs font-semibold tracking-widest text-amber-300">
444
+ GITHUB ACTIONS LAB
445
+ </span>
446
+ </div>
447
+ <input
448
+ value={labName}
449
+ onChange={(e) => setLabName(e.target.value)}
450
+ className="ml-2 flex-1 bg-transparent text-sm font-medium text-slate-100 outline-none border-b border-transparent focus:border-amber-500/50"
451
+ />
452
+ <button
453
+ onClick={() => setMaximized((v) => !v)}
454
+ className="p-1.5 rounded text-slate-400 hover:text-amber-300 hover:bg-slate-800/60"
455
+ title={maximized ? "Restore" : "Maximize"}
456
+ >
457
+ {maximized ? (
458
+ <Minimize2 className="w-4 h-4" />
459
+ ) : (
460
+ <Maximize2 className="w-4 h-4" />
461
+ )}
462
+ </button>
463
+ <button
464
+ onClick={closeGhaLab}
465
+ className="p-1.5 rounded text-slate-400 hover:text-red-300 hover:bg-slate-800/60"
466
+ title="Close"
467
+ >
468
+ <X className="w-4 h-4" />
469
+ </button>
470
+ </div>
471
+
472
+ {/* Toolbar */}
473
+ <div className="flex flex-wrap items-center gap-2 border-b border-slate-800 bg-slate-900/40 px-3 py-2 text-xs">
474
+ <label className="text-slate-400">Event</label>
475
+ <select
476
+ value={event}
477
+ onChange={(e) => setEvent(e.target.value)}
478
+ className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-slate-200"
479
+ >
480
+ {EVENTS.map((ev) => (
481
+ <option key={ev} value={ev}>
482
+ {ev}
483
+ </option>
484
+ ))}
485
+ </select>
486
+
487
+ <label className="ml-2 text-slate-400">Workflow</label>
488
+ <select
489
+ value={workflow}
490
+ onChange={(e) => setWorkflow(e.target.value)}
491
+ className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-slate-200"
492
+ >
493
+ {workflowPaths.length === 0 && (
494
+ <option value="">No workflows</option>
495
+ )}
496
+ {workflowPaths.map((wf) => (
497
+ <option key={wf} value={wf}>
498
+ {wf}
499
+ </option>
500
+ ))}
501
+ </select>
502
+
503
+ <label className="ml-2 text-slate-400">Job</label>
504
+ <input
505
+ value={jobFilter}
506
+ onChange={(e) => setJobFilter(e.target.value)}
507
+ placeholder="(all)"
508
+ className="w-24 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-slate-200 placeholder:text-slate-600"
509
+ />
510
+
511
+ <label className="ml-2 inline-flex items-center gap-1 text-slate-400">
512
+ <input
513
+ type="checkbox"
514
+ checked={dryRun}
515
+ onChange={(e) => setDryRun(e.target.checked)}
516
+ className="accent-amber-400"
517
+ />
518
+ Dry run
519
+ </label>
520
+
521
+ <div className="ml-auto flex items-center gap-2">
522
+ <button
523
+ onClick={handleListJobs}
524
+ disabled={running}
525
+ className="flex items-center gap-1 rounded border border-slate-700 px-2 py-1 text-slate-300 hover:border-amber-500/40 hover:text-amber-200 disabled:opacity-50"
526
+ title="List jobs in the selected workflow"
527
+ >
528
+ List jobs
529
+ </button>
530
+ <button
531
+ onClick={handleRun}
532
+ disabled={running || workflowPaths.length === 0}
533
+ className="flex items-center gap-1 rounded bg-amber-500/20 border border-amber-500/40 px-3 py-1 text-amber-200 hover:bg-amber-500/30 disabled:opacity-50"
534
+ >
535
+ {running ? (
536
+ <Loader2 className="w-3.5 h-3.5 animate-spin" />
537
+ ) : (
538
+ <Play className="w-3.5 h-3.5" />
539
+ )}
540
+ Run
541
+ </button>
542
+ {currentQuestion && (
543
+ <button
544
+ onClick={handleSave}
545
+ disabled={saving}
546
+ className="flex items-center gap-1 rounded border border-slate-700 px-2 py-1 text-slate-300 hover:border-amber-500/40 hover:text-amber-200 disabled:opacity-50"
547
+ title={activeGhaId ? "Update lab snapshot" : "Save as lab"}
548
+ >
549
+ {saving ? (
550
+ <Loader2 className="w-3.5 h-3.5 animate-spin" />
551
+ ) : (
552
+ <Save className="w-3.5 h-3.5" />
553
+ )}
554
+ {saved ? "Saved" : activeGhaId ? "Update" : "Save"}
555
+ </button>
556
+ )}
557
+ </div>
558
+ </div>
559
+
560
+ {/* Main body */}
561
+ <div className="flex-1 min-h-0 grid grid-cols-[220px_1fr_1fr]">
562
+ {/* File tree */}
563
+ <div className="flex flex-col border-r border-slate-800 bg-slate-900/30 min-h-0">
564
+ <div className="flex items-center justify-between px-2 py-1.5 border-b border-slate-800/60">
565
+ <span className="text-[10px] font-semibold tracking-widest text-slate-500">
566
+ FILES
567
+ </span>
568
+ <button
569
+ onClick={addFile}
570
+ className="p-1 rounded text-slate-400 hover:text-amber-300 hover:bg-slate-800/60"
571
+ title="Add file"
572
+ >
573
+ <FilePlus className="w-3.5 h-3.5" />
574
+ </button>
575
+ </div>
576
+ <div className="flex-1 overflow-auto p-1 text-xs">
577
+ {grouped.map(({ folder, files }) => {
578
+ const collapsed = collapsedFolders.has(folder);
579
+ return (
580
+ <div key={folder || "root"} className="mb-1">
581
+ {folder && (
582
+ <button
583
+ onClick={() => toggleFolder(folder)}
584
+ className="flex items-center gap-1 w-full px-1 py-0.5 text-slate-400 hover:text-slate-200"
585
+ >
586
+ {collapsed ? (
587
+ <ChevronRight className="w-3 h-3" />
588
+ ) : (
589
+ <ChevronDown className="w-3 h-3" />
590
+ )}
591
+ <Folder className="w-3 h-3" />
592
+ <span className="truncate">{folder}/</span>
593
+ </button>
594
+ )}
595
+ {!collapsed &&
596
+ files.map((filePath) => (
597
+ <div
598
+ key={filePath}
599
+ className={`group flex items-center gap-1 pl-${folder ? 5 : 1} pr-1 py-0.5 rounded cursor-pointer ${
600
+ activeFile === filePath
601
+ ? "bg-amber-500/15 text-amber-200"
602
+ : "text-slate-300 hover:bg-slate-800/40"
603
+ }`}
604
+ onClick={() => setActiveFile(filePath)}
605
+ style={{ paddingLeft: folder ? 20 : 6 }}
606
+ >
607
+ <span className="truncate flex-1">
608
+ {baseName(filePath)}
609
+ </span>
610
+ <button
611
+ onClick={(e) => {
612
+ e.stopPropagation();
613
+ deleteFile(filePath);
614
+ }}
615
+ className="opacity-0 group-hover:opacity-100 p-0.5 text-slate-500 hover:text-red-400"
616
+ title="Delete"
617
+ >
618
+ <Trash2 className="w-3 h-3" />
619
+ </button>
620
+ </div>
621
+ ))}
622
+ </div>
623
+ );
624
+ })}
625
+ </div>
626
+ </div>
627
+
628
+ {/* Editor */}
629
+ <div className="min-h-0 flex flex-col border-r border-slate-800">
630
+ <div className="px-3 py-1.5 border-b border-slate-800/60 text-[11px] text-slate-400 truncate">
631
+ {activeFile || "(no file selected)"}
632
+ </div>
633
+ <div className="flex-1 min-h-0">
634
+ {activeFile && workspace.files[activeFile] !== undefined && (
635
+ <MonacoEditorLib
636
+ key={activeFile}
637
+ height="100%"
638
+ width="100%"
639
+ language={getEditorLanguage(activeFile)}
640
+ theme="gha-lab-dark"
641
+ path={`file:///gha-lab/${activeFile}`}
642
+ value={workspace.files[activeFile]}
643
+ beforeMount={handleBeforeMount}
644
+ onChange={handleEditorChange}
645
+ options={{
646
+ fontFamily: EDITOR_FONT,
647
+ fontSize: 13,
648
+ lineHeight: 22,
649
+ minimap: { enabled: false },
650
+ automaticLayout: true,
651
+ scrollBeyondLastLine: false,
652
+ wordWrap: "off",
653
+ tabSize: 2,
654
+ insertSpaces: true,
655
+ }}
656
+ />
657
+ )}
658
+ </div>
659
+ </div>
660
+
661
+ {/* Console */}
662
+ <div className="min-h-0 flex flex-col bg-slate-950">
663
+ <div className="flex items-center justify-between border-b border-slate-800/60 px-3 py-1.5">
664
+ <div className="flex items-center gap-1 text-[11px] text-slate-400">
665
+ <Terminal className="w-3 h-3" />
666
+ Console
667
+ </div>
668
+ <div className="flex items-center gap-1">
669
+ {running && (
670
+ <button
671
+ onClick={() => abortRef.current?.abort()}
672
+ className="p-1 rounded text-amber-300 hover:bg-slate-800/60"
673
+ title="Stop (close & restart to fully cancel)"
674
+ >
675
+ <StopCircle className="w-3.5 h-3.5" />
676
+ </button>
677
+ )}
678
+ <button
679
+ onClick={clearConsole}
680
+ className="p-1 rounded text-slate-500 hover:text-slate-200 hover:bg-slate-800/60"
681
+ title="Clear console"
682
+ >
683
+ <Trash2 className="w-3.5 h-3.5" />
684
+ </button>
685
+ </div>
686
+ </div>
687
+ <div className="flex-1 min-h-0 overflow-auto px-3 py-2 text-[11px] font-mono whitespace-pre-wrap break-words">
688
+ {consoleLines.length === 0 ? (
689
+ <div className="text-slate-600">
690
+ Click <span className="text-amber-300">Run</span> to execute
691
+ the selected workflow with{" "}
692
+ <span className="text-amber-300">act</span>. Output will
693
+ stream here just like the Actions tab on GitHub.
694
+ {"\n\n"}
695
+ Tips:
696
+ {"\n"} • First run pulls a ~500 MB runner image. Be patient.
697
+ {"\n"} • Use <span className="text-amber-300">Dry run</span>{" "}
698
+ to plan a workflow without Docker.
699
+ {"\n"} • You can also type{" "}
700
+ <span className="text-amber-300">act -l</span> in the console
701
+ below.
702
+ </div>
703
+ ) : (
704
+ consoleLines.map((line) => (
705
+ <div
706
+ key={line.id}
707
+ className={
708
+ line.kind === "stderr"
709
+ ? "text-red-300"
710
+ : line.kind === "info"
711
+ ? "text-slate-400"
712
+ : line.kind === "input"
713
+ ? "text-amber-200"
714
+ : "text-slate-200"
715
+ }
716
+ >
717
+ {line.text}
718
+ </div>
719
+ ))
720
+ )}
721
+ {runError && (
722
+ <div className="mt-2 rounded border border-red-500/30 bg-red-500/10 px-2 py-1 text-red-200">
723
+ {runError}
724
+ </div>
725
+ )}
726
+ <div ref={consoleEndRef} />
727
+ </div>
728
+ <form
729
+ onSubmit={handleConsoleSubmit}
730
+ className="flex items-center gap-2 border-t border-slate-800/60 px-3 py-1.5"
731
+ >
732
+ <span className="text-amber-400 text-xs font-mono">$</span>
733
+ <input
734
+ value={consoleInput}
735
+ onChange={(e) => setConsoleInput(e.target.value)}
736
+ disabled={running}
737
+ placeholder="act -l | act push -j greet | act -n"
738
+ className="flex-1 bg-transparent text-xs font-mono text-slate-200 placeholder:text-slate-600 outline-none disabled:opacity-50"
739
+ />
740
+ </form>
741
+ </div>
742
+ </div>
743
+ </div>
744
+ </div>
745
+ );
746
+ }