create-interview-cockpit 0.17.3 → 0.19.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 +3 -0
- package/template/client/src/api.ts +184 -8
- package/template/client/src/components/GhaHistoryPanel.tsx +194 -0
- package/template/client/src/components/GhaJobsPanel.tsx +432 -0
- package/template/client/src/components/GithubActionsLabModal.tsx +1048 -0
- package/template/client/src/components/InfraLabModal.tsx +993 -262
- package/template/client/src/components/LabsPanel.tsx +71 -5
- package/template/client/src/components/Sidebar.tsx +603 -60
- package/template/client/src/components/WorkspaceSwitcher.tsx +4 -0
- package/template/client/src/enterpriseLocalLab.ts +921 -0
- package/template/client/src/githubActionsLab.ts +294 -0
- package/template/client/src/infraLab.ts +378 -6
- package/template/client/src/reactLab.ts +409 -0
- package/template/client/src/store.ts +130 -10
- package/template/client/src/types.ts +33 -3
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/cockpit.json +1 -1
- package/template/server/src/gha-runner.ts +793 -0
- package/template/server/src/google-drive.ts +542 -149
- package/template/server/src/index.ts +327 -10
- package/template/server/src/infra-runner.ts +321 -30
- package/template/server/src/storage.ts +3 -1
|
@@ -0,0 +1,1048 @@
|
|
|
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
|
+
PanelLeftClose,
|
|
11
|
+
PanelLeftOpen,
|
|
12
|
+
PanelRightClose,
|
|
13
|
+
PanelRightOpen,
|
|
14
|
+
Play,
|
|
15
|
+
Save,
|
|
16
|
+
StopCircle,
|
|
17
|
+
Terminal,
|
|
18
|
+
Trash2,
|
|
19
|
+
X,
|
|
20
|
+
} from "lucide-react";
|
|
21
|
+
import MonacoEditorLib from "@monaco-editor/react";
|
|
22
|
+
import type { BeforeMount, Monaco, OnChange } from "@monaco-editor/react";
|
|
23
|
+
import { useStore } from "../store";
|
|
24
|
+
import {
|
|
25
|
+
cloneGhaLabWorkspace,
|
|
26
|
+
DEFAULT_GHA_LAB,
|
|
27
|
+
getGhaLabFileOrder,
|
|
28
|
+
listWorkflowPaths,
|
|
29
|
+
serializeGhaLabWorkspace,
|
|
30
|
+
} from "../githubActionsLab";
|
|
31
|
+
import type { GithubActionsLabWorkspace } from "../types";
|
|
32
|
+
import * as api from "../api";
|
|
33
|
+
import type { GhaJobSnapshot, GhaStreamMessage } from "../api";
|
|
34
|
+
import GhaJobsPanel from "./GhaJobsPanel";
|
|
35
|
+
import GhaHistoryPanel from "./GhaHistoryPanel";
|
|
36
|
+
|
|
37
|
+
// ─── Modal layout constants ──────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
const MIN_W = 900;
|
|
40
|
+
const MIN_H = 560;
|
|
41
|
+
const DEFAULT_W = Math.min(1280, window.innerWidth - 48);
|
|
42
|
+
const DEFAULT_H = Math.min(820, window.innerHeight - 48);
|
|
43
|
+
const EDITOR_FONT =
|
|
44
|
+
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace';
|
|
45
|
+
type ResizeDir = "e" | "s" | "se" | "sw" | "w" | "ne" | "nw" | "n";
|
|
46
|
+
|
|
47
|
+
const EVENTS = [
|
|
48
|
+
"push",
|
|
49
|
+
"pull_request",
|
|
50
|
+
"workflow_dispatch",
|
|
51
|
+
"release",
|
|
52
|
+
"schedule",
|
|
53
|
+
] as const;
|
|
54
|
+
|
|
55
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
function baseName(filePath: string): string {
|
|
58
|
+
return filePath.split("/").pop() || filePath;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getEditorLanguage(filePath: string): string {
|
|
62
|
+
const lower = filePath.toLowerCase();
|
|
63
|
+
if (lower.endsWith(".yml") || lower.endsWith(".yaml")) return "yaml";
|
|
64
|
+
if (lower.endsWith(".json")) return "json";
|
|
65
|
+
if (lower.endsWith(".md") || lower.endsWith(".markdown")) return "markdown";
|
|
66
|
+
if (lower.endsWith(".js") || lower.endsWith(".mjs")) return "javascript";
|
|
67
|
+
if (lower.endsWith(".ts") || lower.endsWith(".tsx")) return "typescript";
|
|
68
|
+
if (lower.endsWith(".sh") || lower.endsWith(".bash")) return "shell";
|
|
69
|
+
if (baseName(lower) === "dockerfile") return "dockerfile";
|
|
70
|
+
return "plaintext";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Tiny grouped-by-folder list to keep the modal lean.
|
|
74
|
+
function groupByFolder(paths: string[]): { folder: string; files: string[] }[] {
|
|
75
|
+
const map = new Map<string, string[]>();
|
|
76
|
+
for (const p of paths) {
|
|
77
|
+
const idx = p.lastIndexOf("/");
|
|
78
|
+
const folder = idx === -1 ? "" : p.slice(0, idx);
|
|
79
|
+
if (!map.has(folder)) map.set(folder, []);
|
|
80
|
+
map.get(folder)!.push(p);
|
|
81
|
+
}
|
|
82
|
+
return Array.from(map.entries())
|
|
83
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
84
|
+
.map(([folder, files]) => ({ folder, files: files.sort() }));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ─── Component ───────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
interface ConsoleLine {
|
|
90
|
+
id: string;
|
|
91
|
+
kind: "stdout" | "stderr" | "info" | "input";
|
|
92
|
+
text: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export default function GithubActionsLabModal() {
|
|
96
|
+
const {
|
|
97
|
+
closeGhaLab,
|
|
98
|
+
currentQuestion,
|
|
99
|
+
runnerInitialGha,
|
|
100
|
+
runnerInitialGhaFileId,
|
|
101
|
+
saveCodeSnippetToQuestion,
|
|
102
|
+
overwriteContextFileContent,
|
|
103
|
+
} = useStore();
|
|
104
|
+
|
|
105
|
+
// ── Workspace state ────────────────────────────────────────────────
|
|
106
|
+
const [workspace, setWorkspace] = useState<GithubActionsLabWorkspace>(() =>
|
|
107
|
+
cloneGhaLabWorkspace(runnerInitialGha ?? DEFAULT_GHA_LAB),
|
|
108
|
+
);
|
|
109
|
+
const [labName, setLabName] = useState(
|
|
110
|
+
runnerInitialGha?.label ?? DEFAULT_GHA_LAB.label,
|
|
111
|
+
);
|
|
112
|
+
const [activeFile, setActiveFile] = useState(
|
|
113
|
+
runnerInitialGha?.activeFile ?? DEFAULT_GHA_LAB.activeFile,
|
|
114
|
+
);
|
|
115
|
+
const [activeGhaId, setActiveGhaId] = useState<string | null>(
|
|
116
|
+
runnerInitialGhaFileId ?? null,
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
const next = cloneGhaLabWorkspace(runnerInitialGha ?? DEFAULT_GHA_LAB);
|
|
121
|
+
setWorkspace(next);
|
|
122
|
+
setLabName(next.label);
|
|
123
|
+
setActiveFile(next.activeFile);
|
|
124
|
+
setActiveGhaId(runnerInitialGhaFileId ?? null);
|
|
125
|
+
}, [runnerInitialGha, runnerInitialGhaFileId]);
|
|
126
|
+
|
|
127
|
+
// ── Save state ─────────────────────────────────────────────────────
|
|
128
|
+
const [saving, setSaving] = useState(false);
|
|
129
|
+
const [saved, setSaved] = useState(false);
|
|
130
|
+
|
|
131
|
+
// ── Run state ──────────────────────────────────────────────────────
|
|
132
|
+
const workflowPaths = useMemo(
|
|
133
|
+
() => listWorkflowPaths(workspace),
|
|
134
|
+
[workspace],
|
|
135
|
+
);
|
|
136
|
+
const [event, setEvent] = useState<string>(workspace.defaultEvent ?? "push");
|
|
137
|
+
const [workflow, setWorkflow] = useState<string>(
|
|
138
|
+
workspace.defaultWorkflow ?? workflowPaths[0] ?? ".github/workflows/ci.yml",
|
|
139
|
+
);
|
|
140
|
+
const [jobFilter, setJobFilter] = useState<string>("");
|
|
141
|
+
const [dryRun, setDryRun] = useState(false);
|
|
142
|
+
const [running, setRunning] = useState(false);
|
|
143
|
+
const [consoleLines, setConsoleLines] = useState<ConsoleLine[]>([]);
|
|
144
|
+
const [runError, setRunError] = useState<string | null>(null);
|
|
145
|
+
// Live job snapshots reported by the server during the active run.
|
|
146
|
+
// Reset every time the user kicks off a new run.
|
|
147
|
+
const [liveJobs, setLiveJobs] = useState<GhaJobSnapshot[]>([]);
|
|
148
|
+
// "console" | "jobs" | "history" — controls the right pane tab.
|
|
149
|
+
const [rightTab, setRightTab] = useState<"console" | "jobs" | "history">(
|
|
150
|
+
"console",
|
|
151
|
+
);
|
|
152
|
+
// Bumped each time a run completes so the History tab refetches.
|
|
153
|
+
const [historyNonce, setHistoryNonce] = useState(0);
|
|
154
|
+
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
|
155
|
+
const [rightCollapsed, setRightCollapsed] = useState(false);
|
|
156
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
157
|
+
const consoleEndRef = useRef<HTMLDivElement | null>(null);
|
|
158
|
+
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
// Keep workflow selection valid when files change
|
|
161
|
+
if (!workflowPaths.includes(workflow)) {
|
|
162
|
+
setWorkflow(workflowPaths[0] ?? "");
|
|
163
|
+
}
|
|
164
|
+
}, [workflowPaths, workflow]);
|
|
165
|
+
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
consoleEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
168
|
+
}, [consoleLines.length]);
|
|
169
|
+
|
|
170
|
+
// ── Drag / resize / maximize ───────────────────────────────────────
|
|
171
|
+
const [pos, setPos] = useState(() => ({
|
|
172
|
+
x: Math.max(0, (window.innerWidth - DEFAULT_W) / 2),
|
|
173
|
+
y: Math.max(0, (window.innerHeight - DEFAULT_H) / 2),
|
|
174
|
+
}));
|
|
175
|
+
const [size, setSize] = useState({ w: DEFAULT_W, h: DEFAULT_H });
|
|
176
|
+
const [maximized, setMaximized] = useState(false);
|
|
177
|
+
const dragStart = useRef<{
|
|
178
|
+
mx: number;
|
|
179
|
+
my: number;
|
|
180
|
+
ox: number;
|
|
181
|
+
oy: number;
|
|
182
|
+
} | null>(null);
|
|
183
|
+
const resizeStart = useRef<{
|
|
184
|
+
mx: number;
|
|
185
|
+
my: number;
|
|
186
|
+
ox: number;
|
|
187
|
+
oy: number;
|
|
188
|
+
ow: number;
|
|
189
|
+
oh: number;
|
|
190
|
+
} | null>(null);
|
|
191
|
+
|
|
192
|
+
const onTitleMouseDown = useCallback(
|
|
193
|
+
(e: React.MouseEvent) => {
|
|
194
|
+
if (maximized) return;
|
|
195
|
+
dragStart.current = {
|
|
196
|
+
mx: e.clientX,
|
|
197
|
+
my: e.clientY,
|
|
198
|
+
ox: pos.x,
|
|
199
|
+
oy: pos.y,
|
|
200
|
+
};
|
|
201
|
+
const onMove = (ev: MouseEvent) => {
|
|
202
|
+
if (!dragStart.current) return;
|
|
203
|
+
setPos({
|
|
204
|
+
x: Math.max(
|
|
205
|
+
0,
|
|
206
|
+
dragStart.current.ox + (ev.clientX - dragStart.current.mx),
|
|
207
|
+
),
|
|
208
|
+
y: Math.max(
|
|
209
|
+
0,
|
|
210
|
+
dragStart.current.oy + (ev.clientY - dragStart.current.my),
|
|
211
|
+
),
|
|
212
|
+
});
|
|
213
|
+
};
|
|
214
|
+
const onUp = () => {
|
|
215
|
+
dragStart.current = null;
|
|
216
|
+
window.removeEventListener("mousemove", onMove);
|
|
217
|
+
window.removeEventListener("mouseup", onUp);
|
|
218
|
+
};
|
|
219
|
+
window.addEventListener("mousemove", onMove);
|
|
220
|
+
window.addEventListener("mouseup", onUp);
|
|
221
|
+
},
|
|
222
|
+
[pos.x, pos.y, maximized],
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
const startResize = useCallback(
|
|
226
|
+
(dir: ResizeDir) => (e: React.MouseEvent) => {
|
|
227
|
+
if (maximized) return;
|
|
228
|
+
e.preventDefault();
|
|
229
|
+
e.stopPropagation();
|
|
230
|
+
resizeStart.current = {
|
|
231
|
+
mx: e.clientX,
|
|
232
|
+
my: e.clientY,
|
|
233
|
+
ox: pos.x,
|
|
234
|
+
oy: pos.y,
|
|
235
|
+
ow: size.w,
|
|
236
|
+
oh: size.h,
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const originalCursor = document.body.style.cursor;
|
|
240
|
+
const originalUserSelect = document.body.style.userSelect;
|
|
241
|
+
document.body.style.cursor = `${dir}-resize`;
|
|
242
|
+
document.body.style.userSelect = "none";
|
|
243
|
+
|
|
244
|
+
const onMove = (ev: MouseEvent) => {
|
|
245
|
+
const resize = resizeStart.current;
|
|
246
|
+
if (!resize) return;
|
|
247
|
+
const dx = ev.clientX - resize.mx;
|
|
248
|
+
const dy = ev.clientY - resize.my;
|
|
249
|
+
let w = resize.ow;
|
|
250
|
+
let h = resize.oh;
|
|
251
|
+
let x = resize.ox;
|
|
252
|
+
let y = resize.oy;
|
|
253
|
+
|
|
254
|
+
if (dir.includes("e")) w = Math.max(MIN_W, resize.ow + dx);
|
|
255
|
+
if (dir.includes("s")) h = Math.max(MIN_H, resize.oh + dy);
|
|
256
|
+
if (dir.includes("w")) {
|
|
257
|
+
w = Math.max(MIN_W, resize.ow - dx);
|
|
258
|
+
x = Math.min(resize.ox + resize.ow - MIN_W, resize.ox + dx);
|
|
259
|
+
}
|
|
260
|
+
if (dir.includes("n")) {
|
|
261
|
+
h = Math.max(MIN_H, resize.oh - dy);
|
|
262
|
+
y = Math.min(resize.oy + resize.oh - MIN_H, resize.oy + dy);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
setSize({ w, h });
|
|
266
|
+
setPos({ x: Math.max(0, x), y: Math.max(0, y) });
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const onUp = () => {
|
|
270
|
+
resizeStart.current = null;
|
|
271
|
+
document.body.style.cursor = originalCursor;
|
|
272
|
+
document.body.style.userSelect = originalUserSelect;
|
|
273
|
+
window.removeEventListener("mousemove", onMove);
|
|
274
|
+
window.removeEventListener("mouseup", onUp);
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
window.addEventListener("mousemove", onMove);
|
|
278
|
+
window.addEventListener("mouseup", onUp);
|
|
279
|
+
},
|
|
280
|
+
[maximized, pos.x, pos.y, size.w, size.h],
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
// ── File operations ───────────────────────────────────────────────
|
|
284
|
+
const fileOrder = useMemo(() => getGhaLabFileOrder(workspace), [workspace]);
|
|
285
|
+
const grouped = useMemo(() => groupByFolder(fileOrder), [fileOrder]);
|
|
286
|
+
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(
|
|
287
|
+
() => new Set(),
|
|
288
|
+
);
|
|
289
|
+
const toggleFolder = (folder: string) => {
|
|
290
|
+
setCollapsedFolders((prev) => {
|
|
291
|
+
const next = new Set(prev);
|
|
292
|
+
if (next.has(folder)) next.delete(folder);
|
|
293
|
+
else next.add(folder);
|
|
294
|
+
return next;
|
|
295
|
+
});
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const updateFile = (fileName: string, content: string) => {
|
|
299
|
+
setWorkspace((prev) => ({
|
|
300
|
+
...prev,
|
|
301
|
+
activeFile: fileName,
|
|
302
|
+
files: { ...prev.files, [fileName]: content },
|
|
303
|
+
}));
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const addFile = () => {
|
|
307
|
+
const name = window.prompt(
|
|
308
|
+
"New file path (e.g. .github/workflows/release.yml)",
|
|
309
|
+
".github/workflows/new.yml",
|
|
310
|
+
);
|
|
311
|
+
if (!name) return;
|
|
312
|
+
const trimmed = name.trim();
|
|
313
|
+
if (!trimmed || workspace.files[trimmed]) return;
|
|
314
|
+
setWorkspace((prev) => ({
|
|
315
|
+
...prev,
|
|
316
|
+
activeFile: trimmed,
|
|
317
|
+
files: { ...prev.files, [trimmed]: "" },
|
|
318
|
+
}));
|
|
319
|
+
setActiveFile(trimmed);
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const deleteFile = (fileName: string) => {
|
|
323
|
+
if (!window.confirm(`Delete ${fileName}?`)) return;
|
|
324
|
+
setWorkspace((prev) => {
|
|
325
|
+
const { [fileName]: _, ...rest } = prev.files;
|
|
326
|
+
const nextActive =
|
|
327
|
+
prev.activeFile === fileName
|
|
328
|
+
? (Object.keys(rest)[0] ?? "")
|
|
329
|
+
: prev.activeFile;
|
|
330
|
+
return { ...prev, activeFile: nextActive, files: rest };
|
|
331
|
+
});
|
|
332
|
+
if (activeFile === fileName) {
|
|
333
|
+
const remaining = Object.keys(workspace.files).filter(
|
|
334
|
+
(f) => f !== fileName,
|
|
335
|
+
);
|
|
336
|
+
setActiveFile(remaining[0] ?? "");
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
// ── Save lab as context file ──────────────────────────────────────
|
|
341
|
+
const handleSave = useCallback(async () => {
|
|
342
|
+
if (!currentQuestion) return;
|
|
343
|
+
setSaving(true);
|
|
344
|
+
try {
|
|
345
|
+
// When the user opted in, embed a compact summary of the most recent
|
|
346
|
+
// runs into the saved JSON so the chat LLM has real execution data
|
|
347
|
+
// to reason about (job statuses, durations, exit codes).
|
|
348
|
+
let recentRunsExtras: Record<string, unknown> = {};
|
|
349
|
+
if (workspace.includeRunHistoryInContext) {
|
|
350
|
+
try {
|
|
351
|
+
const runs = await api.listGhaRuns({
|
|
352
|
+
questionId: currentQuestion.id,
|
|
353
|
+
...(activeGhaId ? { fileId: activeGhaId } : {}),
|
|
354
|
+
limit: 5,
|
|
355
|
+
});
|
|
356
|
+
recentRunsExtras = {
|
|
357
|
+
recentRuns: runs.map((r) => ({
|
|
358
|
+
command: r.command,
|
|
359
|
+
status: r.status,
|
|
360
|
+
exitCode: r.exitCode,
|
|
361
|
+
startedAt: r.startedAt,
|
|
362
|
+
durationMs: r.durationMs,
|
|
363
|
+
jobs: (r.jobs ?? []).map((j) => ({
|
|
364
|
+
name: j.name,
|
|
365
|
+
status: j.status,
|
|
366
|
+
durationMs: j.durationMs,
|
|
367
|
+
})),
|
|
368
|
+
})),
|
|
369
|
+
};
|
|
370
|
+
} catch {
|
|
371
|
+
// Non-fatal — just skip the appendix if the list call fails.
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
const serialized = serializeGhaLabWorkspace({
|
|
375
|
+
...workspace,
|
|
376
|
+
label: labName || workspace.label,
|
|
377
|
+
activeFile,
|
|
378
|
+
defaultEvent: event,
|
|
379
|
+
defaultWorkflow: workflow,
|
|
380
|
+
});
|
|
381
|
+
// Splice the extras into the serialised JSON without breaking the
|
|
382
|
+
// existing schema parsers tolerate.
|
|
383
|
+
const payload = Object.keys(recentRunsExtras).length
|
|
384
|
+
? JSON.stringify(
|
|
385
|
+
{ ...JSON.parse(serialized), ...recentRunsExtras },
|
|
386
|
+
null,
|
|
387
|
+
2,
|
|
388
|
+
)
|
|
389
|
+
: serialized;
|
|
390
|
+
if (activeGhaId) {
|
|
391
|
+
await overwriteContextFileContent(
|
|
392
|
+
currentQuestion.id,
|
|
393
|
+
activeGhaId,
|
|
394
|
+
payload,
|
|
395
|
+
);
|
|
396
|
+
} else {
|
|
397
|
+
const cf = await saveCodeSnippetToQuestion(
|
|
398
|
+
currentQuestion.id,
|
|
399
|
+
payload,
|
|
400
|
+
"json",
|
|
401
|
+
labName || workspace.label,
|
|
402
|
+
"github-actions",
|
|
403
|
+
);
|
|
404
|
+
setActiveGhaId(cf.id);
|
|
405
|
+
}
|
|
406
|
+
setSaved(true);
|
|
407
|
+
window.setTimeout(() => setSaved(false), 1500);
|
|
408
|
+
} finally {
|
|
409
|
+
setSaving(false);
|
|
410
|
+
}
|
|
411
|
+
}, [
|
|
412
|
+
currentQuestion,
|
|
413
|
+
workspace,
|
|
414
|
+
labName,
|
|
415
|
+
activeFile,
|
|
416
|
+
event,
|
|
417
|
+
workflow,
|
|
418
|
+
activeGhaId,
|
|
419
|
+
overwriteContextFileContent,
|
|
420
|
+
saveCodeSnippetToQuestion,
|
|
421
|
+
]);
|
|
422
|
+
|
|
423
|
+
// ── Run act ──────────────────────────────────────────────────────
|
|
424
|
+
const buildCommand = useCallback(() => {
|
|
425
|
+
const parts = ["act", event];
|
|
426
|
+
if (workflow) {
|
|
427
|
+
parts.push("-W", workflow);
|
|
428
|
+
}
|
|
429
|
+
if (jobFilter.trim()) {
|
|
430
|
+
parts.push("-j", jobFilter.trim());
|
|
431
|
+
}
|
|
432
|
+
if (dryRun) parts.push("-n");
|
|
433
|
+
return parts.join(" ");
|
|
434
|
+
}, [event, workflow, jobFilter, dryRun]);
|
|
435
|
+
|
|
436
|
+
const appendConsole = useCallback((line: Omit<ConsoleLine, "id">) => {
|
|
437
|
+
setConsoleLines((prev) => [
|
|
438
|
+
...prev,
|
|
439
|
+
{ ...line, id: `${Date.now()}-${Math.random().toString(36).slice(2)}` },
|
|
440
|
+
]);
|
|
441
|
+
}, []);
|
|
442
|
+
|
|
443
|
+
const runCommand = useCallback(
|
|
444
|
+
async (command: string) => {
|
|
445
|
+
setRunning(true);
|
|
446
|
+
setRunError(null);
|
|
447
|
+
// Reset the DAG so the user always sees a fresh "pending → running →
|
|
448
|
+
// success/failed" lifecycle for this invocation. Switch tabs to Jobs
|
|
449
|
+
// so the visualisation is immediately visible.
|
|
450
|
+
setLiveJobs([]);
|
|
451
|
+
setRightTab("jobs");
|
|
452
|
+
appendConsole({ kind: "input", text: `$ ${command}\n` });
|
|
453
|
+
|
|
454
|
+
try {
|
|
455
|
+
await api.streamGhaCommand(
|
|
456
|
+
{
|
|
457
|
+
command,
|
|
458
|
+
workspace: {
|
|
459
|
+
...workspace,
|
|
460
|
+
activeFile,
|
|
461
|
+
label: labName || workspace.label,
|
|
462
|
+
defaultEvent: event,
|
|
463
|
+
defaultWorkflow: workflow,
|
|
464
|
+
},
|
|
465
|
+
...(activeGhaId ? { fileId: activeGhaId } : {}),
|
|
466
|
+
...(currentQuestion ? { questionId: currentQuestion.id } : {}),
|
|
467
|
+
label: labName || workspace.label,
|
|
468
|
+
},
|
|
469
|
+
(message: GhaStreamMessage) => {
|
|
470
|
+
if (message.type === "output") {
|
|
471
|
+
appendConsole({ kind: message.kind, text: message.text });
|
|
472
|
+
} else if (message.type === "job") {
|
|
473
|
+
// Merge by job name so repeated updates (running → success)
|
|
474
|
+
// replace the prior entry rather than appending duplicates.
|
|
475
|
+
setLiveJobs((prev) => {
|
|
476
|
+
const idx = prev.findIndex((j) => j.name === message.job.name);
|
|
477
|
+
if (idx === -1) return [...prev, message.job];
|
|
478
|
+
const next = prev.slice();
|
|
479
|
+
next[idx] = message.job;
|
|
480
|
+
return next;
|
|
481
|
+
});
|
|
482
|
+
} else if (message.type === "error") {
|
|
483
|
+
setRunError(message.error);
|
|
484
|
+
appendConsole({
|
|
485
|
+
kind: "stderr",
|
|
486
|
+
text: `\n[error] ${message.error}\n`,
|
|
487
|
+
});
|
|
488
|
+
} else if (message.type === "complete") {
|
|
489
|
+
appendConsole({
|
|
490
|
+
kind: "info",
|
|
491
|
+
text: `\n[done] exit=${message.exitCode} • ${message.durationMs}ms\n`,
|
|
492
|
+
});
|
|
493
|
+
// Refresh History so the just-completed run shows up.
|
|
494
|
+
setHistoryNonce((n) => n + 1);
|
|
495
|
+
}
|
|
496
|
+
},
|
|
497
|
+
);
|
|
498
|
+
} catch (err: any) {
|
|
499
|
+
const msg = err?.message || "Failed to start run";
|
|
500
|
+
setRunError(msg);
|
|
501
|
+
appendConsole({ kind: "stderr", text: `\n[error] ${msg}\n` });
|
|
502
|
+
} finally {
|
|
503
|
+
setRunning(false);
|
|
504
|
+
}
|
|
505
|
+
},
|
|
506
|
+
[
|
|
507
|
+
workspace,
|
|
508
|
+
activeFile,
|
|
509
|
+
labName,
|
|
510
|
+
event,
|
|
511
|
+
workflow,
|
|
512
|
+
activeGhaId,
|
|
513
|
+
currentQuestion,
|
|
514
|
+
appendConsole,
|
|
515
|
+
],
|
|
516
|
+
);
|
|
517
|
+
|
|
518
|
+
const handleRun = () => runCommand(buildCommand());
|
|
519
|
+
const handleListJobs = () =>
|
|
520
|
+
runCommand(workflow ? `act -W ${workflow} -l` : "act -l");
|
|
521
|
+
|
|
522
|
+
const clearConsole = () => setConsoleLines([]);
|
|
523
|
+
|
|
524
|
+
// ── Console input ─────────────────────────────────────────────────
|
|
525
|
+
const [consoleInput, setConsoleInput] = useState("");
|
|
526
|
+
const handleConsoleSubmit = (e: React.FormEvent) => {
|
|
527
|
+
e.preventDefault();
|
|
528
|
+
const cmd = consoleInput.trim();
|
|
529
|
+
if (!cmd || running) return;
|
|
530
|
+
setConsoleInput("");
|
|
531
|
+
runCommand(cmd);
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
// ── Monaco config ─────────────────────────────────────────────────
|
|
535
|
+
const handleBeforeMount = useCallback<BeforeMount>((monaco: Monaco) => {
|
|
536
|
+
monaco.editor.defineTheme("gha-lab-dark", {
|
|
537
|
+
base: "vs-dark",
|
|
538
|
+
inherit: true,
|
|
539
|
+
rules: [
|
|
540
|
+
{ token: "comment", foreground: "64748b", fontStyle: "italic" },
|
|
541
|
+
{ token: "keyword", foreground: "f59e0b", fontStyle: "bold" },
|
|
542
|
+
{ token: "string", foreground: "86efac" },
|
|
543
|
+
{ token: "type", foreground: "a78bfa" },
|
|
544
|
+
],
|
|
545
|
+
colors: {
|
|
546
|
+
"editor.background": "#0b0f1a",
|
|
547
|
+
"editor.foreground": "#e2e8f0",
|
|
548
|
+
"editorLineNumber.foreground": "#475569",
|
|
549
|
+
"editorLineNumber.activeForeground": "#fbbf24",
|
|
550
|
+
"editorCursor.foreground": "#fbbf24",
|
|
551
|
+
"editor.lineHighlightBackground": "#111827",
|
|
552
|
+
"editor.selectionBackground": "#92400eAA",
|
|
553
|
+
},
|
|
554
|
+
});
|
|
555
|
+
}, []);
|
|
556
|
+
|
|
557
|
+
const handleEditorChange = useCallback<OnChange>(
|
|
558
|
+
(next) => {
|
|
559
|
+
updateFile(activeFile, next ?? "");
|
|
560
|
+
},
|
|
561
|
+
[activeFile],
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
// ── Render ────────────────────────────────────────────────────────
|
|
565
|
+
const containerStyle: React.CSSProperties = maximized
|
|
566
|
+
? { top: 0, left: 0, width: "100vw", height: "100vh" }
|
|
567
|
+
: {
|
|
568
|
+
top: pos.y,
|
|
569
|
+
left: pos.x,
|
|
570
|
+
width: size.w,
|
|
571
|
+
height: size.h,
|
|
572
|
+
minWidth: MIN_W,
|
|
573
|
+
minHeight: MIN_H,
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
return (
|
|
577
|
+
<div className="fixed inset-0 z-40 bg-black/40">
|
|
578
|
+
<div
|
|
579
|
+
className="absolute flex flex-col rounded-2xl border border-slate-800 bg-slate-950 shadow-2xl overflow-hidden"
|
|
580
|
+
style={containerStyle}
|
|
581
|
+
>
|
|
582
|
+
{/* Resize handles */}
|
|
583
|
+
{!maximized && (
|
|
584
|
+
<>
|
|
585
|
+
<div
|
|
586
|
+
className="absolute inset-x-0 top-0 h-1 cursor-n-resize z-20"
|
|
587
|
+
onMouseDown={startResize("n")}
|
|
588
|
+
/>
|
|
589
|
+
<div
|
|
590
|
+
className="absolute inset-x-0 bottom-0 h-1 cursor-s-resize z-20"
|
|
591
|
+
onMouseDown={startResize("s")}
|
|
592
|
+
/>
|
|
593
|
+
<div
|
|
594
|
+
className="absolute inset-y-0 left-0 w-1 cursor-w-resize z-20"
|
|
595
|
+
onMouseDown={startResize("w")}
|
|
596
|
+
/>
|
|
597
|
+
<div
|
|
598
|
+
className="absolute inset-y-0 right-0 w-1 cursor-e-resize z-20"
|
|
599
|
+
onMouseDown={startResize("e")}
|
|
600
|
+
/>
|
|
601
|
+
<div
|
|
602
|
+
className="absolute top-0 left-0 w-3 h-3 cursor-nw-resize z-30"
|
|
603
|
+
onMouseDown={startResize("nw")}
|
|
604
|
+
/>
|
|
605
|
+
<div
|
|
606
|
+
className="absolute top-0 right-0 w-3 h-3 cursor-ne-resize z-30"
|
|
607
|
+
onMouseDown={startResize("ne")}
|
|
608
|
+
/>
|
|
609
|
+
<div
|
|
610
|
+
className="absolute bottom-0 left-0 w-3 h-3 cursor-sw-resize z-30"
|
|
611
|
+
onMouseDown={startResize("sw")}
|
|
612
|
+
/>
|
|
613
|
+
<div
|
|
614
|
+
className="absolute bottom-0 right-0 w-3 h-3 cursor-se-resize z-30"
|
|
615
|
+
onMouseDown={startResize("se")}
|
|
616
|
+
/>
|
|
617
|
+
</>
|
|
618
|
+
)}
|
|
619
|
+
|
|
620
|
+
{/* Title bar */}
|
|
621
|
+
<div
|
|
622
|
+
onMouseDown={onTitleMouseDown}
|
|
623
|
+
className="flex items-center gap-2 border-b border-slate-800 bg-slate-900/80 px-4 py-2 cursor-move select-none"
|
|
624
|
+
>
|
|
625
|
+
<div className="flex items-center gap-2">
|
|
626
|
+
<span className="inline-block h-2 w-2 rounded-full bg-amber-400" />
|
|
627
|
+
<span className="text-xs font-semibold tracking-widest text-amber-300">
|
|
628
|
+
GITHUB ACTIONS LAB
|
|
629
|
+
</span>
|
|
630
|
+
</div>
|
|
631
|
+
<input
|
|
632
|
+
value={labName}
|
|
633
|
+
onChange={(e) => setLabName(e.target.value)}
|
|
634
|
+
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"
|
|
635
|
+
/>
|
|
636
|
+
<button
|
|
637
|
+
onClick={() => setMaximized((v) => !v)}
|
|
638
|
+
className="p-1.5 rounded text-slate-400 hover:text-amber-300 hover:bg-slate-800/60"
|
|
639
|
+
title={maximized ? "Restore" : "Maximize"}
|
|
640
|
+
>
|
|
641
|
+
{maximized ? (
|
|
642
|
+
<Minimize2 className="w-4 h-4" />
|
|
643
|
+
) : (
|
|
644
|
+
<Maximize2 className="w-4 h-4" />
|
|
645
|
+
)}
|
|
646
|
+
</button>
|
|
647
|
+
<button
|
|
648
|
+
onClick={closeGhaLab}
|
|
649
|
+
className="p-1.5 rounded text-slate-400 hover:text-red-300 hover:bg-slate-800/60"
|
|
650
|
+
title="Close"
|
|
651
|
+
>
|
|
652
|
+
<X className="w-4 h-4" />
|
|
653
|
+
</button>
|
|
654
|
+
</div>
|
|
655
|
+
|
|
656
|
+
{/* Toolbar */}
|
|
657
|
+
<div className="flex flex-wrap items-center gap-2 border-b border-slate-800 bg-slate-900/40 px-3 py-2 text-xs">
|
|
658
|
+
<label className="text-slate-400">Event</label>
|
|
659
|
+
<select
|
|
660
|
+
value={event}
|
|
661
|
+
onChange={(e) => setEvent(e.target.value)}
|
|
662
|
+
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-slate-200"
|
|
663
|
+
>
|
|
664
|
+
{EVENTS.map((ev) => (
|
|
665
|
+
<option key={ev} value={ev}>
|
|
666
|
+
{ev}
|
|
667
|
+
</option>
|
|
668
|
+
))}
|
|
669
|
+
</select>
|
|
670
|
+
|
|
671
|
+
<label className="ml-2 text-slate-400">Workflow</label>
|
|
672
|
+
<select
|
|
673
|
+
value={workflow}
|
|
674
|
+
onChange={(e) => setWorkflow(e.target.value)}
|
|
675
|
+
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-slate-200"
|
|
676
|
+
>
|
|
677
|
+
{workflowPaths.length === 0 && (
|
|
678
|
+
<option value="">No workflows</option>
|
|
679
|
+
)}
|
|
680
|
+
{workflowPaths.map((wf) => (
|
|
681
|
+
<option key={wf} value={wf}>
|
|
682
|
+
{wf}
|
|
683
|
+
</option>
|
|
684
|
+
))}
|
|
685
|
+
</select>
|
|
686
|
+
|
|
687
|
+
<label className="ml-2 text-slate-400">Job</label>
|
|
688
|
+
<input
|
|
689
|
+
value={jobFilter}
|
|
690
|
+
onChange={(e) => setJobFilter(e.target.value)}
|
|
691
|
+
placeholder="(all)"
|
|
692
|
+
className="w-24 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-slate-200 placeholder:text-slate-600"
|
|
693
|
+
/>
|
|
694
|
+
|
|
695
|
+
<label className="ml-2 inline-flex items-center gap-1 text-slate-400">
|
|
696
|
+
<input
|
|
697
|
+
type="checkbox"
|
|
698
|
+
checked={dryRun}
|
|
699
|
+
onChange={(e) => setDryRun(e.target.checked)}
|
|
700
|
+
className="accent-amber-400"
|
|
701
|
+
/>
|
|
702
|
+
Dry run
|
|
703
|
+
</label>
|
|
704
|
+
|
|
705
|
+
<div className="ml-auto flex items-center gap-2">
|
|
706
|
+
<button
|
|
707
|
+
onClick={handleListJobs}
|
|
708
|
+
disabled={running}
|
|
709
|
+
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"
|
|
710
|
+
title="List jobs in the selected workflow"
|
|
711
|
+
>
|
|
712
|
+
List jobs
|
|
713
|
+
</button>
|
|
714
|
+
<button
|
|
715
|
+
onClick={handleRun}
|
|
716
|
+
disabled={running || workflowPaths.length === 0}
|
|
717
|
+
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"
|
|
718
|
+
>
|
|
719
|
+
{running ? (
|
|
720
|
+
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
721
|
+
) : (
|
|
722
|
+
<Play className="w-3.5 h-3.5" />
|
|
723
|
+
)}
|
|
724
|
+
Run
|
|
725
|
+
</button>
|
|
726
|
+
{currentQuestion && (
|
|
727
|
+
<button
|
|
728
|
+
onClick={handleSave}
|
|
729
|
+
disabled={saving}
|
|
730
|
+
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"
|
|
731
|
+
title={activeGhaId ? "Update lab snapshot" : "Save as lab"}
|
|
732
|
+
>
|
|
733
|
+
{saving ? (
|
|
734
|
+
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
735
|
+
) : (
|
|
736
|
+
<Save className="w-3.5 h-3.5" />
|
|
737
|
+
)}
|
|
738
|
+
{saved ? "Saved" : activeGhaId ? "Update" : "Save"}
|
|
739
|
+
</button>
|
|
740
|
+
)}
|
|
741
|
+
</div>
|
|
742
|
+
</div>
|
|
743
|
+
|
|
744
|
+
{/* Main body */}
|
|
745
|
+
<div
|
|
746
|
+
className="flex-1 min-h-0 grid"
|
|
747
|
+
style={{
|
|
748
|
+
gridTemplateColumns: [
|
|
749
|
+
leftCollapsed ? "0px" : "220px",
|
|
750
|
+
"minmax(0,1fr)",
|
|
751
|
+
rightCollapsed ? "0px" : "minmax(320px,1fr)",
|
|
752
|
+
].join(" "),
|
|
753
|
+
}}
|
|
754
|
+
>
|
|
755
|
+
{/* File tree */}
|
|
756
|
+
<div
|
|
757
|
+
className="flex flex-col border-r border-slate-800 bg-slate-900/30 min-h-0 overflow-hidden"
|
|
758
|
+
style={{
|
|
759
|
+
gridColumn: 1,
|
|
760
|
+
visibility: leftCollapsed ? "hidden" : undefined,
|
|
761
|
+
borderRightWidth: leftCollapsed ? 0 : undefined,
|
|
762
|
+
}}
|
|
763
|
+
>
|
|
764
|
+
<div className="flex items-center justify-between px-2 py-1.5 border-b border-slate-800/60">
|
|
765
|
+
<span className="text-[10px] font-semibold tracking-widest text-slate-500">
|
|
766
|
+
FILES
|
|
767
|
+
</span>
|
|
768
|
+
<button
|
|
769
|
+
onClick={addFile}
|
|
770
|
+
className="p-1 rounded text-slate-400 hover:text-amber-300 hover:bg-slate-800/60"
|
|
771
|
+
title="Add file"
|
|
772
|
+
>
|
|
773
|
+
<FilePlus className="w-3.5 h-3.5" />
|
|
774
|
+
</button>
|
|
775
|
+
</div>
|
|
776
|
+
<div className="flex-1 overflow-auto p-1 text-xs">
|
|
777
|
+
{grouped.map(({ folder, files }) => {
|
|
778
|
+
const collapsed = collapsedFolders.has(folder);
|
|
779
|
+
return (
|
|
780
|
+
<div key={folder || "root"} className="mb-1">
|
|
781
|
+
{folder && (
|
|
782
|
+
<button
|
|
783
|
+
onClick={() => toggleFolder(folder)}
|
|
784
|
+
className="flex items-center gap-1 w-full px-1 py-0.5 text-slate-400 hover:text-slate-200"
|
|
785
|
+
>
|
|
786
|
+
{collapsed ? (
|
|
787
|
+
<ChevronRight className="w-3 h-3" />
|
|
788
|
+
) : (
|
|
789
|
+
<ChevronDown className="w-3 h-3" />
|
|
790
|
+
)}
|
|
791
|
+
<Folder className="w-3 h-3" />
|
|
792
|
+
<span className="truncate">{folder}/</span>
|
|
793
|
+
</button>
|
|
794
|
+
)}
|
|
795
|
+
{!collapsed &&
|
|
796
|
+
files.map((filePath) => (
|
|
797
|
+
<div
|
|
798
|
+
key={filePath}
|
|
799
|
+
className={`group flex items-center gap-1 pl-${folder ? 5 : 1} pr-1 py-0.5 rounded cursor-pointer ${
|
|
800
|
+
activeFile === filePath
|
|
801
|
+
? "bg-amber-500/15 text-amber-200"
|
|
802
|
+
: "text-slate-300 hover:bg-slate-800/40"
|
|
803
|
+
}`}
|
|
804
|
+
onClick={() => setActiveFile(filePath)}
|
|
805
|
+
style={{ paddingLeft: folder ? 20 : 6 }}
|
|
806
|
+
>
|
|
807
|
+
<span className="truncate flex-1">
|
|
808
|
+
{baseName(filePath)}
|
|
809
|
+
</span>
|
|
810
|
+
<button
|
|
811
|
+
onClick={(e) => {
|
|
812
|
+
e.stopPropagation();
|
|
813
|
+
deleteFile(filePath);
|
|
814
|
+
}}
|
|
815
|
+
className="opacity-0 group-hover:opacity-100 p-0.5 text-slate-500 hover:text-red-400"
|
|
816
|
+
title="Delete"
|
|
817
|
+
>
|
|
818
|
+
<Trash2 className="w-3 h-3" />
|
|
819
|
+
</button>
|
|
820
|
+
</div>
|
|
821
|
+
))}
|
|
822
|
+
</div>
|
|
823
|
+
);
|
|
824
|
+
})}
|
|
825
|
+
</div>
|
|
826
|
+
</div>
|
|
827
|
+
|
|
828
|
+
{/* Editor */}
|
|
829
|
+
<div
|
|
830
|
+
className="min-h-0 flex flex-col border-r border-slate-800"
|
|
831
|
+
style={{ gridColumn: 2 }}
|
|
832
|
+
>
|
|
833
|
+
<div className="flex items-center justify-between gap-2 px-2 py-1.5 border-b border-slate-800/60 text-[11px] text-slate-400">
|
|
834
|
+
<div className="flex items-center gap-1 min-w-0">
|
|
835
|
+
<button
|
|
836
|
+
onClick={() => setLeftCollapsed((v) => !v)}
|
|
837
|
+
className="p-1 rounded text-slate-500 hover:text-amber-300 hover:bg-slate-800/60 shrink-0"
|
|
838
|
+
title={
|
|
839
|
+
leftCollapsed ? "Show files panel" : "Hide files panel"
|
|
840
|
+
}
|
|
841
|
+
>
|
|
842
|
+
{leftCollapsed ? (
|
|
843
|
+
<PanelLeftOpen className="w-3.5 h-3.5" />
|
|
844
|
+
) : (
|
|
845
|
+
<PanelLeftClose className="w-3.5 h-3.5" />
|
|
846
|
+
)}
|
|
847
|
+
</button>
|
|
848
|
+
<span className="truncate">
|
|
849
|
+
{activeFile || "(no file selected)"}
|
|
850
|
+
</span>
|
|
851
|
+
</div>
|
|
852
|
+
<button
|
|
853
|
+
onClick={() => setRightCollapsed((v) => !v)}
|
|
854
|
+
className="p-1 rounded text-slate-500 hover:text-amber-300 hover:bg-slate-800/60 shrink-0"
|
|
855
|
+
title={
|
|
856
|
+
rightCollapsed
|
|
857
|
+
? "Show console/jobs panel"
|
|
858
|
+
: "Hide console/jobs panel"
|
|
859
|
+
}
|
|
860
|
+
>
|
|
861
|
+
{rightCollapsed ? (
|
|
862
|
+
<PanelRightOpen className="w-3.5 h-3.5" />
|
|
863
|
+
) : (
|
|
864
|
+
<PanelRightClose className="w-3.5 h-3.5" />
|
|
865
|
+
)}
|
|
866
|
+
</button>
|
|
867
|
+
</div>
|
|
868
|
+
<div className="flex-1 min-h-0">
|
|
869
|
+
{activeFile && workspace.files[activeFile] !== undefined && (
|
|
870
|
+
<MonacoEditorLib
|
|
871
|
+
key={activeFile}
|
|
872
|
+
height="100%"
|
|
873
|
+
width="100%"
|
|
874
|
+
language={getEditorLanguage(activeFile)}
|
|
875
|
+
theme="gha-lab-dark"
|
|
876
|
+
path={`file:///gha-lab/${activeFile}`}
|
|
877
|
+
value={workspace.files[activeFile]}
|
|
878
|
+
beforeMount={handleBeforeMount}
|
|
879
|
+
onChange={handleEditorChange}
|
|
880
|
+
options={{
|
|
881
|
+
fontFamily: EDITOR_FONT,
|
|
882
|
+
fontSize: 13,
|
|
883
|
+
lineHeight: 22,
|
|
884
|
+
minimap: { enabled: false },
|
|
885
|
+
automaticLayout: true,
|
|
886
|
+
scrollBeyondLastLine: false,
|
|
887
|
+
wordWrap: "off",
|
|
888
|
+
tabSize: 2,
|
|
889
|
+
insertSpaces: true,
|
|
890
|
+
}}
|
|
891
|
+
/>
|
|
892
|
+
)}
|
|
893
|
+
</div>
|
|
894
|
+
</div>
|
|
895
|
+
|
|
896
|
+
{/* Right pane: tabbed Console / Jobs / History */}
|
|
897
|
+
<div
|
|
898
|
+
className="min-h-0 flex flex-col bg-slate-950 overflow-hidden"
|
|
899
|
+
style={{
|
|
900
|
+
gridColumn: 3,
|
|
901
|
+
visibility: rightCollapsed ? "hidden" : undefined,
|
|
902
|
+
}}
|
|
903
|
+
>
|
|
904
|
+
<div className="flex items-center justify-between border-b border-slate-800/60 px-2 py-1">
|
|
905
|
+
<div className="flex items-center gap-0.5 text-[11px]">
|
|
906
|
+
{(
|
|
907
|
+
[
|
|
908
|
+
{ id: "console", label: "Console" },
|
|
909
|
+
{ id: "jobs", label: "Jobs" },
|
|
910
|
+
{ id: "history", label: "History" },
|
|
911
|
+
] as const
|
|
912
|
+
).map((t) => (
|
|
913
|
+
<button
|
|
914
|
+
key={t.id}
|
|
915
|
+
onClick={() => setRightTab(t.id)}
|
|
916
|
+
className={`px-2 py-1 rounded ${
|
|
917
|
+
rightTab === t.id
|
|
918
|
+
? "bg-slate-800/70 text-amber-200"
|
|
919
|
+
: "text-slate-400 hover:text-slate-200 hover:bg-slate-800/40"
|
|
920
|
+
}`}
|
|
921
|
+
>
|
|
922
|
+
{t.id === "console" && (
|
|
923
|
+
<Terminal className="inline w-3 h-3 mr-1 -mt-px" />
|
|
924
|
+
)}
|
|
925
|
+
{t.label}
|
|
926
|
+
{t.id === "jobs" && running && (
|
|
927
|
+
<span className="ml-1 inline-block w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse" />
|
|
928
|
+
)}
|
|
929
|
+
</button>
|
|
930
|
+
))}
|
|
931
|
+
</div>
|
|
932
|
+
<div className="flex items-center gap-1">
|
|
933
|
+
{running && (
|
|
934
|
+
<button
|
|
935
|
+
onClick={() => abortRef.current?.abort()}
|
|
936
|
+
className="p-1 rounded text-amber-300 hover:bg-slate-800/60"
|
|
937
|
+
title="Stop (close & restart to fully cancel)"
|
|
938
|
+
>
|
|
939
|
+
<StopCircle className="w-3.5 h-3.5" />
|
|
940
|
+
</button>
|
|
941
|
+
)}
|
|
942
|
+
{rightTab === "console" && (
|
|
943
|
+
<button
|
|
944
|
+
onClick={clearConsole}
|
|
945
|
+
className="p-1 rounded text-slate-500 hover:text-slate-200 hover:bg-slate-800/60"
|
|
946
|
+
title="Clear console"
|
|
947
|
+
>
|
|
948
|
+
<Trash2 className="w-3.5 h-3.5" />
|
|
949
|
+
</button>
|
|
950
|
+
)}
|
|
951
|
+
</div>
|
|
952
|
+
</div>
|
|
953
|
+
|
|
954
|
+
{rightTab === "console" && (
|
|
955
|
+
<>
|
|
956
|
+
<div className="flex-1 min-h-0 overflow-auto px-3 py-2 text-[11px] font-mono whitespace-pre-wrap break-words">
|
|
957
|
+
{consoleLines.length === 0 ? (
|
|
958
|
+
<div className="text-slate-600">
|
|
959
|
+
Click <span className="text-amber-300">Run</span> to
|
|
960
|
+
execute the selected workflow with{" "}
|
|
961
|
+
<span className="text-amber-300">act</span>. Output will
|
|
962
|
+
stream here just like the Actions tab on GitHub.
|
|
963
|
+
{"\n\n"}
|
|
964
|
+
Tips:
|
|
965
|
+
{"\n"} • First run pulls a ~500 MB runner image. Be
|
|
966
|
+
patient.
|
|
967
|
+
{"\n"} • Use{" "}
|
|
968
|
+
<span className="text-amber-300">Dry run</span> to plan a
|
|
969
|
+
workflow without Docker.
|
|
970
|
+
{"\n"} • Switch to the{" "}
|
|
971
|
+
<span className="text-amber-300">Jobs</span> tab to see a
|
|
972
|
+
live DAG of running, succeeded, and failed jobs.
|
|
973
|
+
</div>
|
|
974
|
+
) : (
|
|
975
|
+
consoleLines.map((line) => (
|
|
976
|
+
<div
|
|
977
|
+
key={line.id}
|
|
978
|
+
className={
|
|
979
|
+
line.kind === "stderr"
|
|
980
|
+
? "text-red-300"
|
|
981
|
+
: line.kind === "info"
|
|
982
|
+
? "text-slate-400"
|
|
983
|
+
: line.kind === "input"
|
|
984
|
+
? "text-amber-200"
|
|
985
|
+
: "text-slate-200"
|
|
986
|
+
}
|
|
987
|
+
>
|
|
988
|
+
{line.text}
|
|
989
|
+
</div>
|
|
990
|
+
))
|
|
991
|
+
)}
|
|
992
|
+
{runError && (
|
|
993
|
+
<div className="mt-2 rounded border border-red-500/30 bg-red-500/10 px-2 py-1 text-red-200">
|
|
994
|
+
{runError}
|
|
995
|
+
</div>
|
|
996
|
+
)}
|
|
997
|
+
<div ref={consoleEndRef} />
|
|
998
|
+
</div>
|
|
999
|
+
<form
|
|
1000
|
+
onSubmit={handleConsoleSubmit}
|
|
1001
|
+
className="flex items-center gap-2 border-t border-slate-800/60 px-3 py-1.5"
|
|
1002
|
+
>
|
|
1003
|
+
<span className="text-amber-400 text-xs font-mono">$</span>
|
|
1004
|
+
<input
|
|
1005
|
+
value={consoleInput}
|
|
1006
|
+
onChange={(e) => setConsoleInput(e.target.value)}
|
|
1007
|
+
disabled={running}
|
|
1008
|
+
placeholder="act -l | act push -j greet | act -n"
|
|
1009
|
+
className="flex-1 bg-transparent text-xs font-mono text-slate-200 placeholder:text-slate-600 outline-none disabled:opacity-50"
|
|
1010
|
+
/>
|
|
1011
|
+
</form>
|
|
1012
|
+
</>
|
|
1013
|
+
)}
|
|
1014
|
+
|
|
1015
|
+
{rightTab === "jobs" && (
|
|
1016
|
+
<GhaJobsPanel
|
|
1017
|
+
workflowYaml={workflow ? workspace.files[workflow] : undefined}
|
|
1018
|
+
jobs={liveJobs}
|
|
1019
|
+
caption={
|
|
1020
|
+
running
|
|
1021
|
+
? "Live — updating as act emits job lines"
|
|
1022
|
+
: liveJobs.length
|
|
1023
|
+
? "Last run"
|
|
1024
|
+
: "Pre-run plan (parsed from workflow YAML)"
|
|
1025
|
+
}
|
|
1026
|
+
/>
|
|
1027
|
+
)}
|
|
1028
|
+
|
|
1029
|
+
{rightTab === "history" && (
|
|
1030
|
+
<GhaHistoryPanel
|
|
1031
|
+
{...(currentQuestion ? { questionId: currentQuestion.id } : {})}
|
|
1032
|
+
{...(activeGhaId ? { fileId: activeGhaId } : {})}
|
|
1033
|
+
includeInContext={!!workspace.includeRunHistoryInContext}
|
|
1034
|
+
onToggleIncludeInContext={(next) =>
|
|
1035
|
+
setWorkspace((prev) => ({
|
|
1036
|
+
...prev,
|
|
1037
|
+
includeRunHistoryInContext: next,
|
|
1038
|
+
}))
|
|
1039
|
+
}
|
|
1040
|
+
refreshNonce={historyNonce}
|
|
1041
|
+
/>
|
|
1042
|
+
)}
|
|
1043
|
+
</div>
|
|
1044
|
+
</div>
|
|
1045
|
+
</div>
|
|
1046
|
+
</div>
|
|
1047
|
+
);
|
|
1048
|
+
}
|