create-interview-cockpit 0.27.0 → 0.28.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.
@@ -5,8 +5,7 @@ import {
5
5
  Copy,
6
6
  FilePlus,
7
7
  Folder,
8
- GitBranch,
9
- KeyRound,
8
+ GitPullRequest,
10
9
  ListChecks,
11
10
  Loader2,
12
11
  Maximize2,
@@ -18,6 +17,7 @@ import {
18
17
  Pencil,
19
18
  Play,
20
19
  Save,
20
+ Settings,
21
21
  StopCircle,
22
22
  Terminal,
23
23
  Trash2,
@@ -39,12 +39,19 @@ import {
39
39
  serializeGhaLabWorkspace,
40
40
  } from "../githubActionsLab";
41
41
  import type { GithubActionsLabWorkspace } from "../types";
42
- import type { GithubActionsLabEnvironmentEntry } from "../types";
43
42
  import * as api from "../api";
44
43
  import type { GhaJobSnapshot, GhaStreamMessage } from "../api";
45
44
  import GhaJobsPanel from "./GhaJobsPanel";
46
45
  import GhaHistoryPanel from "./GhaHistoryPanel";
47
- import GhaConcurrencyPanel from "./GhaConcurrencyPanel";
46
+ import PullRequestPanel from "./PullRequestPanel";
47
+ import SettingsPanel from "./SettingsPanel";
48
+ import {
49
+ evaluateCodeOwners,
50
+ findCodeOwnersPath,
51
+ isCodeOwnersPath,
52
+ lintCodeOwnersAgainstOrg,
53
+ parseCodeOwners,
54
+ } from "../codeowners";
48
55
  import {
49
56
  defaultContextForEvent,
50
57
  evaluateConcurrencyFor,
@@ -215,6 +222,16 @@ function getGhaLabModelPath(filePath: string): string {
215
222
  .join("/")}`;
216
223
  }
217
224
 
225
+ // Translate the act runner's GhaJobStatus into the PR-side "check run"
226
+ // vocabulary used by codeowners.ts. "pending" is a pre-run state we
227
+ // don't get from act in practice, so we treat it as "queued" for parity.
228
+ function mapJobStatusToCheck(
229
+ s: GhaJobSnapshot["status"],
230
+ ): "queued" | "running" | "success" | "failed" | "cancelled" | "skipped" {
231
+ if (s === "pending") return "queued";
232
+ return s;
233
+ }
234
+
218
235
  // Tiny grouped-by-folder list to keep the modal lean.
219
236
  function groupByFolder(paths: string[]): { folder: string; files: string[] }[] {
220
237
  const map = new Map<string, string[]>();
@@ -239,41 +256,9 @@ interface ConsoleLine {
239
256
 
240
257
  type GhaEnvironmentKind = "variables" | "secrets" | "env";
241
258
 
242
- const GHA_ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
243
-
244
259
  const GHA_ENVIRONMENT_SECTIONS: Array<{
245
260
  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
- ];
261
+ }> = [{ kind: "variables" }, { kind: "secrets" }, { kind: "env" }];
277
262
 
278
263
  export default function GithubActionsLabModal() {
279
264
  const {
@@ -328,10 +313,23 @@ export default function GithubActionsLabModal() {
328
313
  // Live job snapshots reported by the server during the active run.
329
314
  // Reset every time the user kicks off a new run.
330
315
  const [liveJobs, setLiveJobs] = useState<GhaJobSnapshot[]>([]);
331
- // "console" | "jobs" | "env" | "concurrency" | "history" controls the right pane tab.
332
- const [rightTab, setRightTab] = useState<
333
- "console" | "jobs" | "env" | "concurrency" | "history"
334
- >("console");
316
+ // Mirror into a ref so the run-complete handler can read the freshest
317
+ // job set without racing the React state queue.
318
+ const liveJobsRef = useRef<GhaJobSnapshot[]>([]);
319
+ useEffect(() => {
320
+ liveJobsRef.current = liveJobs;
321
+ }, [liveJobs]);
322
+ // ── Right-pane tab state ───────────────────────────────────────
323
+ // The right pane mimics github.com's repo nav: three top-level tabs
324
+ // (Actions / Pull request / Settings) and Actions has its own sub-
325
+ // tabs (Console / Jobs / History). Splitting state keeps the sub-tab
326
+ // sticky as the user bounces between top tabs.
327
+ const [topTab, setTopTab] = useState<"actions" | "pr" | "settings">(
328
+ "actions",
329
+ );
330
+ const [rightTab, setRightTab] = useState<"console" | "jobs" | "history">(
331
+ "console",
332
+ );
335
333
  const environmentEntryCount = useMemo(
336
334
  () =>
337
335
  GHA_ENVIRONMENT_SECTIONS.reduce((total, section) => {
@@ -964,46 +962,9 @@ export default function GithubActionsLabModal() {
964
962
  };
965
963
 
966
964
  // ── 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
- );
1006
- };
965
+ // The Environments table itself lives inside SettingsPanel; we keep
966
+ // only the bits the modal still needs to build act --var/secret/env
967
+ // files on every run.
1007
968
 
1008
969
  // ── Save lab as context file ──────────────────────────────────────
1009
970
  const handleSave = useCallback(async () => {
@@ -1119,9 +1080,58 @@ export default function GithubActionsLabModal() {
1119
1080
  // success/failed" lifecycle for this invocation. Switch tabs to Jobs
1120
1081
  // so the visualisation is immediately visible.
1121
1082
  setLiveJobs([]);
1083
+ setTopTab("actions");
1122
1084
  setRightTab("jobs");
1123
1085
  appendConsole({ kind: "input", text: `$ ${run.command}\n` });
1124
1086
 
1087
+ // For push / pull_request events, surface a quick CODEOWNERS dry-run
1088
+ // so the user sees who would be auto-requested as a reviewer, just
1089
+ // like GitHub posts on a real PR. We do this client-side because
1090
+ // act has no concept of CODEOWNERS — it's a github.com feature.
1091
+ try {
1092
+ const eventName = run.command.match(/^act\s+([a-z_]+)/i)?.[1];
1093
+ if (
1094
+ (eventName === "push" || eventName === "pull_request") &&
1095
+ workspace.pullRequest?.changedFiles?.length
1096
+ ) {
1097
+ const codeownersPath = findCodeOwnersPath(workspace.files);
1098
+ if (codeownersPath) {
1099
+ const parsed = parseCodeOwners(
1100
+ workspace.files[codeownersPath] ?? "",
1101
+ );
1102
+ const evalResult = evaluateCodeOwners(
1103
+ parsed.rules,
1104
+ workspace.pullRequest.changedFiles,
1105
+ workspace.ghOrg,
1106
+ );
1107
+ appendConsole({
1108
+ kind: "info",
1109
+ text: `[CODEOWNERS] ${codeownersPath} \u2192 ${evalResult.ownedFiles.length}/${workspace.pullRequest.changedFiles.length} files owned`,
1110
+ });
1111
+ for (const match of evalResult.perFile) {
1112
+ if (match.rule && match.owners.length > 0) {
1113
+ appendConsole({
1114
+ kind: "info",
1115
+ text: `[CODEOWNERS] ${match.path} \u2192 ${match.owners
1116
+ .map((o) => `@${o}`)
1117
+ .join(" ")}`,
1118
+ });
1119
+ }
1120
+ }
1121
+ if (evalResult.requestedHandles.length > 0) {
1122
+ appendConsole({
1123
+ kind: "info",
1124
+ text: `[CODEOWNERS] Reviewers requested: ${evalResult.requestedHandles
1125
+ .map((h) => `@${h}`)
1126
+ .join(", ")}`,
1127
+ });
1128
+ }
1129
+ }
1130
+ }
1131
+ } catch {
1132
+ // CODEOWNERS preview is informational only \u2014 never block the run.
1133
+ }
1134
+
1125
1135
  // Flip this run's record to running so the Concurrency panel ticks.
1126
1136
  setConcurrencyRuns((rs) =>
1127
1137
  rs.map((r) =>
@@ -1175,6 +1185,46 @@ export default function GithubActionsLabModal() {
1175
1185
  kind: "info",
1176
1186
  text: `\n[done] exit=${message.exitCode} • ${message.durationMs}ms\n`,
1177
1187
  });
1188
+ // Persist this run as the PR's "last check run" so the Pull
1189
+ // Request tab can grade required status checks. We pull the
1190
+ // freshest job snapshots from the ref, not the closure-captured
1191
+ // value, because `setLiveJobs` updates above may not have
1192
+ // flushed by the time `complete` arrives.
1193
+ const completedAt = new Date().toISOString();
1194
+ setWorkspace((prev) => {
1195
+ const jobs = liveJobsRef.current.map((j) => {
1196
+ const baseJob = {
1197
+ name: j.name,
1198
+ status: mapJobStatusToCheck(j.status),
1199
+ } as {
1200
+ name: string;
1201
+ status:
1202
+ | "queued"
1203
+ | "running"
1204
+ | "success"
1205
+ | "failed"
1206
+ | "cancelled"
1207
+ | "skipped";
1208
+ durationMs?: number;
1209
+ };
1210
+ if (typeof j.durationMs === "number") {
1211
+ baseJob.durationMs = j.durationMs;
1212
+ }
1213
+ return baseJob;
1214
+ });
1215
+ if (jobs.length === 0) return prev;
1216
+ return {
1217
+ ...prev,
1218
+ pullRequest: {
1219
+ ...(prev.pullRequest ?? { changedFiles: [] }),
1220
+ lastCheckRun: {
1221
+ completedAt,
1222
+ ...(workflow ? { workflow } : {}),
1223
+ jobs,
1224
+ },
1225
+ },
1226
+ };
1227
+ });
1178
1228
  // Refresh History so the just-completed run shows up.
1179
1229
  setHistoryNonce((n) => n + 1);
1180
1230
  }
@@ -1595,6 +1645,46 @@ interface ImportMeta {
1595
1645
  [activeFile],
1596
1646
  );
1597
1647
 
1648
+ // ── CODEOWNERS live validation ───────────────────────────────────
1649
+ // When the CODEOWNERS file is open, push diagnostics into Monaco so
1650
+ // the user sees red squiggles for bad owner handles (just like the
1651
+ // GitHub UI flags unknown users in the CODEOWNERS settings page).
1652
+ useEffect(() => {
1653
+ const monaco = monacoRef.current;
1654
+ if (!monaco) return;
1655
+ // Clear our markers on every editor change; we only set them when
1656
+ // the active file is a CODEOWNERS file.
1657
+ const codeownersOwner = "gha-lab-codeowners";
1658
+ if (!isCodeOwnersPath(activeFile)) {
1659
+ // Clear markers on whatever model is currently active.
1660
+ const uri = monaco.Uri.parse(getGhaLabModelPath(activeFile));
1661
+ const model = monaco.editor.getModel(uri);
1662
+ if (model) monaco.editor.setModelMarkers(model, codeownersOwner, []);
1663
+ return;
1664
+ }
1665
+ const body = workspace.files[activeFile] ?? "";
1666
+ const parsed = parseCodeOwners(body);
1667
+ const issues = lintCodeOwnersAgainstOrg(parsed, workspace.ghOrg);
1668
+ const uri = monaco.Uri.parse(getGhaLabModelPath(activeFile));
1669
+ const model = monaco.editor.getModel(uri);
1670
+ if (!model) return;
1671
+ monaco.editor.setModelMarkers(
1672
+ model,
1673
+ codeownersOwner,
1674
+ issues.map((issue) => ({
1675
+ severity:
1676
+ issue.severity === "error"
1677
+ ? monaco.MarkerSeverity.Error
1678
+ : monaco.MarkerSeverity.Warning,
1679
+ message: issue.message,
1680
+ startLineNumber: issue.line,
1681
+ endLineNumber: issue.line,
1682
+ startColumn: 1,
1683
+ endColumn: (model.getLineContent(issue.line)?.length ?? 0) + 1,
1684
+ })),
1685
+ );
1686
+ }, [activeFile, workspace.files, workspace.ghOrg]);
1687
+
1598
1688
  // ── Render ────────────────────────────────────────────────────────
1599
1689
  const containerStyle: React.CSSProperties = maximized
1600
1690
  ? { top: 0, left: 0, width: "100vw", height: "100vh" }
@@ -1659,7 +1749,7 @@ interface ImportMeta {
1659
1749
  <div className="flex items-center gap-2">
1660
1750
  <span className="inline-block h-2 w-2 rounded-full bg-amber-400" />
1661
1751
  <span className="text-xs font-semibold tracking-widest text-amber-300">
1662
- GITHUB ACTIONS LAB
1752
+ GITHUB LAB
1663
1753
  </span>
1664
1754
  </div>
1665
1755
  <input
@@ -2143,7 +2233,7 @@ interface ImportMeta {
2143
2233
  </div>
2144
2234
  </div>
2145
2235
 
2146
- {/* Right pane: tabbed Console / Jobs / Env / History */}
2236
+ {/* Right pane: GitHub-style top tabs (Actions / PR / Settings) */}
2147
2237
  <div
2148
2238
  className="min-h-0 flex flex-col bg-slate-950 overflow-hidden"
2149
2239
  style={{
@@ -2151,46 +2241,60 @@ interface ImportMeta {
2151
2241
  visibility: rightCollapsed ? "hidden" : undefined,
2152
2242
  }}
2153
2243
  >
2154
- <div className="flex items-center justify-between border-b border-slate-800/60 px-2 py-1">
2155
- <div className="flex items-center gap-0.5 text-[11px]">
2156
- {(
2157
- [
2158
- { id: "console", label: "Console" },
2159
- { id: "jobs", label: "Jobs" },
2160
- { id: "env", label: "Env" },
2161
- { id: "concurrency", label: "Concurrency" },
2162
- { id: "history", label: "History" },
2163
- ] as const
2164
- ).map((t) => (
2165
- <button
2166
- key={t.id}
2167
- onClick={() => setRightTab(t.id)}
2168
- className={`px-2 py-1 rounded ${
2169
- rightTab === t.id
2170
- ? "bg-slate-800/70 text-amber-200"
2171
- : "text-slate-400 hover:text-slate-200 hover:bg-slate-800/40"
2172
- }`}
2173
- >
2174
- {t.id === "console" && (
2175
- <Terminal className="inline w-3 h-3 mr-1 -mt-px" />
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
- )}
2183
- {t.label}
2184
- {t.id === "jobs" && running && (
2185
- <span className="ml-1 inline-block w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse" />
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
- )}
2192
- </button>
2193
- ))}
2244
+ {/* Top-level nav mimicking github.com's repo header. We keep
2245
+ three tabs only — the saturated 6-tab bar moved its CI
2246
+ config items (Env, Concurrency) into a real Settings page. */}
2247
+ <div className="flex items-center justify-between border-b border-slate-800/60 bg-slate-950 px-2">
2248
+ <div className="flex items-center gap-2 text-[12px]">
2249
+ {[
2250
+ {
2251
+ id: "actions" as const,
2252
+ label: "Actions",
2253
+ icon: Play,
2254
+ },
2255
+ {
2256
+ id: "pr" as const,
2257
+ label: "Pull request",
2258
+ icon: GitPullRequest,
2259
+ },
2260
+ {
2261
+ id: "settings" as const,
2262
+ label: "Settings",
2263
+ icon: Settings,
2264
+ },
2265
+ ].map((t) => {
2266
+ const Icon = t.icon;
2267
+ const isActive = topTab === t.id;
2268
+ return (
2269
+ <button
2270
+ key={t.id}
2271
+ onClick={() => setTopTab(t.id)}
2272
+ className={`-mb-px flex items-center gap-1.5 border-b-2 px-1.5 py-2 ${
2273
+ isActive
2274
+ ? "border-amber-500 text-amber-200"
2275
+ : "border-transparent text-slate-400 hover:text-slate-200"
2276
+ }`}
2277
+ >
2278
+ <Icon className="h-3.5 w-3.5" />
2279
+ {t.label}
2280
+ {t.id === "actions" && running && (
2281
+ <span className="ml-0.5 inline-block h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse" />
2282
+ )}
2283
+ {t.id === "pr" &&
2284
+ (workspace.pullRequest?.changedFiles.length ?? 0) >
2285
+ 0 && (
2286
+ <span className="ml-0.5 rounded bg-emerald-500/15 px-1 text-[10px] text-emerald-200">
2287
+ {workspace.pullRequest?.changedFiles.length}
2288
+ </span>
2289
+ )}
2290
+ {t.id === "settings" && environmentEntryCount > 0 && (
2291
+ <span className="ml-0.5 rounded bg-slate-700/60 px-1 text-[10px] text-slate-300">
2292
+ {environmentEntryCount}
2293
+ </span>
2294
+ )}
2295
+ </button>
2296
+ );
2297
+ })}
2194
2298
  </div>
2195
2299
  <div className="flex items-center gap-1">
2196
2300
  {running && (
@@ -2202,7 +2306,7 @@ interface ImportMeta {
2202
2306
  <StopCircle className="w-3.5 h-3.5" />
2203
2307
  </button>
2204
2308
  )}
2205
- {rightTab === "console" && (
2309
+ {topTab === "actions" && rightTab === "console" && (
2206
2310
  <button
2207
2311
  onClick={clearConsole}
2208
2312
  className="p-1 rounded text-slate-500 hover:text-slate-200 hover:bg-slate-800/60"
@@ -2214,7 +2318,47 @@ interface ImportMeta {
2214
2318
  </div>
2215
2319
  </div>
2216
2320
 
2217
- {rightTab === "console" && (
2321
+ {/* Actions sub-tabs (Workflow runs view): only render when the
2322
+ user is on the Actions top tab. Console + Jobs + History
2323
+ are sub-views of the same "runs" workflow on github.com. */}
2324
+ {topTab === "actions" && (
2325
+ <div className="flex items-center gap-0.5 border-b border-slate-800/40 bg-slate-900/30 px-2 py-1 text-[11px]">
2326
+ {[
2327
+ {
2328
+ id: "console" as const,
2329
+ label: "Workflow runs",
2330
+ icon: Terminal,
2331
+ },
2332
+ { id: "jobs" as const, label: "Jobs", icon: ListChecks },
2333
+ {
2334
+ id: "history" as const,
2335
+ label: "History",
2336
+ icon: ListChecks,
2337
+ },
2338
+ ].map((t) => {
2339
+ const Icon = t.icon;
2340
+ return (
2341
+ <button
2342
+ key={t.id}
2343
+ onClick={() => setRightTab(t.id)}
2344
+ className={`flex items-center gap-1 rounded px-2 py-1 ${
2345
+ rightTab === t.id
2346
+ ? "bg-slate-800/70 text-amber-200"
2347
+ : "text-slate-400 hover:text-slate-200 hover:bg-slate-800/40"
2348
+ }`}
2349
+ >
2350
+ <Icon className="h-3 w-3" />
2351
+ {t.label}
2352
+ {t.id === "jobs" && running && (
2353
+ <span className="ml-1 inline-block w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse" />
2354
+ )}
2355
+ </button>
2356
+ );
2357
+ })}
2358
+ </div>
2359
+ )}
2360
+
2361
+ {topTab === "actions" && rightTab === "console" && (
2218
2362
  <>
2219
2363
  <div className="flex-1 min-h-0 overflow-auto px-3 py-2 text-[11px] font-mono whitespace-pre-wrap break-words">
2220
2364
  {consoleLines.length === 0 ? (
@@ -2275,7 +2419,7 @@ interface ImportMeta {
2275
2419
  </>
2276
2420
  )}
2277
2421
 
2278
- {rightTab === "jobs" && (
2422
+ {topTab === "actions" && rightTab === "jobs" && (
2279
2423
  <GhaJobsPanel
2280
2424
  workflowYaml={workflow ? workspace.files[workflow] : undefined}
2281
2425
  jobs={liveJobs}
@@ -2289,154 +2433,37 @@ interface ImportMeta {
2289
2433
  />
2290
2434
  )}
2291
2435
 
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
- )}
2436
+ {topTab === "settings" && (
2437
+ <SettingsPanel
2438
+ workspace={workspace}
2439
+ onChange={(updater) => setWorkspace(updater)}
2423
2440
  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.
2441
+ workflowYaml={workflow ? workspace.files[workflow] : undefined}
2442
+ concurrencyRuns={concurrencyRuns}
2443
+ concurrencyContext={concurrencyContext}
2444
+ onConcurrencyContextChange={setConcurrencyContext}
2445
+ onClearConcurrencyRuns={() => {
2430
2446
  setConcurrencyRuns((rs) =>
2431
2447
  rs.filter(
2432
2448
  (r) => r.status === "running" || r.status === "pending",
2433
2449
  ),
2434
2450
  );
2435
2451
  }}
2452
+ onResetWorkspace={() => {
2453
+ setWorkspace(cloneGhaLabWorkspace(DEFAULT_GHA_LAB));
2454
+ setTopTab("actions");
2455
+ }}
2456
+ />
2457
+ )}
2458
+
2459
+ {topTab === "pr" && (
2460
+ <PullRequestPanel
2461
+ workspace={workspace}
2462
+ onChange={(updater) => setWorkspace(updater)}
2436
2463
  />
2437
2464
  )}
2438
2465
 
2439
- {rightTab === "history" && (
2466
+ {topTab === "actions" && rightTab === "history" && (
2440
2467
  <GhaHistoryPanel
2441
2468
  {...(currentQuestion ? { questionId: currentQuestion.id } : {})}
2442
2469
  {...(activeGhaId ? { fileId: activeGhaId } : {})}