create-interview-cockpit 0.22.0 → 0.23.1
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/api.ts +2 -0
- package/template/client/src/components/GhaConcurrencyPanel.tsx +281 -0
- package/template/client/src/components/GithubActionsLabModal.tsx +1238 -33
- package/template/client/src/components/WorkspaceSwitcher.tsx +6 -1
- package/template/client/src/ghaConcurrency.ts +216 -0
- package/template/client/src/githubActionsLab.ts +41 -0
- package/template/client/src/types.ts +17 -0
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/cockpit.json +1 -1
- package/template/server/src/gha-runner.ts +198 -1
- package/template/server/src/google-drive.ts +25 -9
- package/template/server/src/index.ts +0 -1
|
@@ -2,8 +2,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
2
2
|
import {
|
|
3
3
|
ChevronDown,
|
|
4
4
|
ChevronRight,
|
|
5
|
+
Copy,
|
|
5
6
|
FilePlus,
|
|
6
7
|
Folder,
|
|
8
|
+
GitBranch,
|
|
9
|
+
KeyRound,
|
|
10
|
+
ListChecks,
|
|
7
11
|
Loader2,
|
|
8
12
|
Maximize2,
|
|
9
13
|
Minimize2,
|
|
@@ -11,6 +15,7 @@ import {
|
|
|
11
15
|
PanelLeftOpen,
|
|
12
16
|
PanelRightClose,
|
|
13
17
|
PanelRightOpen,
|
|
18
|
+
Pencil,
|
|
14
19
|
Play,
|
|
15
20
|
Save,
|
|
16
21
|
StopCircle,
|
|
@@ -34,10 +39,19 @@ import {
|
|
|
34
39
|
serializeGhaLabWorkspace,
|
|
35
40
|
} from "../githubActionsLab";
|
|
36
41
|
import type { GithubActionsLabWorkspace } from "../types";
|
|
42
|
+
import type { GithubActionsLabEnvironmentEntry } from "../types";
|
|
37
43
|
import * as api from "../api";
|
|
38
44
|
import type { GhaJobSnapshot, GhaStreamMessage } from "../api";
|
|
39
45
|
import GhaJobsPanel from "./GhaJobsPanel";
|
|
40
46
|
import GhaHistoryPanel from "./GhaHistoryPanel";
|
|
47
|
+
import GhaConcurrencyPanel from "./GhaConcurrencyPanel";
|
|
48
|
+
import {
|
|
49
|
+
defaultContextForEvent,
|
|
50
|
+
evaluateConcurrencyFor,
|
|
51
|
+
parseConcurrencyBlock,
|
|
52
|
+
type GhaConcurrencyContext,
|
|
53
|
+
type GhaConcurrencyRun,
|
|
54
|
+
} from "../ghaConcurrency";
|
|
41
55
|
|
|
42
56
|
// ─── Modal layout constants ──────────────────────────────────────────────
|
|
43
57
|
|
|
@@ -66,6 +80,120 @@ function baseName(filePath: string): string {
|
|
|
66
80
|
return filePath.split("/").pop() || filePath;
|
|
67
81
|
}
|
|
68
82
|
|
|
83
|
+
function folderName(filePath: string): string {
|
|
84
|
+
const idx = filePath.lastIndexOf("/");
|
|
85
|
+
return idx === -1 ? "" : filePath.slice(0, idx);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function joinLabPath(folder: string, name: string): string {
|
|
89
|
+
return folder ? `${folder}/${name}` : name;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function getKnownFolders(paths: string[]): Set<string> {
|
|
93
|
+
const folders = new Set<string>([""]);
|
|
94
|
+
for (const filePath of paths) {
|
|
95
|
+
const parts = folderName(filePath).split("/").filter(Boolean);
|
|
96
|
+
for (let i = 1; i <= parts.length; i += 1) {
|
|
97
|
+
folders.add(parts.slice(0, i).join("/"));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return folders;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function normalizeDestinationInput(input: string): {
|
|
104
|
+
path: string;
|
|
105
|
+
isDirectoryHint: boolean;
|
|
106
|
+
error?: string;
|
|
107
|
+
} {
|
|
108
|
+
const raw = input.trim().replace(/\\/g, "/");
|
|
109
|
+
if (!raw) {
|
|
110
|
+
return {
|
|
111
|
+
path: "",
|
|
112
|
+
isDirectoryHint: false,
|
|
113
|
+
error: "Enter a destination path.",
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
if (raw.startsWith("/") || /^[a-zA-Z]:\//.test(raw)) {
|
|
117
|
+
return {
|
|
118
|
+
path: "",
|
|
119
|
+
isDirectoryHint: false,
|
|
120
|
+
error: "Use a relative path inside the lab workspace.",
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const isDirectoryHint = raw === "." || raw.endsWith("/");
|
|
125
|
+
let value = raw;
|
|
126
|
+
while (value.startsWith("./")) value = value.slice(2);
|
|
127
|
+
value = value.replace(/\/+/g, "/");
|
|
128
|
+
if (value === ".") value = "";
|
|
129
|
+
value = value.replace(/\/+$/, "");
|
|
130
|
+
|
|
131
|
+
const segments = value ? value.split("/") : [];
|
|
132
|
+
if (
|
|
133
|
+
segments.some(
|
|
134
|
+
(segment) =>
|
|
135
|
+
!segment ||
|
|
136
|
+
segment === "." ||
|
|
137
|
+
segment === ".." ||
|
|
138
|
+
segment.includes("\0"),
|
|
139
|
+
)
|
|
140
|
+
) {
|
|
141
|
+
return {
|
|
142
|
+
path: "",
|
|
143
|
+
isDirectoryHint: false,
|
|
144
|
+
error: "Destination paths cannot contain empty, '.', or '..' segments.",
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return { path: segments.join("/"), isDirectoryHint };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function resolveDestinationPath(
|
|
152
|
+
input: string,
|
|
153
|
+
sourceFile: string,
|
|
154
|
+
knownFolders: Set<string>,
|
|
155
|
+
): string | null {
|
|
156
|
+
const normalized = normalizeDestinationInput(input);
|
|
157
|
+
if (normalized.error) {
|
|
158
|
+
window.alert(normalized.error);
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
let target = normalized.path;
|
|
163
|
+
if (normalized.isDirectoryHint || knownFolders.has(target)) {
|
|
164
|
+
target = joinLabPath(target, baseName(sourceFile));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!target) {
|
|
168
|
+
window.alert("Destination file path is required.");
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
return target;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function getCopyCandidatePath(
|
|
175
|
+
sourceFile: string,
|
|
176
|
+
existingPaths: Set<string>,
|
|
177
|
+
targetFolder = folderName(sourceFile),
|
|
178
|
+
): string {
|
|
179
|
+
const sourceName = baseName(sourceFile);
|
|
180
|
+
const dot = sourceName.lastIndexOf(".");
|
|
181
|
+
const hasExtension = dot > 0;
|
|
182
|
+
const stem = hasExtension ? sourceName.slice(0, dot) : sourceName;
|
|
183
|
+
const ext = hasExtension ? sourceName.slice(dot) : "";
|
|
184
|
+
let count = 1;
|
|
185
|
+
|
|
186
|
+
while (true) {
|
|
187
|
+
const marker = count === 1 ? "copy" : `copy-${count}`;
|
|
188
|
+
const nextName = hasExtension
|
|
189
|
+
? `${stem}.${marker}${ext}`
|
|
190
|
+
: `${sourceName}.${marker}`;
|
|
191
|
+
const candidate = joinLabPath(targetFolder, nextName);
|
|
192
|
+
if (!existingPaths.has(candidate)) return candidate;
|
|
193
|
+
count += 1;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
69
197
|
function getEditorLanguage(filePath: string): string {
|
|
70
198
|
const lower = filePath.toLowerCase();
|
|
71
199
|
if (lower.endsWith(".yml") || lower.endsWith(".yaml")) return "yaml";
|
|
@@ -109,6 +237,44 @@ interface ConsoleLine {
|
|
|
109
237
|
text: string;
|
|
110
238
|
}
|
|
111
239
|
|
|
240
|
+
type GhaEnvironmentKind = "variables" | "secrets" | "env";
|
|
241
|
+
|
|
242
|
+
const GHA_ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
243
|
+
|
|
244
|
+
const GHA_ENVIRONMENT_SECTIONS: Array<{
|
|
245
|
+
kind: GhaEnvironmentKind;
|
|
246
|
+
title: string;
|
|
247
|
+
addLabel: string;
|
|
248
|
+
namePlaceholder: string;
|
|
249
|
+
valuePlaceholder: string;
|
|
250
|
+
help: string;
|
|
251
|
+
}> = [
|
|
252
|
+
{
|
|
253
|
+
kind: "variables",
|
|
254
|
+
title: "Repository variables",
|
|
255
|
+
addLabel: "Add variable",
|
|
256
|
+
namePlaceholder: "API_URL",
|
|
257
|
+
valuePlaceholder: "https://example.test",
|
|
258
|
+
help: "Available in workflows as `${{ vars.NAME }}` through act's --var-file.",
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
kind: "secrets",
|
|
262
|
+
title: "Repository secrets",
|
|
263
|
+
addLabel: "Add secret",
|
|
264
|
+
namePlaceholder: "NPM_TOKEN",
|
|
265
|
+
valuePlaceholder: "secret value",
|
|
266
|
+
help: "Available in workflows as `${{ secrets.NAME }}` through act's --secret-file.",
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
kind: "env",
|
|
270
|
+
title: "Runner environment",
|
|
271
|
+
addLabel: "Add env",
|
|
272
|
+
namePlaceholder: "NODE_ENV",
|
|
273
|
+
valuePlaceholder: "test",
|
|
274
|
+
help: "Available to shell steps as environment variables, e.g. `$NAME`, through act's --env-file.",
|
|
275
|
+
},
|
|
276
|
+
];
|
|
277
|
+
|
|
112
278
|
export default function GithubActionsLabModal() {
|
|
113
279
|
const {
|
|
114
280
|
closeGhaLab,
|
|
@@ -162,15 +328,55 @@ export default function GithubActionsLabModal() {
|
|
|
162
328
|
// Live job snapshots reported by the server during the active run.
|
|
163
329
|
// Reset every time the user kicks off a new run.
|
|
164
330
|
const [liveJobs, setLiveJobs] = useState<GhaJobSnapshot[]>([]);
|
|
165
|
-
// "console" | "jobs" | "history" — controls the right pane tab.
|
|
166
|
-
const [rightTab, setRightTab] = useState<
|
|
167
|
-
"console"
|
|
331
|
+
// "console" | "jobs" | "env" | "concurrency" | "history" — controls the right pane tab.
|
|
332
|
+
const [rightTab, setRightTab] = useState<
|
|
333
|
+
"console" | "jobs" | "env" | "concurrency" | "history"
|
|
334
|
+
>("console");
|
|
335
|
+
const environmentEntryCount = useMemo(
|
|
336
|
+
() =>
|
|
337
|
+
GHA_ENVIRONMENT_SECTIONS.reduce((total, section) => {
|
|
338
|
+
return (
|
|
339
|
+
total +
|
|
340
|
+
(workspace.environment?.[section.kind] ?? []).filter(
|
|
341
|
+
(entry) => entry.enabled !== false && entry.name.trim(),
|
|
342
|
+
).length
|
|
343
|
+
);
|
|
344
|
+
}, 0),
|
|
345
|
+
[workspace.environment],
|
|
168
346
|
);
|
|
169
347
|
// Bumped each time a run completes so the History tab refetches.
|
|
170
348
|
const [historyNonce, setHistoryNonce] = useState(0);
|
|
171
349
|
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
|
172
350
|
const [rightCollapsed, setRightCollapsed] = useState(false);
|
|
173
351
|
const abortRef = useRef<AbortController | null>(null);
|
|
352
|
+
// ── Concurrency engine state ───────────────────────────────────────
|
|
353
|
+
// The concurrency tab is no longer a simulator — these records track
|
|
354
|
+
// real `act` invocations so we can apply GitHub's queue/cancel rules
|
|
355
|
+
// when the user clicks Run a second time before the first finishes.
|
|
356
|
+
const [concurrencyRuns, setConcurrencyRuns] = useState<GhaConcurrencyRun[]>(
|
|
357
|
+
[],
|
|
358
|
+
);
|
|
359
|
+
const [concurrencyContext, setConcurrencyContext] =
|
|
360
|
+
useState<GhaConcurrencyContext>(() =>
|
|
361
|
+
defaultContextForEvent(
|
|
362
|
+
workspace.defaultEvent ?? "push",
|
|
363
|
+
workspace.defaultWorkflow ?? ".github/workflows/ci.yml",
|
|
364
|
+
"main",
|
|
365
|
+
42,
|
|
366
|
+
"feature/login",
|
|
367
|
+
),
|
|
368
|
+
);
|
|
369
|
+
// Mirrors of state used inside async callbacks to dodge stale closures
|
|
370
|
+
// when the runner finishes and needs to drain the next pending run.
|
|
371
|
+
const concurrencyRunsRef = useRef<GhaConcurrencyRun[]>([]);
|
|
372
|
+
const activeRunIdRef = useRef<string | null>(null);
|
|
373
|
+
const runSeqRef = useRef(0);
|
|
374
|
+
const runConcurrencyRunRef = useRef<
|
|
375
|
+
((run: GhaConcurrencyRun) => Promise<void>) | null
|
|
376
|
+
>(null);
|
|
377
|
+
useEffect(() => {
|
|
378
|
+
concurrencyRunsRef.current = concurrencyRuns;
|
|
379
|
+
}, [concurrencyRuns]);
|
|
174
380
|
const consoleEndRef = useRef<HTMLDivElement | null>(null);
|
|
175
381
|
const monacoRef = useRef<Monaco | null>(null);
|
|
176
382
|
const monacoModelUrisRef = useRef<Set<string>>(new Set());
|
|
@@ -305,6 +511,32 @@ export default function GithubActionsLabModal() {
|
|
|
305
511
|
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(
|
|
306
512
|
() => new Set(),
|
|
307
513
|
);
|
|
514
|
+
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(
|
|
515
|
+
() => new Set(),
|
|
516
|
+
);
|
|
517
|
+
const [draggingFile, setDraggingFile] = useState<string | null>(null);
|
|
518
|
+
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
|
|
519
|
+
const [openFileMenu, setOpenFileMenu] = useState<string | null>(null);
|
|
520
|
+
const [bulkMenuOpen, setBulkMenuOpen] = useState(false);
|
|
521
|
+
// When false the row checkboxes stay hidden so the tree reads like a
|
|
522
|
+
// normal file list; flipped on by the toolbar toggle, by checking a
|
|
523
|
+
// single row via its hover affordance, or whenever something is selected.
|
|
524
|
+
const [selectMode, setSelectMode] = useState(false);
|
|
525
|
+
const selectedFileList = useMemo(
|
|
526
|
+
() => fileOrder.filter((filePath) => selectedFiles.has(filePath)),
|
|
527
|
+
[fileOrder, selectedFiles],
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
useEffect(() => {
|
|
531
|
+
setSelectedFiles((prev) => {
|
|
532
|
+
const known = new Set(fileOrder);
|
|
533
|
+
const next = new Set(
|
|
534
|
+
Array.from(prev).filter((filePath) => known.has(filePath)),
|
|
535
|
+
);
|
|
536
|
+
return next.size === prev.size ? prev : next;
|
|
537
|
+
});
|
|
538
|
+
}, [fileOrder]);
|
|
539
|
+
|
|
308
540
|
const toggleFolder = (folder: string) => {
|
|
309
541
|
setCollapsedFolders((prev) => {
|
|
310
542
|
const next = new Set(prev);
|
|
@@ -314,6 +546,27 @@ export default function GithubActionsLabModal() {
|
|
|
314
546
|
});
|
|
315
547
|
};
|
|
316
548
|
|
|
549
|
+
const toggleFileSelection = (fileName: string) => {
|
|
550
|
+
setSelectedFiles((prev) => {
|
|
551
|
+
const next = new Set(prev);
|
|
552
|
+
if (next.has(fileName)) next.delete(fileName);
|
|
553
|
+
else next.add(fileName);
|
|
554
|
+
if (next.size > 0) setSelectMode(true);
|
|
555
|
+
return next;
|
|
556
|
+
});
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
const toggleSelectAllFiles = () => {
|
|
560
|
+
setSelectedFiles((prev) => {
|
|
561
|
+
if (prev.size === fileOrder.length) {
|
|
562
|
+
setSelectMode(false);
|
|
563
|
+
return new Set();
|
|
564
|
+
}
|
|
565
|
+
setSelectMode(true);
|
|
566
|
+
return new Set(fileOrder);
|
|
567
|
+
});
|
|
568
|
+
};
|
|
569
|
+
|
|
317
570
|
const updateFile = (fileName: string, content: string) => {
|
|
318
571
|
setWorkspace((prev) => ({
|
|
319
572
|
...prev,
|
|
@@ -322,6 +575,325 @@ export default function GithubActionsLabModal() {
|
|
|
322
575
|
}));
|
|
323
576
|
};
|
|
324
577
|
|
|
578
|
+
const applyFilePathOperation = useCallback(
|
|
579
|
+
(
|
|
580
|
+
sourceFile: string,
|
|
581
|
+
requestedTarget: string,
|
|
582
|
+
operation: "move" | "copy",
|
|
583
|
+
collisionStrategy: "prompt" | "unique" = "prompt",
|
|
584
|
+
) => {
|
|
585
|
+
const sourceContent = workspace.files[sourceFile];
|
|
586
|
+
if (sourceContent === undefined) return;
|
|
587
|
+
|
|
588
|
+
const existingPaths = new Set(Object.keys(workspace.files));
|
|
589
|
+
let targetFile = requestedTarget;
|
|
590
|
+
if (
|
|
591
|
+
operation === "copy" &&
|
|
592
|
+
(targetFile === sourceFile ||
|
|
593
|
+
(collisionStrategy === "unique" && existingPaths.has(targetFile)))
|
|
594
|
+
) {
|
|
595
|
+
targetFile = getCopyCandidatePath(
|
|
596
|
+
sourceFile,
|
|
597
|
+
existingPaths,
|
|
598
|
+
folderName(targetFile),
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (operation === "move" && targetFile === sourceFile) return;
|
|
603
|
+
|
|
604
|
+
const overwrites =
|
|
605
|
+
Object.prototype.hasOwnProperty.call(workspace.files, targetFile) &&
|
|
606
|
+
targetFile !== sourceFile;
|
|
607
|
+
if (
|
|
608
|
+
overwrites &&
|
|
609
|
+
!window.confirm(`Overwrite ${targetFile} with ${sourceFile}?`)
|
|
610
|
+
) {
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
setWorkspace((prev) => {
|
|
615
|
+
const content = prev.files[sourceFile];
|
|
616
|
+
if (content === undefined) return prev;
|
|
617
|
+
|
|
618
|
+
const files = { ...prev.files };
|
|
619
|
+
if (operation === "move") {
|
|
620
|
+
delete files[sourceFile];
|
|
621
|
+
}
|
|
622
|
+
files[targetFile] = content;
|
|
623
|
+
|
|
624
|
+
const nextActiveFile =
|
|
625
|
+
operation === "copy" || prev.activeFile === sourceFile
|
|
626
|
+
? targetFile
|
|
627
|
+
: prev.activeFile;
|
|
628
|
+
|
|
629
|
+
return {
|
|
630
|
+
...prev,
|
|
631
|
+
activeFile: nextActiveFile,
|
|
632
|
+
defaultWorkflow:
|
|
633
|
+
operation === "move" && prev.defaultWorkflow === sourceFile
|
|
634
|
+
? targetFile
|
|
635
|
+
: prev.defaultWorkflow,
|
|
636
|
+
files,
|
|
637
|
+
};
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
if (operation === "copy" || activeFile === sourceFile) {
|
|
641
|
+
setActiveFile(targetFile);
|
|
642
|
+
}
|
|
643
|
+
if (operation === "move" && workflow === sourceFile) {
|
|
644
|
+
setWorkflow(targetFile);
|
|
645
|
+
}
|
|
646
|
+
setSelectedFiles((prev) => {
|
|
647
|
+
if (!prev.has(sourceFile)) return prev;
|
|
648
|
+
const next = new Set(prev);
|
|
649
|
+
if (operation === "move") next.delete(sourceFile);
|
|
650
|
+
next.add(targetFile);
|
|
651
|
+
return next;
|
|
652
|
+
});
|
|
653
|
+
},
|
|
654
|
+
[activeFile, workflow, workspace.files],
|
|
655
|
+
);
|
|
656
|
+
|
|
657
|
+
const applyBulkFolderOperation = useCallback(
|
|
658
|
+
(
|
|
659
|
+
sourceFiles: string[],
|
|
660
|
+
targetFolder: string,
|
|
661
|
+
operation: "move" | "copy",
|
|
662
|
+
) => {
|
|
663
|
+
const uniqueSources = Array.from(new Set(sourceFiles)).filter(
|
|
664
|
+
(fileName) => workspace.files[fileName] !== undefined,
|
|
665
|
+
);
|
|
666
|
+
if (uniqueSources.length === 0) return;
|
|
667
|
+
|
|
668
|
+
const existingPaths = new Set(Object.keys(workspace.files));
|
|
669
|
+
const plannedTargets = new Set<string>();
|
|
670
|
+
const pairs: Array<{ source: string; target: string }> = [];
|
|
671
|
+
|
|
672
|
+
for (const source of uniqueSources) {
|
|
673
|
+
let target = joinLabPath(targetFolder, baseName(source));
|
|
674
|
+
|
|
675
|
+
if (operation === "move" && target === source) {
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (operation === "copy") {
|
|
680
|
+
if (
|
|
681
|
+
target === source ||
|
|
682
|
+
existingPaths.has(target) ||
|
|
683
|
+
plannedTargets.has(target)
|
|
684
|
+
) {
|
|
685
|
+
target = getCopyCandidatePath(
|
|
686
|
+
source,
|
|
687
|
+
new Set([...existingPaths, ...plannedTargets]),
|
|
688
|
+
targetFolder,
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
} else if (plannedTargets.has(target)) {
|
|
692
|
+
window.alert(
|
|
693
|
+
`Multiple selected files would become ${target}. Rename one first, or move them separately.`,
|
|
694
|
+
);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
pairs.push({ source, target });
|
|
699
|
+
plannedTargets.add(target);
|
|
700
|
+
if (operation === "copy") existingPaths.add(target);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
if (pairs.length === 0) return;
|
|
704
|
+
|
|
705
|
+
const conflicts =
|
|
706
|
+
operation === "move"
|
|
707
|
+
? pairs.filter(
|
|
708
|
+
({ source, target }) =>
|
|
709
|
+
source !== target && workspace.files[target] !== undefined,
|
|
710
|
+
)
|
|
711
|
+
: [];
|
|
712
|
+
if (conflicts.length > 0) {
|
|
713
|
+
const preview = conflicts
|
|
714
|
+
.slice(0, 4)
|
|
715
|
+
.map(({ target }) => target)
|
|
716
|
+
.join("\n");
|
|
717
|
+
const suffix = conflicts.length > 4 ? "\n…" : "";
|
|
718
|
+
if (
|
|
719
|
+
!window.confirm(
|
|
720
|
+
`Overwrite existing file${conflicts.length === 1 ? "" : "s"}?\n${preview}${suffix}`,
|
|
721
|
+
)
|
|
722
|
+
) {
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
setWorkspace((prev) => {
|
|
728
|
+
const files = { ...prev.files };
|
|
729
|
+
const materialized = pairs
|
|
730
|
+
.map(({ source, target }) => ({
|
|
731
|
+
source,
|
|
732
|
+
target,
|
|
733
|
+
content: prev.files[source],
|
|
734
|
+
}))
|
|
735
|
+
.filter(
|
|
736
|
+
(
|
|
737
|
+
entry,
|
|
738
|
+
): entry is { source: string; target: string; content: string } =>
|
|
739
|
+
entry.content !== undefined,
|
|
740
|
+
);
|
|
741
|
+
|
|
742
|
+
if (operation === "move") {
|
|
743
|
+
for (const { source } of materialized) delete files[source];
|
|
744
|
+
}
|
|
745
|
+
for (const { target, content } of materialized) files[target] = content;
|
|
746
|
+
|
|
747
|
+
const activeMapping = materialized.find(
|
|
748
|
+
({ source }) => source === prev.activeFile,
|
|
749
|
+
);
|
|
750
|
+
const defaultWorkflowMapping = materialized.find(
|
|
751
|
+
({ source }) => source === prev.defaultWorkflow,
|
|
752
|
+
);
|
|
753
|
+
|
|
754
|
+
return {
|
|
755
|
+
...prev,
|
|
756
|
+
activeFile:
|
|
757
|
+
operation === "copy"
|
|
758
|
+
? (materialized[0]?.target ?? prev.activeFile)
|
|
759
|
+
: (activeMapping?.target ?? prev.activeFile),
|
|
760
|
+
defaultWorkflow:
|
|
761
|
+
operation === "move" && defaultWorkflowMapping
|
|
762
|
+
? defaultWorkflowMapping.target
|
|
763
|
+
: prev.defaultWorkflow,
|
|
764
|
+
files,
|
|
765
|
+
};
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
const activePair = pairs.find(({ source }) => source === activeFile);
|
|
769
|
+
if (operation === "copy") {
|
|
770
|
+
setActiveFile(pairs[0]?.target ?? activeFile);
|
|
771
|
+
} else if (activePair) {
|
|
772
|
+
setActiveFile(activePair.target);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
const workflowPair = pairs.find(({ source }) => source === workflow);
|
|
776
|
+
if (operation === "move" && workflowPair) {
|
|
777
|
+
setWorkflow(workflowPair.target);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
setSelectedFiles(new Set(pairs.map(({ target }) => target)));
|
|
781
|
+
},
|
|
782
|
+
[activeFile, workflow, workspace.files],
|
|
783
|
+
);
|
|
784
|
+
|
|
785
|
+
const resolveBulkFolderPath = (input: string): string | null => {
|
|
786
|
+
const normalized = normalizeDestinationInput(input);
|
|
787
|
+
if (normalized.error) {
|
|
788
|
+
window.alert(normalized.error);
|
|
789
|
+
return null;
|
|
790
|
+
}
|
|
791
|
+
return normalized.path;
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
const moveFilesToFolder = (fileNames: string[]) => {
|
|
795
|
+
if (fileNames.length === 0) return;
|
|
796
|
+
const next = window.prompt(
|
|
797
|
+
`Move ${fileNames.length} file${fileNames.length === 1 ? "" : "s"} into folder path. Use . for the workspace root.`,
|
|
798
|
+
folderName(fileNames[0]) || ".",
|
|
799
|
+
);
|
|
800
|
+
if (!next) return;
|
|
801
|
+
const targetFolder = resolveBulkFolderPath(next);
|
|
802
|
+
if (targetFolder === null) return;
|
|
803
|
+
applyBulkFolderOperation(fileNames, targetFolder, "move");
|
|
804
|
+
};
|
|
805
|
+
|
|
806
|
+
const copyFilesToFolder = (fileNames: string[]) => {
|
|
807
|
+
if (fileNames.length === 0) return;
|
|
808
|
+
const next = window.prompt(
|
|
809
|
+
`Copy ${fileNames.length} file${fileNames.length === 1 ? "" : "s"} into folder path. Use . for the workspace root.`,
|
|
810
|
+
folderName(fileNames[0]) || ".",
|
|
811
|
+
);
|
|
812
|
+
if (!next) return;
|
|
813
|
+
const targetFolder = resolveBulkFolderPath(next);
|
|
814
|
+
if (targetFolder === null) return;
|
|
815
|
+
applyBulkFolderOperation(fileNames, targetFolder, "copy");
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
const moveFile = (fileName: string) => {
|
|
819
|
+
const next = window.prompt(
|
|
820
|
+
"Move or rename file. Enter a destination path, or an existing folder / folder ending in / to keep the same file name.",
|
|
821
|
+
fileName,
|
|
822
|
+
);
|
|
823
|
+
if (!next) return;
|
|
824
|
+
const target = resolveDestinationPath(
|
|
825
|
+
next,
|
|
826
|
+
fileName,
|
|
827
|
+
getKnownFolders(Object.keys(workspace.files)),
|
|
828
|
+
);
|
|
829
|
+
if (!target) return;
|
|
830
|
+
applyFilePathOperation(fileName, target, "move");
|
|
831
|
+
setOpenFileMenu(null);
|
|
832
|
+
};
|
|
833
|
+
|
|
834
|
+
const copyFile = (fileName: string) => {
|
|
835
|
+
const next = window.prompt(
|
|
836
|
+
"Copy file. Enter a destination path, or an existing folder / folder ending in / to keep the same file name.",
|
|
837
|
+
getCopyCandidatePath(fileName, new Set(Object.keys(workspace.files))),
|
|
838
|
+
);
|
|
839
|
+
if (!next) return;
|
|
840
|
+
const target = resolveDestinationPath(
|
|
841
|
+
next,
|
|
842
|
+
fileName,
|
|
843
|
+
getKnownFolders(Object.keys(workspace.files)),
|
|
844
|
+
);
|
|
845
|
+
if (!target) return;
|
|
846
|
+
applyFilePathOperation(fileName, target, "copy");
|
|
847
|
+
setOpenFileMenu(null);
|
|
848
|
+
};
|
|
849
|
+
|
|
850
|
+
const handleFileDragStart = (
|
|
851
|
+
e: React.DragEvent<HTMLDivElement>,
|
|
852
|
+
fileName: string,
|
|
853
|
+
) => {
|
|
854
|
+
setDraggingFile(fileName);
|
|
855
|
+
e.dataTransfer.effectAllowed = "copyMove";
|
|
856
|
+
e.dataTransfer.setData("text/x-gha-lab-file", fileName);
|
|
857
|
+
e.dataTransfer.setData("text/plain", fileName);
|
|
858
|
+
};
|
|
859
|
+
|
|
860
|
+
const handleFolderDragOver = (
|
|
861
|
+
e: React.DragEvent<HTMLElement>,
|
|
862
|
+
folder: string,
|
|
863
|
+
) => {
|
|
864
|
+
if (!draggingFile) return;
|
|
865
|
+
e.preventDefault();
|
|
866
|
+
e.dataTransfer.dropEffect = e.altKey ? "copy" : "move";
|
|
867
|
+
setDragOverFolder(folder);
|
|
868
|
+
};
|
|
869
|
+
|
|
870
|
+
const handleFolderDrop = (
|
|
871
|
+
e: React.DragEvent<HTMLElement>,
|
|
872
|
+
folder: string,
|
|
873
|
+
) => {
|
|
874
|
+
e.preventDefault();
|
|
875
|
+
const sourceFile =
|
|
876
|
+
e.dataTransfer.getData("text/x-gha-lab-file") || draggingFile;
|
|
877
|
+
setDraggingFile(null);
|
|
878
|
+
setDragOverFolder(null);
|
|
879
|
+
if (!sourceFile) return;
|
|
880
|
+
|
|
881
|
+
const operation = e.altKey ? "copy" : "move";
|
|
882
|
+
const sourceGroup = selectedFiles.has(sourceFile)
|
|
883
|
+
? selectedFileList
|
|
884
|
+
: [sourceFile];
|
|
885
|
+
if (sourceGroup.length > 1) {
|
|
886
|
+
applyBulkFolderOperation(sourceGroup, folder, operation);
|
|
887
|
+
} else {
|
|
888
|
+
applyFilePathOperation(
|
|
889
|
+
sourceFile,
|
|
890
|
+
joinLabPath(folder, baseName(sourceFile)),
|
|
891
|
+
operation,
|
|
892
|
+
operation === "copy" ? "unique" : "prompt",
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
};
|
|
896
|
+
|
|
325
897
|
const addFile = () => {
|
|
326
898
|
const name = window.prompt(
|
|
327
899
|
"New file path (e.g. .github/workflows/release.yml)",
|
|
@@ -354,6 +926,83 @@ export default function GithubActionsLabModal() {
|
|
|
354
926
|
);
|
|
355
927
|
setActiveFile(remaining[0] ?? "");
|
|
356
928
|
}
|
|
929
|
+
setSelectedFiles((prev) => {
|
|
930
|
+
if (!prev.has(fileName)) return prev;
|
|
931
|
+
const next = new Set(prev);
|
|
932
|
+
next.delete(fileName);
|
|
933
|
+
return next;
|
|
934
|
+
});
|
|
935
|
+
setOpenFileMenu(null);
|
|
936
|
+
};
|
|
937
|
+
|
|
938
|
+
const deleteSelectedFiles = () => {
|
|
939
|
+
if (selectedFileList.length === 0) return;
|
|
940
|
+
if (
|
|
941
|
+
!window.confirm(
|
|
942
|
+
`Delete ${selectedFileList.length} selected file${selectedFileList.length === 1 ? "" : "s"}?`,
|
|
943
|
+
)
|
|
944
|
+
) {
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
const selected = new Set(selectedFileList);
|
|
948
|
+
setWorkspace((prev) => {
|
|
949
|
+
const files = { ...prev.files };
|
|
950
|
+
for (const fileName of selected) delete files[fileName];
|
|
951
|
+
const nextActive = selected.has(prev.activeFile)
|
|
952
|
+
? (Object.keys(files)[0] ?? "")
|
|
953
|
+
: prev.activeFile;
|
|
954
|
+
return { ...prev, activeFile: nextActive, files };
|
|
955
|
+
});
|
|
956
|
+
if (selected.has(activeFile)) {
|
|
957
|
+
const remaining = Object.keys(workspace.files).filter(
|
|
958
|
+
(fileName) => !selected.has(fileName),
|
|
959
|
+
);
|
|
960
|
+
setActiveFile(remaining[0] ?? "");
|
|
961
|
+
}
|
|
962
|
+
setSelectedFiles(new Set());
|
|
963
|
+
setBulkMenuOpen(false);
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
// ── GitHub Actions environment inputs ─────────────────────────────
|
|
967
|
+
const getEnvironmentRows = (kind: GhaEnvironmentKind) =>
|
|
968
|
+
workspace.environment?.[kind] ?? [];
|
|
969
|
+
|
|
970
|
+
const setEnvironmentRows = (
|
|
971
|
+
kind: GhaEnvironmentKind,
|
|
972
|
+
rows: GithubActionsLabEnvironmentEntry[],
|
|
973
|
+
) => {
|
|
974
|
+
setWorkspace((prev) => ({
|
|
975
|
+
...prev,
|
|
976
|
+
environment: {
|
|
977
|
+
...(prev.environment ?? {}),
|
|
978
|
+
[kind]: rows,
|
|
979
|
+
},
|
|
980
|
+
}));
|
|
981
|
+
};
|
|
982
|
+
|
|
983
|
+
const addEnvironmentEntry = (kind: GhaEnvironmentKind) => {
|
|
984
|
+
const rows = getEnvironmentRows(kind);
|
|
985
|
+
setEnvironmentRows(kind, [...rows, { name: "", value: "", enabled: true }]);
|
|
986
|
+
setRightTab("env");
|
|
987
|
+
};
|
|
988
|
+
|
|
989
|
+
const updateEnvironmentEntry = (
|
|
990
|
+
kind: GhaEnvironmentKind,
|
|
991
|
+
index: number,
|
|
992
|
+
patch: Partial<GithubActionsLabEnvironmentEntry>,
|
|
993
|
+
) => {
|
|
994
|
+
const rows = getEnvironmentRows(kind).slice();
|
|
995
|
+
const current = rows[index];
|
|
996
|
+
if (!current) return;
|
|
997
|
+
rows[index] = { ...current, ...patch };
|
|
998
|
+
setEnvironmentRows(kind, rows);
|
|
999
|
+
};
|
|
1000
|
+
|
|
1001
|
+
const removeEnvironmentEntry = (kind: GhaEnvironmentKind, index: number) => {
|
|
1002
|
+
setEnvironmentRows(
|
|
1003
|
+
kind,
|
|
1004
|
+
getEnvironmentRows(kind).filter((_, i) => i !== index),
|
|
1005
|
+
);
|
|
357
1006
|
};
|
|
358
1007
|
|
|
359
1008
|
// ── Save lab as context file ──────────────────────────────────────
|
|
@@ -460,7 +1109,10 @@ export default function GithubActionsLabModal() {
|
|
|
460
1109
|
}, []);
|
|
461
1110
|
|
|
462
1111
|
const runCommand = useCallback(
|
|
463
|
-
async (
|
|
1112
|
+
async (run: GhaConcurrencyRun) => {
|
|
1113
|
+
const controller = new AbortController();
|
|
1114
|
+
abortRef.current = controller;
|
|
1115
|
+
activeRunIdRef.current = run.id;
|
|
464
1116
|
setRunning(true);
|
|
465
1117
|
setRunError(null);
|
|
466
1118
|
// Reset the DAG so the user always sees a fresh "pending → running →
|
|
@@ -468,12 +1120,25 @@ export default function GithubActionsLabModal() {
|
|
|
468
1120
|
// so the visualisation is immediately visible.
|
|
469
1121
|
setLiveJobs([]);
|
|
470
1122
|
setRightTab("jobs");
|
|
471
|
-
appendConsole({ kind: "input", text: `$ ${command}\n` });
|
|
1123
|
+
appendConsole({ kind: "input", text: `$ ${run.command}\n` });
|
|
1124
|
+
|
|
1125
|
+
// Flip this run's record to running so the Concurrency panel ticks.
|
|
1126
|
+
setConcurrencyRuns((rs) =>
|
|
1127
|
+
rs.map((r) =>
|
|
1128
|
+
r.id === run.id
|
|
1129
|
+
? { ...r, status: "running", startedAt: Date.now() }
|
|
1130
|
+
: r,
|
|
1131
|
+
),
|
|
1132
|
+
);
|
|
1133
|
+
|
|
1134
|
+
let exitCode: number | undefined;
|
|
1135
|
+
let didError = false;
|
|
1136
|
+
let errorMsg = "";
|
|
472
1137
|
|
|
473
1138
|
try {
|
|
474
1139
|
await api.streamGhaCommand(
|
|
475
1140
|
{
|
|
476
|
-
command,
|
|
1141
|
+
command: run.command,
|
|
477
1142
|
workspace: {
|
|
478
1143
|
...workspace,
|
|
479
1144
|
activeFile,
|
|
@@ -505,6 +1170,7 @@ export default function GithubActionsLabModal() {
|
|
|
505
1170
|
text: `\n[error] ${message.error}\n`,
|
|
506
1171
|
});
|
|
507
1172
|
} else if (message.type === "complete") {
|
|
1173
|
+
exitCode = message.exitCode;
|
|
508
1174
|
appendConsole({
|
|
509
1175
|
kind: "info",
|
|
510
1176
|
text: `\n[done] exit=${message.exitCode} • ${message.durationMs}ms\n`,
|
|
@@ -513,13 +1179,54 @@ export default function GithubActionsLabModal() {
|
|
|
513
1179
|
setHistoryNonce((n) => n + 1);
|
|
514
1180
|
}
|
|
515
1181
|
},
|
|
1182
|
+
{ signal: controller.signal },
|
|
516
1183
|
);
|
|
517
1184
|
} catch (err: any) {
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
1185
|
+
if (controller.signal.aborted) {
|
|
1186
|
+
// The run was cancelled (either by the user or by a newer run
|
|
1187
|
+
// that supersedes it). Its record was already marked cancelled
|
|
1188
|
+
// — do not surface the AbortError as a normal failure.
|
|
1189
|
+
appendConsole({
|
|
1190
|
+
kind: "info",
|
|
1191
|
+
text: `\n[cancelled] run #${run.seq}\n`,
|
|
1192
|
+
});
|
|
1193
|
+
} else {
|
|
1194
|
+
didError = true;
|
|
1195
|
+
errorMsg = err?.message || "Failed to start run";
|
|
1196
|
+
setRunError(errorMsg);
|
|
1197
|
+
appendConsole({ kind: "stderr", text: `\n[error] ${errorMsg}\n` });
|
|
1198
|
+
}
|
|
521
1199
|
} finally {
|
|
1200
|
+
abortRef.current = null;
|
|
1201
|
+
activeRunIdRef.current = null;
|
|
522
1202
|
setRunning(false);
|
|
1203
|
+
|
|
1204
|
+
// Finalise this run's record. If the enqueue path already marked
|
|
1205
|
+
// it cancelled (preempted by a newer run) keep that status.
|
|
1206
|
+
setConcurrencyRuns((rs) =>
|
|
1207
|
+
rs.map((r) => {
|
|
1208
|
+
if (r.id !== run.id) return r;
|
|
1209
|
+
if (r.status === "cancelled") return r;
|
|
1210
|
+
return {
|
|
1211
|
+
...r,
|
|
1212
|
+
status: didError ? "cancelled" : "completed",
|
|
1213
|
+
endedAt: Date.now(),
|
|
1214
|
+
...(exitCode !== undefined ? { exitCode } : {}),
|
|
1215
|
+
...(didError ? { cancelReason: errorMsg } : {}),
|
|
1216
|
+
};
|
|
1217
|
+
}),
|
|
1218
|
+
);
|
|
1219
|
+
|
|
1220
|
+
// Drain the queue: pick the oldest pending and start it. We use
|
|
1221
|
+
// the ref to dodge the stale snapshot inside this async closure.
|
|
1222
|
+
const pending = concurrencyRunsRef.current.find(
|
|
1223
|
+
(r) => r.status === "pending",
|
|
1224
|
+
);
|
|
1225
|
+
if (pending) {
|
|
1226
|
+
window.setTimeout(() => {
|
|
1227
|
+
runConcurrencyRunRef.current?.(pending);
|
|
1228
|
+
}, 0);
|
|
1229
|
+
}
|
|
523
1230
|
}
|
|
524
1231
|
},
|
|
525
1232
|
[
|
|
@@ -533,10 +1240,133 @@ export default function GithubActionsLabModal() {
|
|
|
533
1240
|
appendConsole,
|
|
534
1241
|
],
|
|
535
1242
|
);
|
|
1243
|
+
useEffect(() => {
|
|
1244
|
+
runConcurrencyRunRef.current = runCommand;
|
|
1245
|
+
}, [runCommand]);
|
|
1246
|
+
|
|
1247
|
+
/**
|
|
1248
|
+
* Enqueue a new run. Evaluates the active workflow's concurrency block
|
|
1249
|
+
* against the current github.* context to decide whether to cancel the
|
|
1250
|
+
* in-flight run, drop older pendings in the same group, or just queue.
|
|
1251
|
+
*/
|
|
1252
|
+
const enqueueRun = useCallback(
|
|
1253
|
+
(command: string) => {
|
|
1254
|
+
const parsed = parseConcurrencyBlock(
|
|
1255
|
+
workflow ? workspace.files[workflow] : undefined,
|
|
1256
|
+
);
|
|
1257
|
+
const ctx: GhaConcurrencyContext = {
|
|
1258
|
+
...concurrencyContext,
|
|
1259
|
+
event_name: event,
|
|
1260
|
+
};
|
|
1261
|
+
const { groupKey, cancelInProgress } = evaluateConcurrencyFor(
|
|
1262
|
+
parsed,
|
|
1263
|
+
ctx,
|
|
1264
|
+
);
|
|
536
1265
|
|
|
537
|
-
|
|
1266
|
+
runSeqRef.current += 1;
|
|
1267
|
+
const seq = runSeqRef.current;
|
|
1268
|
+
const newRun: GhaConcurrencyRun = {
|
|
1269
|
+
id: `${seq}-${Math.random().toString(36).slice(2, 8)}`,
|
|
1270
|
+
seq,
|
|
1271
|
+
command,
|
|
1272
|
+
eventName: event,
|
|
1273
|
+
workflowPath: workflow,
|
|
1274
|
+
groupKey,
|
|
1275
|
+
cancelInProgress,
|
|
1276
|
+
context: ctx,
|
|
1277
|
+
status: "pending",
|
|
1278
|
+
};
|
|
1279
|
+
|
|
1280
|
+
// We need to know whether anything was actively running BEFORE we
|
|
1281
|
+
// mutate state — if so, the drain in runCommand's finally will
|
|
1282
|
+
// pick up the new pending; otherwise we kick it ourselves.
|
|
1283
|
+
const wasIdle = !activeRunIdRef.current;
|
|
1284
|
+
let preemptedActive = false;
|
|
1285
|
+
|
|
1286
|
+
setConcurrencyRuns((prev) => {
|
|
1287
|
+
const updated = [...prev];
|
|
1288
|
+
|
|
1289
|
+
// Rule 1: same group + new.cancelInProgress=true cancels the
|
|
1290
|
+
// currently running sibling. Empty groupKey means "no concurrency
|
|
1291
|
+
// block" and never coalesces with anything.
|
|
1292
|
+
if (groupKey) {
|
|
1293
|
+
const activeIdx = updated.findIndex((r) => r.status === "running");
|
|
1294
|
+
if (activeIdx >= 0) {
|
|
1295
|
+
const active = updated[activeIdx];
|
|
1296
|
+
if (active.groupKey === groupKey && newRun.cancelInProgress) {
|
|
1297
|
+
updated[activeIdx] = {
|
|
1298
|
+
...active,
|
|
1299
|
+
status: "cancelled",
|
|
1300
|
+
endedAt: Date.now(),
|
|
1301
|
+
cancelReason: `superseded by run #${seq}`,
|
|
1302
|
+
};
|
|
1303
|
+
preemptedActive = true;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
// Rule 2: GitHub keeps at most ONE pending per group. Any older
|
|
1308
|
+
// pending in the same group gets cancelled by the newcomer.
|
|
1309
|
+
for (let i = 0; i < updated.length; i += 1) {
|
|
1310
|
+
const r = updated[i];
|
|
1311
|
+
if (r.status === "pending" && r.groupKey === groupKey) {
|
|
1312
|
+
updated[i] = {
|
|
1313
|
+
...r,
|
|
1314
|
+
status: "cancelled",
|
|
1315
|
+
endedAt: Date.now(),
|
|
1316
|
+
cancelReason: `superseded by pending run #${seq}`,
|
|
1317
|
+
};
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
updated.push(newRun);
|
|
1323
|
+
return updated;
|
|
1324
|
+
});
|
|
1325
|
+
|
|
1326
|
+
// Abort the in-flight fetch AFTER the state mutation so runCommand's
|
|
1327
|
+
// catch path sees the record already marked cancelled and won't
|
|
1328
|
+
// overwrite the supersede reason.
|
|
1329
|
+
if (preemptedActive) {
|
|
1330
|
+
abortRef.current?.abort();
|
|
1331
|
+
} else if (wasIdle) {
|
|
1332
|
+
window.setTimeout(() => {
|
|
1333
|
+
runConcurrencyRunRef.current?.(newRun);
|
|
1334
|
+
}, 0);
|
|
1335
|
+
}
|
|
1336
|
+
},
|
|
1337
|
+
[workflow, workspace.files, concurrencyContext, event],
|
|
1338
|
+
);
|
|
1339
|
+
|
|
1340
|
+
const handleRun = () => enqueueRun(buildCommand());
|
|
538
1341
|
const handleListJobs = () =>
|
|
539
|
-
|
|
1342
|
+
enqueueRun(workflow ? `act -W ${workflow} -l` : "act -l");
|
|
1343
|
+
|
|
1344
|
+
/** Cancel the currently running act invocation. */
|
|
1345
|
+
const stopActiveRun = useCallback(() => {
|
|
1346
|
+
const activeId = activeRunIdRef.current;
|
|
1347
|
+
if (!activeId) return;
|
|
1348
|
+
setConcurrencyRuns((rs) =>
|
|
1349
|
+
rs.map((r) =>
|
|
1350
|
+
r.id === activeId
|
|
1351
|
+
? {
|
|
1352
|
+
...r,
|
|
1353
|
+
status: "cancelled",
|
|
1354
|
+
endedAt: Date.now(),
|
|
1355
|
+
cancelReason: "stopped by user",
|
|
1356
|
+
}
|
|
1357
|
+
: r,
|
|
1358
|
+
),
|
|
1359
|
+
);
|
|
1360
|
+
abortRef.current?.abort();
|
|
1361
|
+
}, []);
|
|
1362
|
+
|
|
1363
|
+
// Keep the Concurrency panel's context.event_name in sync with the
|
|
1364
|
+
// toolbar event picker so the read-only field always reflects reality.
|
|
1365
|
+
useEffect(() => {
|
|
1366
|
+
setConcurrencyContext((prev) =>
|
|
1367
|
+
prev.event_name === event ? prev : { ...prev, event_name: event },
|
|
1368
|
+
);
|
|
1369
|
+
}, [event]);
|
|
540
1370
|
|
|
541
1371
|
const clearConsole = () => setConsoleLines([]);
|
|
542
1372
|
|
|
@@ -591,7 +1421,7 @@ export default function GithubActionsLabModal() {
|
|
|
591
1421
|
const cmd = consoleInput.trim();
|
|
592
1422
|
if (!cmd || running) return;
|
|
593
1423
|
setConsoleInput("");
|
|
594
|
-
|
|
1424
|
+
enqueueRun(cmd);
|
|
595
1425
|
};
|
|
596
1426
|
|
|
597
1427
|
// ── Monaco config ─────────────────────────────────────────────────
|
|
@@ -966,18 +1796,132 @@ interface ImportMeta {
|
|
|
966
1796
|
}}
|
|
967
1797
|
>
|
|
968
1798
|
<div className="flex items-center justify-between px-2 py-1.5 border-b border-slate-800/60">
|
|
969
|
-
<
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
</
|
|
1799
|
+
<div className="flex items-center gap-1 min-w-0">
|
|
1800
|
+
<span className="text-[10px] font-semibold tracking-widest text-slate-500">
|
|
1801
|
+
FILES
|
|
1802
|
+
</span>
|
|
1803
|
+
{selectedFileList.length > 0 && (
|
|
1804
|
+
<span className="rounded bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-medium text-amber-200">
|
|
1805
|
+
{selectedFileList.length}
|
|
1806
|
+
</span>
|
|
1807
|
+
)}
|
|
1808
|
+
</div>
|
|
1809
|
+
<div className="relative flex items-center gap-1">
|
|
1810
|
+
<button
|
|
1811
|
+
onClick={(e) => {
|
|
1812
|
+
e.stopPropagation();
|
|
1813
|
+
setSelectMode((prev) => {
|
|
1814
|
+
const next = !prev;
|
|
1815
|
+
if (!next) setSelectedFiles(new Set());
|
|
1816
|
+
return next;
|
|
1817
|
+
});
|
|
1818
|
+
setBulkMenuOpen(false);
|
|
1819
|
+
setOpenFileMenu(null);
|
|
1820
|
+
}}
|
|
1821
|
+
className={`p-1 rounded hover:bg-slate-800/60 ${
|
|
1822
|
+
selectMode
|
|
1823
|
+
? "text-amber-300"
|
|
1824
|
+
: "text-slate-400 hover:text-amber-300"
|
|
1825
|
+
}`}
|
|
1826
|
+
title={selectMode ? "Exit selection mode" : "Select files"}
|
|
1827
|
+
>
|
|
1828
|
+
<ListChecks className="w-3.5 h-3.5" />
|
|
1829
|
+
</button>
|
|
1830
|
+
{selectedFileList.length > 0 && (
|
|
1831
|
+
<>
|
|
1832
|
+
<button
|
|
1833
|
+
onClick={(e) => {
|
|
1834
|
+
e.stopPropagation();
|
|
1835
|
+
setBulkMenuOpen((v) => !v);
|
|
1836
|
+
setOpenFileMenu(null);
|
|
1837
|
+
}}
|
|
1838
|
+
className="rounded px-1.5 py-0.5 text-[11px] text-slate-400 hover:bg-slate-800/60 hover:text-amber-200"
|
|
1839
|
+
title="Selected file actions"
|
|
1840
|
+
>
|
|
1841
|
+
Selected ▾
|
|
1842
|
+
</button>
|
|
1843
|
+
{bulkMenuOpen && (
|
|
1844
|
+
<div
|
|
1845
|
+
onClick={(e) => e.stopPropagation()}
|
|
1846
|
+
className="absolute right-6 top-6 z-40 w-44 overflow-hidden rounded-lg border border-slate-700 bg-slate-950 py-1 text-[11px] shadow-xl"
|
|
1847
|
+
>
|
|
1848
|
+
<button
|
|
1849
|
+
onClick={() => {
|
|
1850
|
+
moveFilesToFolder(selectedFileList);
|
|
1851
|
+
setBulkMenuOpen(false);
|
|
1852
|
+
}}
|
|
1853
|
+
className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-slate-200 hover:bg-slate-800/70"
|
|
1854
|
+
>
|
|
1855
|
+
<Pencil className="w-3 h-3 text-amber-300" />
|
|
1856
|
+
Move to folder…
|
|
1857
|
+
</button>
|
|
1858
|
+
<button
|
|
1859
|
+
onClick={() => {
|
|
1860
|
+
copyFilesToFolder(selectedFileList);
|
|
1861
|
+
setBulkMenuOpen(false);
|
|
1862
|
+
}}
|
|
1863
|
+
className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-slate-200 hover:bg-slate-800/70"
|
|
1864
|
+
>
|
|
1865
|
+
<Copy className="w-3 h-3 text-sky-300" />
|
|
1866
|
+
Copy to folder…
|
|
1867
|
+
</button>
|
|
1868
|
+
<button
|
|
1869
|
+
onClick={() => {
|
|
1870
|
+
setSelectedFiles(new Set());
|
|
1871
|
+
setSelectMode(false);
|
|
1872
|
+
setBulkMenuOpen(false);
|
|
1873
|
+
}}
|
|
1874
|
+
className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-slate-400 hover:bg-slate-800/70"
|
|
1875
|
+
>
|
|
1876
|
+
Clear selection
|
|
1877
|
+
</button>
|
|
1878
|
+
<button
|
|
1879
|
+
onClick={deleteSelectedFiles}
|
|
1880
|
+
className="flex w-full items-center gap-2 border-t border-slate-800 px-2 py-1.5 text-left text-red-300 hover:bg-red-500/10"
|
|
1881
|
+
>
|
|
1882
|
+
<Trash2 className="w-3 h-3" />
|
|
1883
|
+
Delete selected
|
|
1884
|
+
</button>
|
|
1885
|
+
</div>
|
|
1886
|
+
)}
|
|
1887
|
+
</>
|
|
1888
|
+
)}
|
|
1889
|
+
<button
|
|
1890
|
+
onClick={addFile}
|
|
1891
|
+
className="p-1 rounded text-slate-400 hover:text-amber-300 hover:bg-slate-800/60"
|
|
1892
|
+
title="Add file"
|
|
1893
|
+
>
|
|
1894
|
+
<FilePlus className="w-3.5 h-3.5" />
|
|
1895
|
+
</button>
|
|
1896
|
+
</div>
|
|
979
1897
|
</div>
|
|
980
|
-
<div
|
|
1898
|
+
<div
|
|
1899
|
+
className="flex-1 overflow-auto p-1 text-xs"
|
|
1900
|
+
onClick={() => {
|
|
1901
|
+
setOpenFileMenu(null);
|
|
1902
|
+
setBulkMenuOpen(false);
|
|
1903
|
+
}}
|
|
1904
|
+
>
|
|
1905
|
+
{draggingFile && (
|
|
1906
|
+
<div
|
|
1907
|
+
onDragOver={(e) => handleFolderDragOver(e, "")}
|
|
1908
|
+
onDragLeave={() =>
|
|
1909
|
+
setDragOverFolder((current) =>
|
|
1910
|
+
current === "" ? null : current,
|
|
1911
|
+
)
|
|
1912
|
+
}
|
|
1913
|
+
onDrop={(e) => handleFolderDrop(e, "")}
|
|
1914
|
+
className={`mb-1 rounded border border-dashed px-2 py-1 text-[11px] ${
|
|
1915
|
+
dragOverFolder === ""
|
|
1916
|
+
? "border-amber-400/70 bg-amber-500/10 text-amber-200"
|
|
1917
|
+
: "border-slate-700 text-slate-500"
|
|
1918
|
+
}`}
|
|
1919
|
+
>
|
|
1920
|
+
Drop{" "}
|
|
1921
|
+
{selectedFiles.has(draggingFile) ? "selected files" : "file"}{" "}
|
|
1922
|
+
in workspace root • hold Option/Alt to copy
|
|
1923
|
+
</div>
|
|
1924
|
+
)}
|
|
981
1925
|
{grouped.map(({ folder, files }) => {
|
|
982
1926
|
const collapsed = collapsedFolders.has(folder);
|
|
983
1927
|
return (
|
|
@@ -985,7 +1929,15 @@ interface ImportMeta {
|
|
|
985
1929
|
{folder && (
|
|
986
1930
|
<button
|
|
987
1931
|
onClick={() => toggleFolder(folder)}
|
|
1932
|
+
onDragOver={(e) => handleFolderDragOver(e, folder)}
|
|
1933
|
+
onDragLeave={() =>
|
|
1934
|
+
setDragOverFolder((current) =>
|
|
1935
|
+
current === folder ? null : current,
|
|
1936
|
+
)
|
|
1937
|
+
}
|
|
1938
|
+
onDrop={(e) => handleFolderDrop(e, folder)}
|
|
988
1939
|
className="flex items-center gap-1 w-full px-1 py-0.5 text-slate-400 hover:text-slate-200"
|
|
1940
|
+
title="Drop a file here to move it. Hold Option/Alt while dropping to copy."
|
|
989
1941
|
>
|
|
990
1942
|
{collapsed ? (
|
|
991
1943
|
<ChevronRight className="w-3 h-3" />
|
|
@@ -993,34 +1945,127 @@ interface ImportMeta {
|
|
|
993
1945
|
<ChevronDown className="w-3 h-3" />
|
|
994
1946
|
)}
|
|
995
1947
|
<Folder className="w-3 h-3" />
|
|
996
|
-
<span
|
|
1948
|
+
<span
|
|
1949
|
+
className={`truncate rounded px-1 ${
|
|
1950
|
+
dragOverFolder === folder
|
|
1951
|
+
? "bg-amber-500/15 text-amber-200"
|
|
1952
|
+
: ""
|
|
1953
|
+
}`}
|
|
1954
|
+
>
|
|
1955
|
+
{folder}/
|
|
1956
|
+
</span>
|
|
997
1957
|
</button>
|
|
998
1958
|
)}
|
|
999
1959
|
{!collapsed &&
|
|
1000
1960
|
files.map((filePath) => (
|
|
1001
1961
|
<div
|
|
1002
1962
|
key={filePath}
|
|
1003
|
-
|
|
1963
|
+
data-selected={selectedFiles.has(filePath)}
|
|
1964
|
+
draggable
|
|
1965
|
+
onDragStart={(e) => handleFileDragStart(e, filePath)}
|
|
1966
|
+
onDragEnd={() => {
|
|
1967
|
+
setDraggingFile(null);
|
|
1968
|
+
setDragOverFolder(null);
|
|
1969
|
+
}}
|
|
1970
|
+
className={`group relative flex items-center gap-1 pl-${folder ? 5 : 1} pr-1 py-0.5 rounded cursor-pointer ${
|
|
1004
1971
|
activeFile === filePath
|
|
1005
1972
|
? "bg-amber-500/15 text-amber-200"
|
|
1006
|
-
:
|
|
1973
|
+
: selectedFiles.has(filePath)
|
|
1974
|
+
? "bg-sky-500/10 text-sky-100 hover:bg-sky-500/15"
|
|
1975
|
+
: "text-slate-300 hover:bg-slate-800/40"
|
|
1007
1976
|
}`}
|
|
1008
1977
|
onClick={() => setActiveFile(filePath)}
|
|
1009
1978
|
style={{ paddingLeft: folder ? 20 : 6 }}
|
|
1010
1979
|
>
|
|
1980
|
+
{(selectMode || selectedFiles.has(filePath)) && (
|
|
1981
|
+
<input
|
|
1982
|
+
type="checkbox"
|
|
1983
|
+
checked={selectedFiles.has(filePath)}
|
|
1984
|
+
onClick={(e) => e.stopPropagation()}
|
|
1985
|
+
onChange={() => toggleFileSelection(filePath)}
|
|
1986
|
+
className="h-3 w-3 shrink-0 accent-amber-400"
|
|
1987
|
+
title="Select file"
|
|
1988
|
+
/>
|
|
1989
|
+
)}
|
|
1011
1990
|
<span className="truncate flex-1">
|
|
1012
1991
|
{baseName(filePath)}
|
|
1013
1992
|
</span>
|
|
1014
1993
|
<button
|
|
1015
1994
|
onClick={(e) => {
|
|
1016
1995
|
e.stopPropagation();
|
|
1017
|
-
|
|
1996
|
+
setOpenFileMenu((current) =>
|
|
1997
|
+
current === filePath ? null : filePath,
|
|
1998
|
+
);
|
|
1999
|
+
setBulkMenuOpen(false);
|
|
1018
2000
|
}}
|
|
1019
|
-
className="
|
|
1020
|
-
title="
|
|
2001
|
+
className="rounded px-1 text-slate-500 opacity-70 hover:bg-slate-800/70 hover:text-amber-200 group-hover:opacity-100"
|
|
2002
|
+
title="File actions"
|
|
1021
2003
|
>
|
|
1022
|
-
|
|
2004
|
+
⋯
|
|
1023
2005
|
</button>
|
|
2006
|
+
{openFileMenu === filePath && (
|
|
2007
|
+
<div
|
|
2008
|
+
onClick={(e) => e.stopPropagation()}
|
|
2009
|
+
className="absolute right-1 top-6 z-40 w-40 overflow-hidden rounded-lg border border-slate-700 bg-slate-950 py-1 text-[11px] shadow-xl"
|
|
2010
|
+
>
|
|
2011
|
+
<button
|
|
2012
|
+
onClick={() => moveFile(filePath)}
|
|
2013
|
+
className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-slate-200 hover:bg-slate-800/70"
|
|
2014
|
+
>
|
|
2015
|
+
<Pencil className="w-3 h-3 text-amber-300" />
|
|
2016
|
+
Move / rename…
|
|
2017
|
+
</button>
|
|
2018
|
+
<button
|
|
2019
|
+
onClick={() => copyFile(filePath)}
|
|
2020
|
+
className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-slate-200 hover:bg-slate-800/70"
|
|
2021
|
+
>
|
|
2022
|
+
<Copy className="w-3 h-3 text-sky-300" />
|
|
2023
|
+
Copy to path…
|
|
2024
|
+
</button>
|
|
2025
|
+
<button
|
|
2026
|
+
onClick={() => {
|
|
2027
|
+
toggleFileSelection(filePath);
|
|
2028
|
+
setOpenFileMenu(null);
|
|
2029
|
+
}}
|
|
2030
|
+
className="flex w-full items-center gap-2 border-t border-slate-800 px-2 py-1.5 text-left text-slate-200 hover:bg-slate-800/70"
|
|
2031
|
+
>
|
|
2032
|
+
<ListChecks className="w-3 h-3 text-amber-300" />
|
|
2033
|
+
{selectedFiles.has(filePath)
|
|
2034
|
+
? "Deselect"
|
|
2035
|
+
: "Select"}
|
|
2036
|
+
</button>
|
|
2037
|
+
{selectedFileList.length > 1 &&
|
|
2038
|
+
selectedFiles.has(filePath) && (
|
|
2039
|
+
<>
|
|
2040
|
+
<button
|
|
2041
|
+
onClick={() => {
|
|
2042
|
+
moveFilesToFolder(selectedFileList);
|
|
2043
|
+
setOpenFileMenu(null);
|
|
2044
|
+
}}
|
|
2045
|
+
className="flex w-full items-center gap-2 border-t border-slate-800 px-2 py-1.5 text-left text-slate-200 hover:bg-slate-800/70"
|
|
2046
|
+
>
|
|
2047
|
+
Move selected…
|
|
2048
|
+
</button>
|
|
2049
|
+
<button
|
|
2050
|
+
onClick={() => {
|
|
2051
|
+
copyFilesToFolder(selectedFileList);
|
|
2052
|
+
setOpenFileMenu(null);
|
|
2053
|
+
}}
|
|
2054
|
+
className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-slate-200 hover:bg-slate-800/70"
|
|
2055
|
+
>
|
|
2056
|
+
Copy selected…
|
|
2057
|
+
</button>
|
|
2058
|
+
</>
|
|
2059
|
+
)}
|
|
2060
|
+
<button
|
|
2061
|
+
onClick={() => deleteFile(filePath)}
|
|
2062
|
+
className="flex w-full items-center gap-2 border-t border-slate-800 px-2 py-1.5 text-left text-red-300 hover:bg-red-500/10"
|
|
2063
|
+
>
|
|
2064
|
+
<Trash2 className="w-3 h-3" />
|
|
2065
|
+
Delete
|
|
2066
|
+
</button>
|
|
2067
|
+
</div>
|
|
2068
|
+
)}
|
|
1024
2069
|
</div>
|
|
1025
2070
|
))}
|
|
1026
2071
|
</div>
|
|
@@ -1098,7 +2143,7 @@ interface ImportMeta {
|
|
|
1098
2143
|
</div>
|
|
1099
2144
|
</div>
|
|
1100
2145
|
|
|
1101
|
-
{/* Right pane: tabbed Console / Jobs / History */}
|
|
2146
|
+
{/* Right pane: tabbed Console / Jobs / Env / History */}
|
|
1102
2147
|
<div
|
|
1103
2148
|
className="min-h-0 flex flex-col bg-slate-950 overflow-hidden"
|
|
1104
2149
|
style={{
|
|
@@ -1112,6 +2157,8 @@ interface ImportMeta {
|
|
|
1112
2157
|
[
|
|
1113
2158
|
{ id: "console", label: "Console" },
|
|
1114
2159
|
{ id: "jobs", label: "Jobs" },
|
|
2160
|
+
{ id: "env", label: "Env" },
|
|
2161
|
+
{ id: "concurrency", label: "Concurrency" },
|
|
1115
2162
|
{ id: "history", label: "History" },
|
|
1116
2163
|
] as const
|
|
1117
2164
|
).map((t) => (
|
|
@@ -1127,19 +2174,30 @@ interface ImportMeta {
|
|
|
1127
2174
|
{t.id === "console" && (
|
|
1128
2175
|
<Terminal className="inline w-3 h-3 mr-1 -mt-px" />
|
|
1129
2176
|
)}
|
|
2177
|
+
{t.id === "env" && (
|
|
2178
|
+
<KeyRound className="inline w-3 h-3 mr-1 -mt-px" />
|
|
2179
|
+
)}
|
|
2180
|
+
{t.id === "concurrency" && (
|
|
2181
|
+
<GitBranch className="inline w-3 h-3 mr-1 -mt-px" />
|
|
2182
|
+
)}
|
|
1130
2183
|
{t.label}
|
|
1131
2184
|
{t.id === "jobs" && running && (
|
|
1132
2185
|
<span className="ml-1 inline-block w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse" />
|
|
1133
2186
|
)}
|
|
2187
|
+
{t.id === "env" && environmentEntryCount > 0 && (
|
|
2188
|
+
<span className="ml-1 rounded bg-amber-500/15 px-1 text-[10px] text-amber-200">
|
|
2189
|
+
{environmentEntryCount}
|
|
2190
|
+
</span>
|
|
2191
|
+
)}
|
|
1134
2192
|
</button>
|
|
1135
2193
|
))}
|
|
1136
2194
|
</div>
|
|
1137
2195
|
<div className="flex items-center gap-1">
|
|
1138
2196
|
{running && (
|
|
1139
2197
|
<button
|
|
1140
|
-
onClick={
|
|
2198
|
+
onClick={stopActiveRun}
|
|
1141
2199
|
className="p-1 rounded text-amber-300 hover:bg-slate-800/60"
|
|
1142
|
-
title="
|
|
2200
|
+
title="Cancel the running act invocation"
|
|
1143
2201
|
>
|
|
1144
2202
|
<StopCircle className="w-3.5 h-3.5" />
|
|
1145
2203
|
</button>
|
|
@@ -1231,6 +2289,153 @@ interface ImportMeta {
|
|
|
1231
2289
|
/>
|
|
1232
2290
|
)}
|
|
1233
2291
|
|
|
2292
|
+
{rightTab === "env" && (
|
|
2293
|
+
<div className="flex-1 min-h-0 overflow-auto p-3 text-xs text-slate-300">
|
|
2294
|
+
<div className="mb-3 rounded-xl border border-amber-500/20 bg-amber-500/5 p-3">
|
|
2295
|
+
<div className="mb-1 flex items-center gap-2 text-sm font-semibold text-amber-200">
|
|
2296
|
+
<KeyRound className="h-4 w-4" />
|
|
2297
|
+
GitHub-style act inputs
|
|
2298
|
+
</div>
|
|
2299
|
+
<p className="leading-5 text-slate-400">
|
|
2300
|
+
These are written to temporary act files for each run:
|
|
2301
|
+
variables become{" "}
|
|
2302
|
+
<span className="text-amber-200">vars.*</span>, secrets
|
|
2303
|
+
become <span className="text-amber-200">secrets.*</span>,
|
|
2304
|
+
and runner env values become shell environment variables.
|
|
2305
|
+
Use fake or local-only values; saved lab snapshots store
|
|
2306
|
+
these values as plain text.
|
|
2307
|
+
</p>
|
|
2308
|
+
</div>
|
|
2309
|
+
|
|
2310
|
+
<div className="space-y-3">
|
|
2311
|
+
{GHA_ENVIRONMENT_SECTIONS.map((section) => {
|
|
2312
|
+
const rows = getEnvironmentRows(section.kind);
|
|
2313
|
+
return (
|
|
2314
|
+
<section
|
|
2315
|
+
key={section.kind}
|
|
2316
|
+
className="rounded-xl border border-slate-800 bg-slate-900/40"
|
|
2317
|
+
>
|
|
2318
|
+
<div className="flex items-start justify-between gap-2 border-b border-slate-800/70 px-3 py-2">
|
|
2319
|
+
<div>
|
|
2320
|
+
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-slate-200">
|
|
2321
|
+
{section.title}
|
|
2322
|
+
</h3>
|
|
2323
|
+
<p className="mt-0.5 text-[11px] leading-4 text-slate-500">
|
|
2324
|
+
{section.help}
|
|
2325
|
+
</p>
|
|
2326
|
+
</div>
|
|
2327
|
+
<button
|
|
2328
|
+
onClick={() => addEnvironmentEntry(section.kind)}
|
|
2329
|
+
className="shrink-0 rounded border border-slate-700 px-2 py-1 text-[11px] text-slate-300 hover:border-amber-500/40 hover:text-amber-200"
|
|
2330
|
+
>
|
|
2331
|
+
{section.addLabel}
|
|
2332
|
+
</button>
|
|
2333
|
+
</div>
|
|
2334
|
+
|
|
2335
|
+
<div className="space-y-2 p-3">
|
|
2336
|
+
{rows.length === 0 ? (
|
|
2337
|
+
<button
|
|
2338
|
+
onClick={() => addEnvironmentEntry(section.kind)}
|
|
2339
|
+
className="w-full rounded-lg border border-dashed border-slate-700 px-3 py-3 text-left text-[11px] text-slate-500 hover:border-amber-500/40 hover:text-amber-200"
|
|
2340
|
+
>
|
|
2341
|
+
No entries yet — click to add one.
|
|
2342
|
+
</button>
|
|
2343
|
+
) : (
|
|
2344
|
+
rows.map((row, index) => {
|
|
2345
|
+
const nameIsValid =
|
|
2346
|
+
!row.name.trim() ||
|
|
2347
|
+
GHA_ENV_NAME_RE.test(row.name.trim());
|
|
2348
|
+
return (
|
|
2349
|
+
<div
|
|
2350
|
+
key={`${section.kind}-${index}`}
|
|
2351
|
+
className="rounded-lg border border-slate-800 bg-slate-950/60 p-2"
|
|
2352
|
+
>
|
|
2353
|
+
<div className="grid grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)_auto] gap-2">
|
|
2354
|
+
<input
|
|
2355
|
+
value={row.name}
|
|
2356
|
+
onChange={(e) =>
|
|
2357
|
+
updateEnvironmentEntry(
|
|
2358
|
+
section.kind,
|
|
2359
|
+
index,
|
|
2360
|
+
{ name: e.target.value },
|
|
2361
|
+
)
|
|
2362
|
+
}
|
|
2363
|
+
placeholder={section.namePlaceholder}
|
|
2364
|
+
className={`min-w-0 rounded border bg-slate-900 px-2 py-1.5 font-mono text-[11px] text-slate-100 outline-none placeholder:text-slate-600 ${
|
|
2365
|
+
nameIsValid
|
|
2366
|
+
? "border-slate-700 focus:border-amber-500/60"
|
|
2367
|
+
: "border-red-500/60 focus:border-red-400"
|
|
2368
|
+
}`}
|
|
2369
|
+
/>
|
|
2370
|
+
<input
|
|
2371
|
+
type={
|
|
2372
|
+
section.kind === "secrets"
|
|
2373
|
+
? "password"
|
|
2374
|
+
: "text"
|
|
2375
|
+
}
|
|
2376
|
+
value={row.value}
|
|
2377
|
+
onChange={(e) =>
|
|
2378
|
+
updateEnvironmentEntry(
|
|
2379
|
+
section.kind,
|
|
2380
|
+
index,
|
|
2381
|
+
{ value: e.target.value },
|
|
2382
|
+
)
|
|
2383
|
+
}
|
|
2384
|
+
placeholder={section.valuePlaceholder}
|
|
2385
|
+
className="min-w-0 rounded border border-slate-700 bg-slate-900 px-2 py-1.5 font-mono text-[11px] text-slate-100 outline-none placeholder:text-slate-600 focus:border-amber-500/60"
|
|
2386
|
+
/>
|
|
2387
|
+
<button
|
|
2388
|
+
onClick={() =>
|
|
2389
|
+
removeEnvironmentEntry(
|
|
2390
|
+
section.kind,
|
|
2391
|
+
index,
|
|
2392
|
+
)
|
|
2393
|
+
}
|
|
2394
|
+
className="rounded px-2 text-slate-500 hover:bg-red-500/10 hover:text-red-300"
|
|
2395
|
+
title="Remove entry"
|
|
2396
|
+
>
|
|
2397
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
2398
|
+
</button>
|
|
2399
|
+
</div>
|
|
2400
|
+
{!nameIsValid && (
|
|
2401
|
+
<div className="mt-1 text-[10px] text-red-300">
|
|
2402
|
+
Use letters, numbers, and underscores; do
|
|
2403
|
+
not start with a number.
|
|
2404
|
+
</div>
|
|
2405
|
+
)}
|
|
2406
|
+
</div>
|
|
2407
|
+
);
|
|
2408
|
+
})
|
|
2409
|
+
)}
|
|
2410
|
+
</div>
|
|
2411
|
+
</section>
|
|
2412
|
+
);
|
|
2413
|
+
})}
|
|
2414
|
+
</div>
|
|
2415
|
+
</div>
|
|
2416
|
+
)}
|
|
2417
|
+
|
|
2418
|
+
{rightTab === "concurrency" && (
|
|
2419
|
+
<GhaConcurrencyPanel
|
|
2420
|
+
parsed={parseConcurrencyBlock(
|
|
2421
|
+
workflow ? workspace.files[workflow] : undefined,
|
|
2422
|
+
)}
|
|
2423
|
+
workflowPath={workflow}
|
|
2424
|
+
runs={concurrencyRuns}
|
|
2425
|
+
context={concurrencyContext}
|
|
2426
|
+
onContextChange={setConcurrencyContext}
|
|
2427
|
+
onClearRuns={() => {
|
|
2428
|
+
// Only clear finished records; keep the active/pending
|
|
2429
|
+
// ones because the queue depends on them.
|
|
2430
|
+
setConcurrencyRuns((rs) =>
|
|
2431
|
+
rs.filter(
|
|
2432
|
+
(r) => r.status === "running" || r.status === "pending",
|
|
2433
|
+
),
|
|
2434
|
+
);
|
|
2435
|
+
}}
|
|
2436
|
+
/>
|
|
2437
|
+
)}
|
|
2438
|
+
|
|
1234
2439
|
{rightTab === "history" && (
|
|
1235
2440
|
<GhaHistoryPanel
|
|
1236
2441
|
{...(currentQuestion ? { questionId: currentQuestion.id } : {})}
|