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.
@@ -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<"console" | "jobs" | "history">(
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 (command: string) => {
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
- const msg = err?.message || "Failed to start run";
519
- setRunError(msg);
520
- appendConsole({ kind: "stderr", text: `\n[error] ${msg}\n` });
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
- const handleRun = () => runCommand(buildCommand());
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
- runCommand(workflow ? `act -W ${workflow} -l` : "act -l");
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
- runCommand(cmd);
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
- <span className="text-[10px] font-semibold tracking-widest text-slate-500">
970
- FILES
971
- </span>
972
- <button
973
- onClick={addFile}
974
- className="p-1 rounded text-slate-400 hover:text-amber-300 hover:bg-slate-800/60"
975
- title="Add file"
976
- >
977
- <FilePlus className="w-3.5 h-3.5" />
978
- </button>
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 className="flex-1 overflow-auto p-1 text-xs">
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 className="truncate">{folder}/</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
- className={`group flex items-center gap-1 pl-${folder ? 5 : 1} pr-1 py-0.5 rounded cursor-pointer ${
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
- : "text-slate-300 hover:bg-slate-800/40"
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
- deleteFile(filePath);
1996
+ setOpenFileMenu((current) =>
1997
+ current === filePath ? null : filePath,
1998
+ );
1999
+ setBulkMenuOpen(false);
1018
2000
  }}
1019
- className="opacity-0 group-hover:opacity-100 p-0.5 text-slate-500 hover:text-red-400"
1020
- title="Delete"
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
- <Trash2 className="w-3 h-3" />
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={() => abortRef.current?.abort()}
2198
+ onClick={stopActiveRun}
1141
2199
  className="p-1 rounded text-amber-300 hover:bg-slate-800/60"
1142
- title="Stop (close & restart to fully cancel)"
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 } : {})}