create-interview-cockpit 0.21.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-interview-cockpit",
3
- "version": "0.21.0",
3
+ "version": "0.23.0",
4
4
  "description": "Scaffold a personal AI-powered interview prep cockpit",
5
5
  "type": "module",
6
6
  "bin": {
@@ -295,6 +295,13 @@ export default function CodeContextPanel() {
295
295
  [currentQuestion, updateCodeContext],
296
296
  );
297
297
 
298
+ const selectAllFiles = useCallback(() => {
299
+ if (!currentQuestion || availableFiles.length === 0) return;
300
+ const next = [...availableFiles];
301
+ setSelectedFiles(next);
302
+ updateCodeContext(currentQuestion.id, next);
303
+ }, [availableFiles, currentQuestion, updateCodeContext]);
304
+
298
305
  const toggleExpand = useCallback((path: string) => {
299
306
  setExpandedFolders((prev) => {
300
307
  const next = new Set(prev);
@@ -308,6 +315,10 @@ export default function CodeContextPanel() {
308
315
  }, []);
309
316
 
310
317
  const filter = search.toLowerCase();
318
+ const selectedSet = new Set(selectedFiles);
319
+ const allFilesSelected =
320
+ availableFiles.length > 0 &&
321
+ availableFiles.every((filePath) => selectedSet.has(filePath));
311
322
 
312
323
  return (
313
324
  <div className="w-72 h-full min-h-0 border-l border-slate-800 flex flex-col bg-slate-900/30 shrink-0 overflow-hidden">
@@ -317,7 +328,19 @@ export default function CodeContextPanel() {
317
328
  <span className="text-xs font-bold uppercase tracking-wider text-slate-500">
318
329
  Code Context
319
330
  </span>
320
- <FolderOpen className="w-3.5 h-3.5 text-slate-600" />
331
+ <div className="flex items-center gap-2">
332
+ {currentQuestion && availableFiles.length > 0 && (
333
+ <button
334
+ onClick={selectAllFiles}
335
+ disabled={allFilesSelected}
336
+ className="text-[10px] text-cyan-400/70 hover:text-cyan-300 disabled:text-slate-600 disabled:cursor-not-allowed"
337
+ title="Select all code-context files"
338
+ >
339
+ Select all
340
+ </button>
341
+ )}
342
+ <FolderOpen className="w-3.5 h-3.5 text-slate-600" />
343
+ </div>
321
344
  </div>
322
345
  <div className="relative">
323
346
  <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-slate-600" />
@@ -2,8 +2,10 @@ 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
+ ListChecks,
7
9
  Loader2,
8
10
  Maximize2,
9
11
  Minimize2,
@@ -11,6 +13,7 @@ import {
11
13
  PanelLeftOpen,
12
14
  PanelRightClose,
13
15
  PanelRightOpen,
16
+ Pencil,
14
17
  Play,
15
18
  Save,
16
19
  StopCircle,
@@ -66,6 +69,120 @@ function baseName(filePath: string): string {
66
69
  return filePath.split("/").pop() || filePath;
67
70
  }
68
71
 
72
+ function folderName(filePath: string): string {
73
+ const idx = filePath.lastIndexOf("/");
74
+ return idx === -1 ? "" : filePath.slice(0, idx);
75
+ }
76
+
77
+ function joinLabPath(folder: string, name: string): string {
78
+ return folder ? `${folder}/${name}` : name;
79
+ }
80
+
81
+ function getKnownFolders(paths: string[]): Set<string> {
82
+ const folders = new Set<string>([""]);
83
+ for (const filePath of paths) {
84
+ const parts = folderName(filePath).split("/").filter(Boolean);
85
+ for (let i = 1; i <= parts.length; i += 1) {
86
+ folders.add(parts.slice(0, i).join("/"));
87
+ }
88
+ }
89
+ return folders;
90
+ }
91
+
92
+ function normalizeDestinationInput(input: string): {
93
+ path: string;
94
+ isDirectoryHint: boolean;
95
+ error?: string;
96
+ } {
97
+ const raw = input.trim().replace(/\\/g, "/");
98
+ if (!raw) {
99
+ return {
100
+ path: "",
101
+ isDirectoryHint: false,
102
+ error: "Enter a destination path.",
103
+ };
104
+ }
105
+ if (raw.startsWith("/") || /^[a-zA-Z]:\//.test(raw)) {
106
+ return {
107
+ path: "",
108
+ isDirectoryHint: false,
109
+ error: "Use a relative path inside the lab workspace.",
110
+ };
111
+ }
112
+
113
+ const isDirectoryHint = raw === "." || raw.endsWith("/");
114
+ let value = raw;
115
+ while (value.startsWith("./")) value = value.slice(2);
116
+ value = value.replace(/\/+/g, "/");
117
+ if (value === ".") value = "";
118
+ value = value.replace(/\/+$/, "");
119
+
120
+ const segments = value ? value.split("/") : [];
121
+ if (
122
+ segments.some(
123
+ (segment) =>
124
+ !segment ||
125
+ segment === "." ||
126
+ segment === ".." ||
127
+ segment.includes("\0"),
128
+ )
129
+ ) {
130
+ return {
131
+ path: "",
132
+ isDirectoryHint: false,
133
+ error: "Destination paths cannot contain empty, '.', or '..' segments.",
134
+ };
135
+ }
136
+
137
+ return { path: segments.join("/"), isDirectoryHint };
138
+ }
139
+
140
+ function resolveDestinationPath(
141
+ input: string,
142
+ sourceFile: string,
143
+ knownFolders: Set<string>,
144
+ ): string | null {
145
+ const normalized = normalizeDestinationInput(input);
146
+ if (normalized.error) {
147
+ window.alert(normalized.error);
148
+ return null;
149
+ }
150
+
151
+ let target = normalized.path;
152
+ if (normalized.isDirectoryHint || knownFolders.has(target)) {
153
+ target = joinLabPath(target, baseName(sourceFile));
154
+ }
155
+
156
+ if (!target) {
157
+ window.alert("Destination file path is required.");
158
+ return null;
159
+ }
160
+ return target;
161
+ }
162
+
163
+ function getCopyCandidatePath(
164
+ sourceFile: string,
165
+ existingPaths: Set<string>,
166
+ targetFolder = folderName(sourceFile),
167
+ ): string {
168
+ const sourceName = baseName(sourceFile);
169
+ const dot = sourceName.lastIndexOf(".");
170
+ const hasExtension = dot > 0;
171
+ const stem = hasExtension ? sourceName.slice(0, dot) : sourceName;
172
+ const ext = hasExtension ? sourceName.slice(dot) : "";
173
+ let count = 1;
174
+
175
+ while (true) {
176
+ const marker = count === 1 ? "copy" : `copy-${count}`;
177
+ const nextName = hasExtension
178
+ ? `${stem}.${marker}${ext}`
179
+ : `${sourceName}.${marker}`;
180
+ const candidate = joinLabPath(targetFolder, nextName);
181
+ if (!existingPaths.has(candidate)) return candidate;
182
+ count += 1;
183
+ }
184
+ }
185
+
69
186
  function getEditorLanguage(filePath: string): string {
70
187
  const lower = filePath.toLowerCase();
71
188
  if (lower.endsWith(".yml") || lower.endsWith(".yaml")) return "yaml";
@@ -305,6 +422,32 @@ export default function GithubActionsLabModal() {
305
422
  const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(
306
423
  () => new Set(),
307
424
  );
425
+ const [selectedFiles, setSelectedFiles] = useState<Set<string>>(
426
+ () => new Set(),
427
+ );
428
+ const [draggingFile, setDraggingFile] = useState<string | null>(null);
429
+ const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
430
+ const [openFileMenu, setOpenFileMenu] = useState<string | null>(null);
431
+ const [bulkMenuOpen, setBulkMenuOpen] = useState(false);
432
+ // When false the row checkboxes stay hidden so the tree reads like a
433
+ // normal file list; flipped on by the toolbar toggle, by checking a
434
+ // single row via its hover affordance, or whenever something is selected.
435
+ const [selectMode, setSelectMode] = useState(false);
436
+ const selectedFileList = useMemo(
437
+ () => fileOrder.filter((filePath) => selectedFiles.has(filePath)),
438
+ [fileOrder, selectedFiles],
439
+ );
440
+
441
+ useEffect(() => {
442
+ setSelectedFiles((prev) => {
443
+ const known = new Set(fileOrder);
444
+ const next = new Set(
445
+ Array.from(prev).filter((filePath) => known.has(filePath)),
446
+ );
447
+ return next.size === prev.size ? prev : next;
448
+ });
449
+ }, [fileOrder]);
450
+
308
451
  const toggleFolder = (folder: string) => {
309
452
  setCollapsedFolders((prev) => {
310
453
  const next = new Set(prev);
@@ -314,6 +457,27 @@ export default function GithubActionsLabModal() {
314
457
  });
315
458
  };
316
459
 
460
+ const toggleFileSelection = (fileName: string) => {
461
+ setSelectedFiles((prev) => {
462
+ const next = new Set(prev);
463
+ if (next.has(fileName)) next.delete(fileName);
464
+ else next.add(fileName);
465
+ if (next.size > 0) setSelectMode(true);
466
+ return next;
467
+ });
468
+ };
469
+
470
+ const toggleSelectAllFiles = () => {
471
+ setSelectedFiles((prev) => {
472
+ if (prev.size === fileOrder.length) {
473
+ setSelectMode(false);
474
+ return new Set();
475
+ }
476
+ setSelectMode(true);
477
+ return new Set(fileOrder);
478
+ });
479
+ };
480
+
317
481
  const updateFile = (fileName: string, content: string) => {
318
482
  setWorkspace((prev) => ({
319
483
  ...prev,
@@ -322,6 +486,325 @@ export default function GithubActionsLabModal() {
322
486
  }));
323
487
  };
324
488
 
489
+ const applyFilePathOperation = useCallback(
490
+ (
491
+ sourceFile: string,
492
+ requestedTarget: string,
493
+ operation: "move" | "copy",
494
+ collisionStrategy: "prompt" | "unique" = "prompt",
495
+ ) => {
496
+ const sourceContent = workspace.files[sourceFile];
497
+ if (sourceContent === undefined) return;
498
+
499
+ const existingPaths = new Set(Object.keys(workspace.files));
500
+ let targetFile = requestedTarget;
501
+ if (
502
+ operation === "copy" &&
503
+ (targetFile === sourceFile ||
504
+ (collisionStrategy === "unique" && existingPaths.has(targetFile)))
505
+ ) {
506
+ targetFile = getCopyCandidatePath(
507
+ sourceFile,
508
+ existingPaths,
509
+ folderName(targetFile),
510
+ );
511
+ }
512
+
513
+ if (operation === "move" && targetFile === sourceFile) return;
514
+
515
+ const overwrites =
516
+ Object.prototype.hasOwnProperty.call(workspace.files, targetFile) &&
517
+ targetFile !== sourceFile;
518
+ if (
519
+ overwrites &&
520
+ !window.confirm(`Overwrite ${targetFile} with ${sourceFile}?`)
521
+ ) {
522
+ return;
523
+ }
524
+
525
+ setWorkspace((prev) => {
526
+ const content = prev.files[sourceFile];
527
+ if (content === undefined) return prev;
528
+
529
+ const files = { ...prev.files };
530
+ if (operation === "move") {
531
+ delete files[sourceFile];
532
+ }
533
+ files[targetFile] = content;
534
+
535
+ const nextActiveFile =
536
+ operation === "copy" || prev.activeFile === sourceFile
537
+ ? targetFile
538
+ : prev.activeFile;
539
+
540
+ return {
541
+ ...prev,
542
+ activeFile: nextActiveFile,
543
+ defaultWorkflow:
544
+ operation === "move" && prev.defaultWorkflow === sourceFile
545
+ ? targetFile
546
+ : prev.defaultWorkflow,
547
+ files,
548
+ };
549
+ });
550
+
551
+ if (operation === "copy" || activeFile === sourceFile) {
552
+ setActiveFile(targetFile);
553
+ }
554
+ if (operation === "move" && workflow === sourceFile) {
555
+ setWorkflow(targetFile);
556
+ }
557
+ setSelectedFiles((prev) => {
558
+ if (!prev.has(sourceFile)) return prev;
559
+ const next = new Set(prev);
560
+ if (operation === "move") next.delete(sourceFile);
561
+ next.add(targetFile);
562
+ return next;
563
+ });
564
+ },
565
+ [activeFile, workflow, workspace.files],
566
+ );
567
+
568
+ const applyBulkFolderOperation = useCallback(
569
+ (
570
+ sourceFiles: string[],
571
+ targetFolder: string,
572
+ operation: "move" | "copy",
573
+ ) => {
574
+ const uniqueSources = Array.from(new Set(sourceFiles)).filter(
575
+ (fileName) => workspace.files[fileName] !== undefined,
576
+ );
577
+ if (uniqueSources.length === 0) return;
578
+
579
+ const existingPaths = new Set(Object.keys(workspace.files));
580
+ const plannedTargets = new Set<string>();
581
+ const pairs: Array<{ source: string; target: string }> = [];
582
+
583
+ for (const source of uniqueSources) {
584
+ let target = joinLabPath(targetFolder, baseName(source));
585
+
586
+ if (operation === "move" && target === source) {
587
+ continue;
588
+ }
589
+
590
+ if (operation === "copy") {
591
+ if (
592
+ target === source ||
593
+ existingPaths.has(target) ||
594
+ plannedTargets.has(target)
595
+ ) {
596
+ target = getCopyCandidatePath(
597
+ source,
598
+ new Set([...existingPaths, ...plannedTargets]),
599
+ targetFolder,
600
+ );
601
+ }
602
+ } else if (plannedTargets.has(target)) {
603
+ window.alert(
604
+ `Multiple selected files would become ${target}. Rename one first, or move them separately.`,
605
+ );
606
+ return;
607
+ }
608
+
609
+ pairs.push({ source, target });
610
+ plannedTargets.add(target);
611
+ if (operation === "copy") existingPaths.add(target);
612
+ }
613
+
614
+ if (pairs.length === 0) return;
615
+
616
+ const conflicts =
617
+ operation === "move"
618
+ ? pairs.filter(
619
+ ({ source, target }) =>
620
+ source !== target && workspace.files[target] !== undefined,
621
+ )
622
+ : [];
623
+ if (conflicts.length > 0) {
624
+ const preview = conflicts
625
+ .slice(0, 4)
626
+ .map(({ target }) => target)
627
+ .join("\n");
628
+ const suffix = conflicts.length > 4 ? "\n…" : "";
629
+ if (
630
+ !window.confirm(
631
+ `Overwrite existing file${conflicts.length === 1 ? "" : "s"}?\n${preview}${suffix}`,
632
+ )
633
+ ) {
634
+ return;
635
+ }
636
+ }
637
+
638
+ setWorkspace((prev) => {
639
+ const files = { ...prev.files };
640
+ const materialized = pairs
641
+ .map(({ source, target }) => ({
642
+ source,
643
+ target,
644
+ content: prev.files[source],
645
+ }))
646
+ .filter(
647
+ (
648
+ entry,
649
+ ): entry is { source: string; target: string; content: string } =>
650
+ entry.content !== undefined,
651
+ );
652
+
653
+ if (operation === "move") {
654
+ for (const { source } of materialized) delete files[source];
655
+ }
656
+ for (const { target, content } of materialized) files[target] = content;
657
+
658
+ const activeMapping = materialized.find(
659
+ ({ source }) => source === prev.activeFile,
660
+ );
661
+ const defaultWorkflowMapping = materialized.find(
662
+ ({ source }) => source === prev.defaultWorkflow,
663
+ );
664
+
665
+ return {
666
+ ...prev,
667
+ activeFile:
668
+ operation === "copy"
669
+ ? (materialized[0]?.target ?? prev.activeFile)
670
+ : (activeMapping?.target ?? prev.activeFile),
671
+ defaultWorkflow:
672
+ operation === "move" && defaultWorkflowMapping
673
+ ? defaultWorkflowMapping.target
674
+ : prev.defaultWorkflow,
675
+ files,
676
+ };
677
+ });
678
+
679
+ const activePair = pairs.find(({ source }) => source === activeFile);
680
+ if (operation === "copy") {
681
+ setActiveFile(pairs[0]?.target ?? activeFile);
682
+ } else if (activePair) {
683
+ setActiveFile(activePair.target);
684
+ }
685
+
686
+ const workflowPair = pairs.find(({ source }) => source === workflow);
687
+ if (operation === "move" && workflowPair) {
688
+ setWorkflow(workflowPair.target);
689
+ }
690
+
691
+ setSelectedFiles(new Set(pairs.map(({ target }) => target)));
692
+ },
693
+ [activeFile, workflow, workspace.files],
694
+ );
695
+
696
+ const resolveBulkFolderPath = (input: string): string | null => {
697
+ const normalized = normalizeDestinationInput(input);
698
+ if (normalized.error) {
699
+ window.alert(normalized.error);
700
+ return null;
701
+ }
702
+ return normalized.path;
703
+ };
704
+
705
+ const moveFilesToFolder = (fileNames: string[]) => {
706
+ if (fileNames.length === 0) return;
707
+ const next = window.prompt(
708
+ `Move ${fileNames.length} file${fileNames.length === 1 ? "" : "s"} into folder path. Use . for the workspace root.`,
709
+ folderName(fileNames[0]) || ".",
710
+ );
711
+ if (!next) return;
712
+ const targetFolder = resolveBulkFolderPath(next);
713
+ if (targetFolder === null) return;
714
+ applyBulkFolderOperation(fileNames, targetFolder, "move");
715
+ };
716
+
717
+ const copyFilesToFolder = (fileNames: string[]) => {
718
+ if (fileNames.length === 0) return;
719
+ const next = window.prompt(
720
+ `Copy ${fileNames.length} file${fileNames.length === 1 ? "" : "s"} into folder path. Use . for the workspace root.`,
721
+ folderName(fileNames[0]) || ".",
722
+ );
723
+ if (!next) return;
724
+ const targetFolder = resolveBulkFolderPath(next);
725
+ if (targetFolder === null) return;
726
+ applyBulkFolderOperation(fileNames, targetFolder, "copy");
727
+ };
728
+
729
+ const moveFile = (fileName: string) => {
730
+ const next = window.prompt(
731
+ "Move or rename file. Enter a destination path, or an existing folder / folder ending in / to keep the same file name.",
732
+ fileName,
733
+ );
734
+ if (!next) return;
735
+ const target = resolveDestinationPath(
736
+ next,
737
+ fileName,
738
+ getKnownFolders(Object.keys(workspace.files)),
739
+ );
740
+ if (!target) return;
741
+ applyFilePathOperation(fileName, target, "move");
742
+ setOpenFileMenu(null);
743
+ };
744
+
745
+ const copyFile = (fileName: string) => {
746
+ const next = window.prompt(
747
+ "Copy file. Enter a destination path, or an existing folder / folder ending in / to keep the same file name.",
748
+ getCopyCandidatePath(fileName, new Set(Object.keys(workspace.files))),
749
+ );
750
+ if (!next) return;
751
+ const target = resolveDestinationPath(
752
+ next,
753
+ fileName,
754
+ getKnownFolders(Object.keys(workspace.files)),
755
+ );
756
+ if (!target) return;
757
+ applyFilePathOperation(fileName, target, "copy");
758
+ setOpenFileMenu(null);
759
+ };
760
+
761
+ const handleFileDragStart = (
762
+ e: React.DragEvent<HTMLDivElement>,
763
+ fileName: string,
764
+ ) => {
765
+ setDraggingFile(fileName);
766
+ e.dataTransfer.effectAllowed = "copyMove";
767
+ e.dataTransfer.setData("text/x-gha-lab-file", fileName);
768
+ e.dataTransfer.setData("text/plain", fileName);
769
+ };
770
+
771
+ const handleFolderDragOver = (
772
+ e: React.DragEvent<HTMLElement>,
773
+ folder: string,
774
+ ) => {
775
+ if (!draggingFile) return;
776
+ e.preventDefault();
777
+ e.dataTransfer.dropEffect = e.altKey ? "copy" : "move";
778
+ setDragOverFolder(folder);
779
+ };
780
+
781
+ const handleFolderDrop = (
782
+ e: React.DragEvent<HTMLElement>,
783
+ folder: string,
784
+ ) => {
785
+ e.preventDefault();
786
+ const sourceFile =
787
+ e.dataTransfer.getData("text/x-gha-lab-file") || draggingFile;
788
+ setDraggingFile(null);
789
+ setDragOverFolder(null);
790
+ if (!sourceFile) return;
791
+
792
+ const operation = e.altKey ? "copy" : "move";
793
+ const sourceGroup = selectedFiles.has(sourceFile)
794
+ ? selectedFileList
795
+ : [sourceFile];
796
+ if (sourceGroup.length > 1) {
797
+ applyBulkFolderOperation(sourceGroup, folder, operation);
798
+ } else {
799
+ applyFilePathOperation(
800
+ sourceFile,
801
+ joinLabPath(folder, baseName(sourceFile)),
802
+ operation,
803
+ operation === "copy" ? "unique" : "prompt",
804
+ );
805
+ }
806
+ };
807
+
325
808
  const addFile = () => {
326
809
  const name = window.prompt(
327
810
  "New file path (e.g. .github/workflows/release.yml)",
@@ -354,6 +837,41 @@ export default function GithubActionsLabModal() {
354
837
  );
355
838
  setActiveFile(remaining[0] ?? "");
356
839
  }
840
+ setSelectedFiles((prev) => {
841
+ if (!prev.has(fileName)) return prev;
842
+ const next = new Set(prev);
843
+ next.delete(fileName);
844
+ return next;
845
+ });
846
+ setOpenFileMenu(null);
847
+ };
848
+
849
+ const deleteSelectedFiles = () => {
850
+ if (selectedFileList.length === 0) return;
851
+ if (
852
+ !window.confirm(
853
+ `Delete ${selectedFileList.length} selected file${selectedFileList.length === 1 ? "" : "s"}?`,
854
+ )
855
+ ) {
856
+ return;
857
+ }
858
+ const selected = new Set(selectedFileList);
859
+ setWorkspace((prev) => {
860
+ const files = { ...prev.files };
861
+ for (const fileName of selected) delete files[fileName];
862
+ const nextActive = selected.has(prev.activeFile)
863
+ ? (Object.keys(files)[0] ?? "")
864
+ : prev.activeFile;
865
+ return { ...prev, activeFile: nextActive, files };
866
+ });
867
+ if (selected.has(activeFile)) {
868
+ const remaining = Object.keys(workspace.files).filter(
869
+ (fileName) => !selected.has(fileName),
870
+ );
871
+ setActiveFile(remaining[0] ?? "");
872
+ }
873
+ setSelectedFiles(new Set());
874
+ setBulkMenuOpen(false);
357
875
  };
358
876
 
359
877
  // ── Save lab as context file ──────────────────────────────────────
@@ -966,18 +1484,132 @@ interface ImportMeta {
966
1484
  }}
967
1485
  >
968
1486
  <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>
1487
+ <div className="flex items-center gap-1 min-w-0">
1488
+ <span className="text-[10px] font-semibold tracking-widest text-slate-500">
1489
+ FILES
1490
+ </span>
1491
+ {selectedFileList.length > 0 && (
1492
+ <span className="rounded bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-medium text-amber-200">
1493
+ {selectedFileList.length}
1494
+ </span>
1495
+ )}
1496
+ </div>
1497
+ <div className="relative flex items-center gap-1">
1498
+ <button
1499
+ onClick={(e) => {
1500
+ e.stopPropagation();
1501
+ setSelectMode((prev) => {
1502
+ const next = !prev;
1503
+ if (!next) setSelectedFiles(new Set());
1504
+ return next;
1505
+ });
1506
+ setBulkMenuOpen(false);
1507
+ setOpenFileMenu(null);
1508
+ }}
1509
+ className={`p-1 rounded hover:bg-slate-800/60 ${
1510
+ selectMode
1511
+ ? "text-amber-300"
1512
+ : "text-slate-400 hover:text-amber-300"
1513
+ }`}
1514
+ title={selectMode ? "Exit selection mode" : "Select files"}
1515
+ >
1516
+ <ListChecks className="w-3.5 h-3.5" />
1517
+ </button>
1518
+ {selectedFileList.length > 0 && (
1519
+ <>
1520
+ <button
1521
+ onClick={(e) => {
1522
+ e.stopPropagation();
1523
+ setBulkMenuOpen((v) => !v);
1524
+ setOpenFileMenu(null);
1525
+ }}
1526
+ className="rounded px-1.5 py-0.5 text-[11px] text-slate-400 hover:bg-slate-800/60 hover:text-amber-200"
1527
+ title="Selected file actions"
1528
+ >
1529
+ Selected ▾
1530
+ </button>
1531
+ {bulkMenuOpen && (
1532
+ <div
1533
+ onClick={(e) => e.stopPropagation()}
1534
+ 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"
1535
+ >
1536
+ <button
1537
+ onClick={() => {
1538
+ moveFilesToFolder(selectedFileList);
1539
+ setBulkMenuOpen(false);
1540
+ }}
1541
+ className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-slate-200 hover:bg-slate-800/70"
1542
+ >
1543
+ <Pencil className="w-3 h-3 text-amber-300" />
1544
+ Move to folder…
1545
+ </button>
1546
+ <button
1547
+ onClick={() => {
1548
+ copyFilesToFolder(selectedFileList);
1549
+ setBulkMenuOpen(false);
1550
+ }}
1551
+ className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-slate-200 hover:bg-slate-800/70"
1552
+ >
1553
+ <Copy className="w-3 h-3 text-sky-300" />
1554
+ Copy to folder…
1555
+ </button>
1556
+ <button
1557
+ onClick={() => {
1558
+ setSelectedFiles(new Set());
1559
+ setSelectMode(false);
1560
+ setBulkMenuOpen(false);
1561
+ }}
1562
+ className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-slate-400 hover:bg-slate-800/70"
1563
+ >
1564
+ Clear selection
1565
+ </button>
1566
+ <button
1567
+ onClick={deleteSelectedFiles}
1568
+ 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"
1569
+ >
1570
+ <Trash2 className="w-3 h-3" />
1571
+ Delete selected
1572
+ </button>
1573
+ </div>
1574
+ )}
1575
+ </>
1576
+ )}
1577
+ <button
1578
+ onClick={addFile}
1579
+ className="p-1 rounded text-slate-400 hover:text-amber-300 hover:bg-slate-800/60"
1580
+ title="Add file"
1581
+ >
1582
+ <FilePlus className="w-3.5 h-3.5" />
1583
+ </button>
1584
+ </div>
979
1585
  </div>
980
- <div className="flex-1 overflow-auto p-1 text-xs">
1586
+ <div
1587
+ className="flex-1 overflow-auto p-1 text-xs"
1588
+ onClick={() => {
1589
+ setOpenFileMenu(null);
1590
+ setBulkMenuOpen(false);
1591
+ }}
1592
+ >
1593
+ {draggingFile && (
1594
+ <div
1595
+ onDragOver={(e) => handleFolderDragOver(e, "")}
1596
+ onDragLeave={() =>
1597
+ setDragOverFolder((current) =>
1598
+ current === "" ? null : current,
1599
+ )
1600
+ }
1601
+ onDrop={(e) => handleFolderDrop(e, "")}
1602
+ className={`mb-1 rounded border border-dashed px-2 py-1 text-[11px] ${
1603
+ dragOverFolder === ""
1604
+ ? "border-amber-400/70 bg-amber-500/10 text-amber-200"
1605
+ : "border-slate-700 text-slate-500"
1606
+ }`}
1607
+ >
1608
+ Drop{" "}
1609
+ {selectedFiles.has(draggingFile) ? "selected files" : "file"}{" "}
1610
+ in workspace root • hold Option/Alt to copy
1611
+ </div>
1612
+ )}
981
1613
  {grouped.map(({ folder, files }) => {
982
1614
  const collapsed = collapsedFolders.has(folder);
983
1615
  return (
@@ -985,7 +1617,15 @@ interface ImportMeta {
985
1617
  {folder && (
986
1618
  <button
987
1619
  onClick={() => toggleFolder(folder)}
1620
+ onDragOver={(e) => handleFolderDragOver(e, folder)}
1621
+ onDragLeave={() =>
1622
+ setDragOverFolder((current) =>
1623
+ current === folder ? null : current,
1624
+ )
1625
+ }
1626
+ onDrop={(e) => handleFolderDrop(e, folder)}
988
1627
  className="flex items-center gap-1 w-full px-1 py-0.5 text-slate-400 hover:text-slate-200"
1628
+ title="Drop a file here to move it. Hold Option/Alt while dropping to copy."
989
1629
  >
990
1630
  {collapsed ? (
991
1631
  <ChevronRight className="w-3 h-3" />
@@ -993,34 +1633,127 @@ interface ImportMeta {
993
1633
  <ChevronDown className="w-3 h-3" />
994
1634
  )}
995
1635
  <Folder className="w-3 h-3" />
996
- <span className="truncate">{folder}/</span>
1636
+ <span
1637
+ className={`truncate rounded px-1 ${
1638
+ dragOverFolder === folder
1639
+ ? "bg-amber-500/15 text-amber-200"
1640
+ : ""
1641
+ }`}
1642
+ >
1643
+ {folder}/
1644
+ </span>
997
1645
  </button>
998
1646
  )}
999
1647
  {!collapsed &&
1000
1648
  files.map((filePath) => (
1001
1649
  <div
1002
1650
  key={filePath}
1003
- className={`group flex items-center gap-1 pl-${folder ? 5 : 1} pr-1 py-0.5 rounded cursor-pointer ${
1651
+ data-selected={selectedFiles.has(filePath)}
1652
+ draggable
1653
+ onDragStart={(e) => handleFileDragStart(e, filePath)}
1654
+ onDragEnd={() => {
1655
+ setDraggingFile(null);
1656
+ setDragOverFolder(null);
1657
+ }}
1658
+ className={`group relative flex items-center gap-1 pl-${folder ? 5 : 1} pr-1 py-0.5 rounded cursor-pointer ${
1004
1659
  activeFile === filePath
1005
1660
  ? "bg-amber-500/15 text-amber-200"
1006
- : "text-slate-300 hover:bg-slate-800/40"
1661
+ : selectedFiles.has(filePath)
1662
+ ? "bg-sky-500/10 text-sky-100 hover:bg-sky-500/15"
1663
+ : "text-slate-300 hover:bg-slate-800/40"
1007
1664
  }`}
1008
1665
  onClick={() => setActiveFile(filePath)}
1009
1666
  style={{ paddingLeft: folder ? 20 : 6 }}
1010
1667
  >
1668
+ {(selectMode || selectedFiles.has(filePath)) && (
1669
+ <input
1670
+ type="checkbox"
1671
+ checked={selectedFiles.has(filePath)}
1672
+ onClick={(e) => e.stopPropagation()}
1673
+ onChange={() => toggleFileSelection(filePath)}
1674
+ className="h-3 w-3 shrink-0 accent-amber-400"
1675
+ title="Select file"
1676
+ />
1677
+ )}
1011
1678
  <span className="truncate flex-1">
1012
1679
  {baseName(filePath)}
1013
1680
  </span>
1014
1681
  <button
1015
1682
  onClick={(e) => {
1016
1683
  e.stopPropagation();
1017
- deleteFile(filePath);
1684
+ setOpenFileMenu((current) =>
1685
+ current === filePath ? null : filePath,
1686
+ );
1687
+ setBulkMenuOpen(false);
1018
1688
  }}
1019
- className="opacity-0 group-hover:opacity-100 p-0.5 text-slate-500 hover:text-red-400"
1020
- title="Delete"
1689
+ className="rounded px-1 text-slate-500 opacity-70 hover:bg-slate-800/70 hover:text-amber-200 group-hover:opacity-100"
1690
+ title="File actions"
1021
1691
  >
1022
- <Trash2 className="w-3 h-3" />
1692
+
1023
1693
  </button>
1694
+ {openFileMenu === filePath && (
1695
+ <div
1696
+ onClick={(e) => e.stopPropagation()}
1697
+ 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"
1698
+ >
1699
+ <button
1700
+ onClick={() => moveFile(filePath)}
1701
+ className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-slate-200 hover:bg-slate-800/70"
1702
+ >
1703
+ <Pencil className="w-3 h-3 text-amber-300" />
1704
+ Move / rename…
1705
+ </button>
1706
+ <button
1707
+ onClick={() => copyFile(filePath)}
1708
+ className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-slate-200 hover:bg-slate-800/70"
1709
+ >
1710
+ <Copy className="w-3 h-3 text-sky-300" />
1711
+ Copy to path…
1712
+ </button>
1713
+ <button
1714
+ onClick={() => {
1715
+ toggleFileSelection(filePath);
1716
+ setOpenFileMenu(null);
1717
+ }}
1718
+ 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"
1719
+ >
1720
+ <ListChecks className="w-3 h-3 text-amber-300" />
1721
+ {selectedFiles.has(filePath)
1722
+ ? "Deselect"
1723
+ : "Select"}
1724
+ </button>
1725
+ {selectedFileList.length > 1 &&
1726
+ selectedFiles.has(filePath) && (
1727
+ <>
1728
+ <button
1729
+ onClick={() => {
1730
+ moveFilesToFolder(selectedFileList);
1731
+ setOpenFileMenu(null);
1732
+ }}
1733
+ 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"
1734
+ >
1735
+ Move selected…
1736
+ </button>
1737
+ <button
1738
+ onClick={() => {
1739
+ copyFilesToFolder(selectedFileList);
1740
+ setOpenFileMenu(null);
1741
+ }}
1742
+ className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-slate-200 hover:bg-slate-800/70"
1743
+ >
1744
+ Copy selected…
1745
+ </button>
1746
+ </>
1747
+ )}
1748
+ <button
1749
+ onClick={() => deleteFile(filePath)}
1750
+ 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"
1751
+ >
1752
+ <Trash2 className="w-3 h-3" />
1753
+ Delete
1754
+ </button>
1755
+ </div>
1756
+ )}
1024
1757
  </div>
1025
1758
  ))}
1026
1759
  </div>
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.19.0"
2
+ "version": "0.21.0"
3
3
  }
@@ -105,6 +105,21 @@ const ALLOWED_ACT_FLAGS = new Set([
105
105
  "-v",
106
106
  ]);
107
107
 
108
+ // act asks an interactive first-run question for runner image size unless it
109
+ // already has platform mappings. The lab console cannot answer arrow-key
110
+ // prompts cleanly, so we inject the Medium image mappings by default. Users can
111
+ // still override with -P / --platform in the command.
112
+ const DEFAULT_ACT_PLATFORM_ARGS = [
113
+ "-P",
114
+ "ubuntu-latest=catthehacker/ubuntu:act-latest",
115
+ "-P",
116
+ "ubuntu-24.04=catthehacker/ubuntu:act-24.04",
117
+ "-P",
118
+ "ubuntu-22.04=catthehacker/ubuntu:act-22.04",
119
+ "-P",
120
+ "ubuntu-20.04=catthehacker/ubuntu:act-20.04",
121
+ ];
122
+
108
123
  // ─── Utilities ───────────────────────────────────────────────────────────
109
124
 
110
125
  function getGhaRunsDir(): string {
@@ -304,6 +319,24 @@ interface ParsedActCommand {
304
319
  displayCommand: string;
305
320
  }
306
321
 
322
+ function hasPlatformOverride(args: string[]): boolean {
323
+ return args.some(
324
+ (arg) =>
325
+ arg === "-P" ||
326
+ arg === "--platform" ||
327
+ arg.startsWith("-P=") ||
328
+ arg.startsWith("--platform="),
329
+ );
330
+ }
331
+
332
+ function withDefaultActPlatforms(args: string[]): {
333
+ args: string[];
334
+ injected: boolean;
335
+ } {
336
+ if (hasPlatformOverride(args)) return { args, injected: false };
337
+ return { args: [...args, ...DEFAULT_ACT_PLATFORM_ARGS], injected: true };
338
+ }
339
+
307
340
  function parseActCommand(command: string): ParsedActCommand {
308
341
  const tokens = splitCommand(command);
309
342
  if (tokens.length === 0) throw new Error("Type a command to run");
@@ -681,6 +714,7 @@ export async function streamGhaCommand(
681
714
  const workspace = parseWorkspace(input.workspace);
682
715
 
683
716
  const parsed = parseActCommand(input.command);
717
+ const platformArgs = withDefaultActPlatforms(parsed.args);
684
718
  const sessionKey =
685
719
  input.fileId ?? input.questionId ?? `draft-${randomUUID()}`;
686
720
  const runId = randomUUID();
@@ -696,12 +730,19 @@ export async function streamGhaCommand(
696
730
 
697
731
  const emit = (msg: GhaStreamMessage) => input.onMessage?.(msg);
698
732
  emit({ type: "output", kind: "info", text: parsed.displayCommand });
733
+ if (platformArgs.injected) {
734
+ emit({
735
+ type: "output",
736
+ kind: "info",
737
+ text: "[info] Using default Medium act runner image mappings. Pass -P/--platform to override.\n",
738
+ });
739
+ }
699
740
 
700
741
  // Track per-job status from act's prefixed stdout/stderr lines so the
701
742
  // client can render a live DAG in addition to the raw console.
702
743
  const tracker = new JobTracker((job) => emit({ type: "job", job }));
703
744
 
704
- const child = spawn("act", parsed.args, {
745
+ const child = spawn("act", platformArgs.args, {
705
746
  cwd: workspaceDir,
706
747
  env: {
707
748
  ...process.env,
@@ -2240,7 +2240,6 @@ const DEFAULT_IGNORE_DIRS = [
2240
2240
  "bin",
2241
2241
  "obj",
2242
2242
  ".vs",
2243
- "packages",
2244
2243
  "TestResults",
2245
2244
  "publish",
2246
2245
  // Python / misc