create-interview-cockpit 0.27.0 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/client/src/codeowners.ts +912 -0
- package/template/client/src/components/ChatView.tsx +18 -7
- package/template/client/src/components/CodeContextPanel.tsx +44 -0
- package/template/client/src/components/DiagramsModal.tsx +839 -0
- package/template/client/src/components/GithubActionsLabModal.tsx +371 -303
- package/template/client/src/components/LabsPanel.tsx +10 -3
- package/template/client/src/components/PullRequestPanel.tsx +1267 -0
- package/template/client/src/components/SettingsPanel.tsx +1398 -0
- package/template/client/src/githubActionsLab.ts +2154 -3
- package/template/client/src/index.css +71 -0
- package/template/client/src/types.ts +225 -0
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/cockpit.json +1 -1
- package/template/server/src/gha-runner.ts +1 -1
|
@@ -3,10 +3,11 @@ import {
|
|
|
3
3
|
ChevronDown,
|
|
4
4
|
ChevronRight,
|
|
5
5
|
Copy,
|
|
6
|
+
Eye,
|
|
7
|
+
EyeOff,
|
|
6
8
|
FilePlus,
|
|
7
9
|
Folder,
|
|
8
|
-
|
|
9
|
-
KeyRound,
|
|
10
|
+
GitPullRequest,
|
|
10
11
|
ListChecks,
|
|
11
12
|
Loader2,
|
|
12
13
|
Maximize2,
|
|
@@ -18,12 +19,15 @@ import {
|
|
|
18
19
|
Pencil,
|
|
19
20
|
Play,
|
|
20
21
|
Save,
|
|
22
|
+
Settings,
|
|
21
23
|
StopCircle,
|
|
22
24
|
Terminal,
|
|
23
25
|
Trash2,
|
|
24
26
|
X,
|
|
25
27
|
} from "lucide-react";
|
|
26
28
|
import MonacoEditorLib from "@monaco-editor/react";
|
|
29
|
+
import ReactMarkdown from "react-markdown";
|
|
30
|
+
import remarkGfm from "remark-gfm";
|
|
27
31
|
import type {
|
|
28
32
|
BeforeMount,
|
|
29
33
|
Monaco,
|
|
@@ -39,12 +43,19 @@ import {
|
|
|
39
43
|
serializeGhaLabWorkspace,
|
|
40
44
|
} from "../githubActionsLab";
|
|
41
45
|
import type { GithubActionsLabWorkspace } from "../types";
|
|
42
|
-
import type { GithubActionsLabEnvironmentEntry } from "../types";
|
|
43
46
|
import * as api from "../api";
|
|
44
47
|
import type { GhaJobSnapshot, GhaStreamMessage } from "../api";
|
|
45
48
|
import GhaJobsPanel from "./GhaJobsPanel";
|
|
46
49
|
import GhaHistoryPanel from "./GhaHistoryPanel";
|
|
47
|
-
import
|
|
50
|
+
import PullRequestPanel from "./PullRequestPanel";
|
|
51
|
+
import SettingsPanel from "./SettingsPanel";
|
|
52
|
+
import {
|
|
53
|
+
evaluateCodeOwners,
|
|
54
|
+
findCodeOwnersPath,
|
|
55
|
+
isCodeOwnersPath,
|
|
56
|
+
lintCodeOwnersAgainstOrg,
|
|
57
|
+
parseCodeOwners,
|
|
58
|
+
} from "../codeowners";
|
|
48
59
|
import {
|
|
49
60
|
defaultContextForEvent,
|
|
50
61
|
evaluateConcurrencyFor,
|
|
@@ -215,6 +226,16 @@ function getGhaLabModelPath(filePath: string): string {
|
|
|
215
226
|
.join("/")}`;
|
|
216
227
|
}
|
|
217
228
|
|
|
229
|
+
// Translate the act runner's GhaJobStatus into the PR-side "check run"
|
|
230
|
+
// vocabulary used by codeowners.ts. "pending" is a pre-run state we
|
|
231
|
+
// don't get from act in practice, so we treat it as "queued" for parity.
|
|
232
|
+
function mapJobStatusToCheck(
|
|
233
|
+
s: GhaJobSnapshot["status"],
|
|
234
|
+
): "queued" | "running" | "success" | "failed" | "cancelled" | "skipped" {
|
|
235
|
+
if (s === "pending") return "queued";
|
|
236
|
+
return s;
|
|
237
|
+
}
|
|
238
|
+
|
|
218
239
|
// Tiny grouped-by-folder list to keep the modal lean.
|
|
219
240
|
function groupByFolder(paths: string[]): { folder: string; files: string[] }[] {
|
|
220
241
|
const map = new Map<string, string[]>();
|
|
@@ -239,41 +260,9 @@ interface ConsoleLine {
|
|
|
239
260
|
|
|
240
261
|
type GhaEnvironmentKind = "variables" | "secrets" | "env";
|
|
241
262
|
|
|
242
|
-
const GHA_ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
243
|
-
|
|
244
263
|
const GHA_ENVIRONMENT_SECTIONS: Array<{
|
|
245
264
|
kind: GhaEnvironmentKind;
|
|
246
|
-
|
|
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
|
-
];
|
|
265
|
+
}> = [{ kind: "variables" }, { kind: "secrets" }, { kind: "env" }];
|
|
277
266
|
|
|
278
267
|
export default function GithubActionsLabModal() {
|
|
279
268
|
const {
|
|
@@ -328,10 +317,23 @@ export default function GithubActionsLabModal() {
|
|
|
328
317
|
// Live job snapshots reported by the server during the active run.
|
|
329
318
|
// Reset every time the user kicks off a new run.
|
|
330
319
|
const [liveJobs, setLiveJobs] = useState<GhaJobSnapshot[]>([]);
|
|
331
|
-
//
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
320
|
+
// Mirror into a ref so the run-complete handler can read the freshest
|
|
321
|
+
// job set without racing the React state queue.
|
|
322
|
+
const liveJobsRef = useRef<GhaJobSnapshot[]>([]);
|
|
323
|
+
useEffect(() => {
|
|
324
|
+
liveJobsRef.current = liveJobs;
|
|
325
|
+
}, [liveJobs]);
|
|
326
|
+
// ── Right-pane tab state ───────────────────────────────────────
|
|
327
|
+
// The right pane mimics github.com's repo nav: three top-level tabs
|
|
328
|
+
// (Actions / Pull request / Settings) and Actions has its own sub-
|
|
329
|
+
// tabs (Console / Jobs / History). Splitting state keeps the sub-tab
|
|
330
|
+
// sticky as the user bounces between top tabs.
|
|
331
|
+
const [topTab, setTopTab] = useState<"actions" | "pr" | "settings">(
|
|
332
|
+
"actions",
|
|
333
|
+
);
|
|
334
|
+
const [rightTab, setRightTab] = useState<"console" | "jobs" | "history">(
|
|
335
|
+
"console",
|
|
336
|
+
);
|
|
335
337
|
const environmentEntryCount = useMemo(
|
|
336
338
|
() =>
|
|
337
339
|
GHA_ENVIRONMENT_SECTIONS.reduce((total, section) => {
|
|
@@ -348,6 +350,11 @@ export default function GithubActionsLabModal() {
|
|
|
348
350
|
const [historyNonce, setHistoryNonce] = useState(0);
|
|
349
351
|
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
|
350
352
|
const [rightCollapsed, setRightCollapsed] = useState(false);
|
|
353
|
+
// When viewing a markdown file, let the user toggle between the raw
|
|
354
|
+
// editor and a rendered preview. We default to preview because most
|
|
355
|
+
// of the .md files in the governance template are explanatory docs
|
|
356
|
+
// — readers want them to look like real GitHub markdown, not source.
|
|
357
|
+
const [mdPreview, setMdPreview] = useState(true);
|
|
351
358
|
const abortRef = useRef<AbortController | null>(null);
|
|
352
359
|
// ── Concurrency engine state ───────────────────────────────────────
|
|
353
360
|
// The concurrency tab is no longer a simulator — these records track
|
|
@@ -964,46 +971,9 @@ export default function GithubActionsLabModal() {
|
|
|
964
971
|
};
|
|
965
972
|
|
|
966
973
|
// ── GitHub Actions environment inputs ─────────────────────────────
|
|
967
|
-
|
|
968
|
-
|
|
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
|
-
};
|
|
974
|
+
// The Environments table itself lives inside SettingsPanel; we keep
|
|
975
|
+
// only the bits the modal still needs to build act --var/secret/env
|
|
976
|
+
// files on every run.
|
|
1007
977
|
|
|
1008
978
|
// ── Save lab as context file ──────────────────────────────────────
|
|
1009
979
|
const handleSave = useCallback(async () => {
|
|
@@ -1119,9 +1089,58 @@ export default function GithubActionsLabModal() {
|
|
|
1119
1089
|
// success/failed" lifecycle for this invocation. Switch tabs to Jobs
|
|
1120
1090
|
// so the visualisation is immediately visible.
|
|
1121
1091
|
setLiveJobs([]);
|
|
1092
|
+
setTopTab("actions");
|
|
1122
1093
|
setRightTab("jobs");
|
|
1123
1094
|
appendConsole({ kind: "input", text: `$ ${run.command}\n` });
|
|
1124
1095
|
|
|
1096
|
+
// For push / pull_request events, surface a quick CODEOWNERS dry-run
|
|
1097
|
+
// so the user sees who would be auto-requested as a reviewer, just
|
|
1098
|
+
// like GitHub posts on a real PR. We do this client-side because
|
|
1099
|
+
// act has no concept of CODEOWNERS — it's a github.com feature.
|
|
1100
|
+
try {
|
|
1101
|
+
const eventName = run.command.match(/^act\s+([a-z_]+)/i)?.[1];
|
|
1102
|
+
if (
|
|
1103
|
+
(eventName === "push" || eventName === "pull_request") &&
|
|
1104
|
+
workspace.pullRequest?.changedFiles?.length
|
|
1105
|
+
) {
|
|
1106
|
+
const codeownersPath = findCodeOwnersPath(workspace.files);
|
|
1107
|
+
if (codeownersPath) {
|
|
1108
|
+
const parsed = parseCodeOwners(
|
|
1109
|
+
workspace.files[codeownersPath] ?? "",
|
|
1110
|
+
);
|
|
1111
|
+
const evalResult = evaluateCodeOwners(
|
|
1112
|
+
parsed.rules,
|
|
1113
|
+
workspace.pullRequest.changedFiles,
|
|
1114
|
+
workspace.ghOrg,
|
|
1115
|
+
);
|
|
1116
|
+
appendConsole({
|
|
1117
|
+
kind: "info",
|
|
1118
|
+
text: `[CODEOWNERS] ${codeownersPath} \u2192 ${evalResult.ownedFiles.length}/${workspace.pullRequest.changedFiles.length} files owned`,
|
|
1119
|
+
});
|
|
1120
|
+
for (const match of evalResult.perFile) {
|
|
1121
|
+
if (match.rule && match.owners.length > 0) {
|
|
1122
|
+
appendConsole({
|
|
1123
|
+
kind: "info",
|
|
1124
|
+
text: `[CODEOWNERS] ${match.path} \u2192 ${match.owners
|
|
1125
|
+
.map((o) => `@${o}`)
|
|
1126
|
+
.join(" ")}`,
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
if (evalResult.requestedHandles.length > 0) {
|
|
1131
|
+
appendConsole({
|
|
1132
|
+
kind: "info",
|
|
1133
|
+
text: `[CODEOWNERS] Reviewers requested: ${evalResult.requestedHandles
|
|
1134
|
+
.map((h) => `@${h}`)
|
|
1135
|
+
.join(", ")}`,
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
} catch {
|
|
1141
|
+
// CODEOWNERS preview is informational only \u2014 never block the run.
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1125
1144
|
// Flip this run's record to running so the Concurrency panel ticks.
|
|
1126
1145
|
setConcurrencyRuns((rs) =>
|
|
1127
1146
|
rs.map((r) =>
|
|
@@ -1175,6 +1194,46 @@ export default function GithubActionsLabModal() {
|
|
|
1175
1194
|
kind: "info",
|
|
1176
1195
|
text: `\n[done] exit=${message.exitCode} • ${message.durationMs}ms\n`,
|
|
1177
1196
|
});
|
|
1197
|
+
// Persist this run as the PR's "last check run" so the Pull
|
|
1198
|
+
// Request tab can grade required status checks. We pull the
|
|
1199
|
+
// freshest job snapshots from the ref, not the closure-captured
|
|
1200
|
+
// value, because `setLiveJobs` updates above may not have
|
|
1201
|
+
// flushed by the time `complete` arrives.
|
|
1202
|
+
const completedAt = new Date().toISOString();
|
|
1203
|
+
setWorkspace((prev) => {
|
|
1204
|
+
const jobs = liveJobsRef.current.map((j) => {
|
|
1205
|
+
const baseJob = {
|
|
1206
|
+
name: j.name,
|
|
1207
|
+
status: mapJobStatusToCheck(j.status),
|
|
1208
|
+
} as {
|
|
1209
|
+
name: string;
|
|
1210
|
+
status:
|
|
1211
|
+
| "queued"
|
|
1212
|
+
| "running"
|
|
1213
|
+
| "success"
|
|
1214
|
+
| "failed"
|
|
1215
|
+
| "cancelled"
|
|
1216
|
+
| "skipped";
|
|
1217
|
+
durationMs?: number;
|
|
1218
|
+
};
|
|
1219
|
+
if (typeof j.durationMs === "number") {
|
|
1220
|
+
baseJob.durationMs = j.durationMs;
|
|
1221
|
+
}
|
|
1222
|
+
return baseJob;
|
|
1223
|
+
});
|
|
1224
|
+
if (jobs.length === 0) return prev;
|
|
1225
|
+
return {
|
|
1226
|
+
...prev,
|
|
1227
|
+
pullRequest: {
|
|
1228
|
+
...(prev.pullRequest ?? { changedFiles: [] }),
|
|
1229
|
+
lastCheckRun: {
|
|
1230
|
+
completedAt,
|
|
1231
|
+
...(workflow ? { workflow } : {}),
|
|
1232
|
+
jobs,
|
|
1233
|
+
},
|
|
1234
|
+
},
|
|
1235
|
+
};
|
|
1236
|
+
});
|
|
1178
1237
|
// Refresh History so the just-completed run shows up.
|
|
1179
1238
|
setHistoryNonce((n) => n + 1);
|
|
1180
1239
|
}
|
|
@@ -1595,6 +1654,46 @@ interface ImportMeta {
|
|
|
1595
1654
|
[activeFile],
|
|
1596
1655
|
);
|
|
1597
1656
|
|
|
1657
|
+
// ── CODEOWNERS live validation ───────────────────────────────────
|
|
1658
|
+
// When the CODEOWNERS file is open, push diagnostics into Monaco so
|
|
1659
|
+
// the user sees red squiggles for bad owner handles (just like the
|
|
1660
|
+
// GitHub UI flags unknown users in the CODEOWNERS settings page).
|
|
1661
|
+
useEffect(() => {
|
|
1662
|
+
const monaco = monacoRef.current;
|
|
1663
|
+
if (!monaco) return;
|
|
1664
|
+
// Clear our markers on every editor change; we only set them when
|
|
1665
|
+
// the active file is a CODEOWNERS file.
|
|
1666
|
+
const codeownersOwner = "gha-lab-codeowners";
|
|
1667
|
+
if (!isCodeOwnersPath(activeFile)) {
|
|
1668
|
+
// Clear markers on whatever model is currently active.
|
|
1669
|
+
const uri = monaco.Uri.parse(getGhaLabModelPath(activeFile));
|
|
1670
|
+
const model = monaco.editor.getModel(uri);
|
|
1671
|
+
if (model) monaco.editor.setModelMarkers(model, codeownersOwner, []);
|
|
1672
|
+
return;
|
|
1673
|
+
}
|
|
1674
|
+
const body = workspace.files[activeFile] ?? "";
|
|
1675
|
+
const parsed = parseCodeOwners(body);
|
|
1676
|
+
const issues = lintCodeOwnersAgainstOrg(parsed, workspace.ghOrg);
|
|
1677
|
+
const uri = monaco.Uri.parse(getGhaLabModelPath(activeFile));
|
|
1678
|
+
const model = monaco.editor.getModel(uri);
|
|
1679
|
+
if (!model) return;
|
|
1680
|
+
monaco.editor.setModelMarkers(
|
|
1681
|
+
model,
|
|
1682
|
+
codeownersOwner,
|
|
1683
|
+
issues.map((issue) => ({
|
|
1684
|
+
severity:
|
|
1685
|
+
issue.severity === "error"
|
|
1686
|
+
? monaco.MarkerSeverity.Error
|
|
1687
|
+
: monaco.MarkerSeverity.Warning,
|
|
1688
|
+
message: issue.message,
|
|
1689
|
+
startLineNumber: issue.line,
|
|
1690
|
+
endLineNumber: issue.line,
|
|
1691
|
+
startColumn: 1,
|
|
1692
|
+
endColumn: (model.getLineContent(issue.line)?.length ?? 0) + 1,
|
|
1693
|
+
})),
|
|
1694
|
+
);
|
|
1695
|
+
}, [activeFile, workspace.files, workspace.ghOrg]);
|
|
1696
|
+
|
|
1598
1697
|
// ── Render ────────────────────────────────────────────────────────
|
|
1599
1698
|
const containerStyle: React.CSSProperties = maximized
|
|
1600
1699
|
? { top: 0, left: 0, width: "100vw", height: "100vh" }
|
|
@@ -1659,7 +1758,7 @@ interface ImportMeta {
|
|
|
1659
1758
|
<div className="flex items-center gap-2">
|
|
1660
1759
|
<span className="inline-block h-2 w-2 rounded-full bg-amber-400" />
|
|
1661
1760
|
<span className="text-xs font-semibold tracking-widest text-amber-300">
|
|
1662
|
-
GITHUB
|
|
1761
|
+
GITHUB LAB
|
|
1663
1762
|
</span>
|
|
1664
1763
|
</div>
|
|
1665
1764
|
<input
|
|
@@ -2098,52 +2197,84 @@ interface ImportMeta {
|
|
|
2098
2197
|
{activeFile || "(no file selected)"}
|
|
2099
2198
|
</span>
|
|
2100
2199
|
</div>
|
|
2101
|
-
<
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
:
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2200
|
+
<div className="flex items-center gap-1">
|
|
2201
|
+
{/* Markdown preview toggle. Only meaningful for .md / .markdown
|
|
2202
|
+
files; hidden otherwise so it doesn't clutter the header. */}
|
|
2203
|
+
{activeFile && /\.(md|markdown)$/i.test(activeFile) && (
|
|
2204
|
+
<button
|
|
2205
|
+
onClick={() => setMdPreview((v) => !v)}
|
|
2206
|
+
className="flex items-center gap-1 p-1 rounded text-slate-500 hover:text-amber-300 hover:bg-slate-800/60"
|
|
2207
|
+
title={
|
|
2208
|
+
mdPreview
|
|
2209
|
+
? "Show raw markdown source"
|
|
2210
|
+
: "Render markdown preview"
|
|
2211
|
+
}
|
|
2212
|
+
>
|
|
2213
|
+
{mdPreview ? (
|
|
2214
|
+
<EyeOff className="w-3.5 h-3.5" />
|
|
2215
|
+
) : (
|
|
2216
|
+
<Eye className="w-3.5 h-3.5" />
|
|
2217
|
+
)}
|
|
2218
|
+
<span className="text-[10px] uppercase tracking-wide">
|
|
2219
|
+
{mdPreview ? "Source" : "Preview"}
|
|
2220
|
+
</span>
|
|
2221
|
+
</button>
|
|
2114
2222
|
)}
|
|
2115
|
-
|
|
2223
|
+
<button
|
|
2224
|
+
onClick={() => setRightCollapsed((v) => !v)}
|
|
2225
|
+
className="p-1 rounded text-slate-500 hover:text-amber-300 hover:bg-slate-800/60 shrink-0"
|
|
2226
|
+
title={
|
|
2227
|
+
rightCollapsed
|
|
2228
|
+
? "Show console/jobs panel"
|
|
2229
|
+
: "Hide console/jobs panel"
|
|
2230
|
+
}
|
|
2231
|
+
>
|
|
2232
|
+
{rightCollapsed ? (
|
|
2233
|
+
<PanelRightOpen className="w-3.5 h-3.5" />
|
|
2234
|
+
) : (
|
|
2235
|
+
<PanelRightClose className="w-3.5 h-3.5" />
|
|
2236
|
+
)}
|
|
2237
|
+
</button>
|
|
2238
|
+
</div>
|
|
2116
2239
|
</div>
|
|
2117
2240
|
<div className="flex-1 min-h-0">
|
|
2118
|
-
{activeFile &&
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2241
|
+
{activeFile &&
|
|
2242
|
+
workspace.files[activeFile] !== undefined &&
|
|
2243
|
+
(mdPreview && /\.(md|markdown)$/i.test(activeFile) ? (
|
|
2244
|
+
<div className="h-full w-full overflow-auto px-6 py-4 text-slate-200 gha-md-preview">
|
|
2245
|
+
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
|
2246
|
+
{workspace.files[activeFile]}
|
|
2247
|
+
</ReactMarkdown>
|
|
2248
|
+
</div>
|
|
2249
|
+
) : (
|
|
2250
|
+
<MonacoEditorLib
|
|
2251
|
+
key={activeFile}
|
|
2252
|
+
height="100%"
|
|
2253
|
+
width="100%"
|
|
2254
|
+
language={getEditorLanguage(activeFile)}
|
|
2255
|
+
theme="gha-lab-dark"
|
|
2256
|
+
path={getGhaLabModelPath(activeFile)}
|
|
2257
|
+
value={workspace.files[activeFile]}
|
|
2258
|
+
beforeMount={handleBeforeMount}
|
|
2259
|
+
onMount={handleMount}
|
|
2260
|
+
onChange={handleEditorChange}
|
|
2261
|
+
options={{
|
|
2262
|
+
fontFamily: EDITOR_FONT,
|
|
2263
|
+
fontSize: 13,
|
|
2264
|
+
lineHeight: 22,
|
|
2265
|
+
minimap: { enabled: false },
|
|
2266
|
+
automaticLayout: true,
|
|
2267
|
+
scrollBeyondLastLine: false,
|
|
2268
|
+
wordWrap: "off",
|
|
2269
|
+
tabSize: 2,
|
|
2270
|
+
insertSpaces: true,
|
|
2271
|
+
}}
|
|
2272
|
+
/>
|
|
2273
|
+
))}
|
|
2143
2274
|
</div>
|
|
2144
2275
|
</div>
|
|
2145
2276
|
|
|
2146
|
-
{/* Right pane:
|
|
2277
|
+
{/* Right pane: GitHub-style top tabs (Actions / PR / Settings) */}
|
|
2147
2278
|
<div
|
|
2148
2279
|
className="min-h-0 flex flex-col bg-slate-950 overflow-hidden"
|
|
2149
2280
|
style={{
|
|
@@ -2151,46 +2282,60 @@ interface ImportMeta {
|
|
|
2151
2282
|
visibility: rightCollapsed ? "hidden" : undefined,
|
|
2152
2283
|
}}
|
|
2153
2284
|
>
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
<
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2285
|
+
{/* Top-level nav mimicking github.com's repo header. We keep
|
|
2286
|
+
three tabs only — the saturated 6-tab bar moved its CI
|
|
2287
|
+
config items (Env, Concurrency) into a real Settings page. */}
|
|
2288
|
+
<div className="flex items-center justify-between border-b border-slate-800/60 bg-slate-950 px-2">
|
|
2289
|
+
<div className="flex items-center gap-2 text-[12px]">
|
|
2290
|
+
{[
|
|
2291
|
+
{
|
|
2292
|
+
id: "actions" as const,
|
|
2293
|
+
label: "Actions",
|
|
2294
|
+
icon: Play,
|
|
2295
|
+
},
|
|
2296
|
+
{
|
|
2297
|
+
id: "pr" as const,
|
|
2298
|
+
label: "Pull request",
|
|
2299
|
+
icon: GitPullRequest,
|
|
2300
|
+
},
|
|
2301
|
+
{
|
|
2302
|
+
id: "settings" as const,
|
|
2303
|
+
label: "Settings",
|
|
2304
|
+
icon: Settings,
|
|
2305
|
+
},
|
|
2306
|
+
].map((t) => {
|
|
2307
|
+
const Icon = t.icon;
|
|
2308
|
+
const isActive = topTab === t.id;
|
|
2309
|
+
return (
|
|
2310
|
+
<button
|
|
2311
|
+
key={t.id}
|
|
2312
|
+
onClick={() => setTopTab(t.id)}
|
|
2313
|
+
className={`-mb-px flex items-center gap-1.5 border-b-2 px-1.5 py-2 ${
|
|
2314
|
+
isActive
|
|
2315
|
+
? "border-amber-500 text-amber-200"
|
|
2316
|
+
: "border-transparent text-slate-400 hover:text-slate-200"
|
|
2317
|
+
}`}
|
|
2318
|
+
>
|
|
2319
|
+
<Icon className="h-3.5 w-3.5" />
|
|
2320
|
+
{t.label}
|
|
2321
|
+
{t.id === "actions" && running && (
|
|
2322
|
+
<span className="ml-0.5 inline-block h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse" />
|
|
2323
|
+
)}
|
|
2324
|
+
{t.id === "pr" &&
|
|
2325
|
+
(workspace.pullRequest?.changedFiles.length ?? 0) >
|
|
2326
|
+
0 && (
|
|
2327
|
+
<span className="ml-0.5 rounded bg-emerald-500/15 px-1 text-[10px] text-emerald-200">
|
|
2328
|
+
{workspace.pullRequest?.changedFiles.length}
|
|
2329
|
+
</span>
|
|
2330
|
+
)}
|
|
2331
|
+
{t.id === "settings" && environmentEntryCount > 0 && (
|
|
2332
|
+
<span className="ml-0.5 rounded bg-slate-700/60 px-1 text-[10px] text-slate-300">
|
|
2333
|
+
{environmentEntryCount}
|
|
2334
|
+
</span>
|
|
2335
|
+
)}
|
|
2336
|
+
</button>
|
|
2337
|
+
);
|
|
2338
|
+
})}
|
|
2194
2339
|
</div>
|
|
2195
2340
|
<div className="flex items-center gap-1">
|
|
2196
2341
|
{running && (
|
|
@@ -2202,7 +2347,7 @@ interface ImportMeta {
|
|
|
2202
2347
|
<StopCircle className="w-3.5 h-3.5" />
|
|
2203
2348
|
</button>
|
|
2204
2349
|
)}
|
|
2205
|
-
{rightTab === "console" && (
|
|
2350
|
+
{topTab === "actions" && rightTab === "console" && (
|
|
2206
2351
|
<button
|
|
2207
2352
|
onClick={clearConsole}
|
|
2208
2353
|
className="p-1 rounded text-slate-500 hover:text-slate-200 hover:bg-slate-800/60"
|
|
@@ -2214,7 +2359,47 @@ interface ImportMeta {
|
|
|
2214
2359
|
</div>
|
|
2215
2360
|
</div>
|
|
2216
2361
|
|
|
2217
|
-
{
|
|
2362
|
+
{/* Actions sub-tabs (Workflow runs view): only render when the
|
|
2363
|
+
user is on the Actions top tab. Console + Jobs + History
|
|
2364
|
+
are sub-views of the same "runs" workflow on github.com. */}
|
|
2365
|
+
{topTab === "actions" && (
|
|
2366
|
+
<div className="flex items-center gap-0.5 border-b border-slate-800/40 bg-slate-900/30 px-2 py-1 text-[11px]">
|
|
2367
|
+
{[
|
|
2368
|
+
{
|
|
2369
|
+
id: "console" as const,
|
|
2370
|
+
label: "Workflow runs",
|
|
2371
|
+
icon: Terminal,
|
|
2372
|
+
},
|
|
2373
|
+
{ id: "jobs" as const, label: "Jobs", icon: ListChecks },
|
|
2374
|
+
{
|
|
2375
|
+
id: "history" as const,
|
|
2376
|
+
label: "History",
|
|
2377
|
+
icon: ListChecks,
|
|
2378
|
+
},
|
|
2379
|
+
].map((t) => {
|
|
2380
|
+
const Icon = t.icon;
|
|
2381
|
+
return (
|
|
2382
|
+
<button
|
|
2383
|
+
key={t.id}
|
|
2384
|
+
onClick={() => setRightTab(t.id)}
|
|
2385
|
+
className={`flex items-center gap-1 rounded px-2 py-1 ${
|
|
2386
|
+
rightTab === t.id
|
|
2387
|
+
? "bg-slate-800/70 text-amber-200"
|
|
2388
|
+
: "text-slate-400 hover:text-slate-200 hover:bg-slate-800/40"
|
|
2389
|
+
}`}
|
|
2390
|
+
>
|
|
2391
|
+
<Icon className="h-3 w-3" />
|
|
2392
|
+
{t.label}
|
|
2393
|
+
{t.id === "jobs" && running && (
|
|
2394
|
+
<span className="ml-1 inline-block w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse" />
|
|
2395
|
+
)}
|
|
2396
|
+
</button>
|
|
2397
|
+
);
|
|
2398
|
+
})}
|
|
2399
|
+
</div>
|
|
2400
|
+
)}
|
|
2401
|
+
|
|
2402
|
+
{topTab === "actions" && rightTab === "console" && (
|
|
2218
2403
|
<>
|
|
2219
2404
|
<div className="flex-1 min-h-0 overflow-auto px-3 py-2 text-[11px] font-mono whitespace-pre-wrap break-words">
|
|
2220
2405
|
{consoleLines.length === 0 ? (
|
|
@@ -2275,7 +2460,7 @@ interface ImportMeta {
|
|
|
2275
2460
|
</>
|
|
2276
2461
|
)}
|
|
2277
2462
|
|
|
2278
|
-
{rightTab === "jobs" && (
|
|
2463
|
+
{topTab === "actions" && rightTab === "jobs" && (
|
|
2279
2464
|
<GhaJobsPanel
|
|
2280
2465
|
workflowYaml={workflow ? workspace.files[workflow] : undefined}
|
|
2281
2466
|
jobs={liveJobs}
|
|
@@ -2289,154 +2474,37 @@ interface ImportMeta {
|
|
|
2289
2474
|
/>
|
|
2290
2475
|
)}
|
|
2291
2476
|
|
|
2292
|
-
{
|
|
2293
|
-
<
|
|
2294
|
-
|
|
2295
|
-
|
|
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
|
-
)}
|
|
2477
|
+
{topTab === "settings" && (
|
|
2478
|
+
<SettingsPanel
|
|
2479
|
+
workspace={workspace}
|
|
2480
|
+
onChange={(updater) => setWorkspace(updater)}
|
|
2423
2481
|
workflowPath={workflow}
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
// ones because the queue depends on them.
|
|
2482
|
+
workflowYaml={workflow ? workspace.files[workflow] : undefined}
|
|
2483
|
+
concurrencyRuns={concurrencyRuns}
|
|
2484
|
+
concurrencyContext={concurrencyContext}
|
|
2485
|
+
onConcurrencyContextChange={setConcurrencyContext}
|
|
2486
|
+
onClearConcurrencyRuns={() => {
|
|
2430
2487
|
setConcurrencyRuns((rs) =>
|
|
2431
2488
|
rs.filter(
|
|
2432
2489
|
(r) => r.status === "running" || r.status === "pending",
|
|
2433
2490
|
),
|
|
2434
2491
|
);
|
|
2435
2492
|
}}
|
|
2493
|
+
onResetWorkspace={() => {
|
|
2494
|
+
setWorkspace(cloneGhaLabWorkspace(DEFAULT_GHA_LAB));
|
|
2495
|
+
setTopTab("actions");
|
|
2496
|
+
}}
|
|
2497
|
+
/>
|
|
2498
|
+
)}
|
|
2499
|
+
|
|
2500
|
+
{topTab === "pr" && (
|
|
2501
|
+
<PullRequestPanel
|
|
2502
|
+
workspace={workspace}
|
|
2503
|
+
onChange={(updater) => setWorkspace(updater)}
|
|
2436
2504
|
/>
|
|
2437
2505
|
)}
|
|
2438
2506
|
|
|
2439
|
-
{rightTab === "history" && (
|
|
2507
|
+
{topTab === "actions" && rightTab === "history" && (
|
|
2440
2508
|
<GhaHistoryPanel
|
|
2441
2509
|
{...(currentQuestion ? { questionId: currentQuestion.id } : {})}
|
|
2442
2510
|
{...(activeGhaId ? { fileId: activeGhaId } : {})}
|