create-interview-cockpit 0.23.0 → 0.23.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/client/src/api.ts +2 -0
- package/template/client/src/components/GhaConcurrencyPanel.tsx +281 -0
- package/template/client/src/components/GithubActionsLabModal.tsx +487 -15
- package/template/client/src/components/Sidebar.tsx +268 -49
- package/template/client/src/components/WorkspaceSwitcher.tsx +6 -1
- package/template/client/src/ghaConcurrency.ts +216 -0
- package/template/client/src/githubActionsLab.ts +41 -0
- package/template/client/src/types.ts +17 -0
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/cockpit.json +1 -1
- package/template/server/src/gha-runner.ts +157 -1
- package/template/server/src/google-drive.ts +32 -9
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useState, useEffect, useRef, Fragment } from "react";
|
|
2
2
|
import { useStore } from "../store";
|
|
3
3
|
import type { Question } from "../types";
|
|
4
|
+
import type { DriveFolder } from "../api";
|
|
4
5
|
import * as api from "../api";
|
|
5
6
|
import FileAttachments from "./FileAttachments";
|
|
6
7
|
import WorkspaceSwitcher from "./WorkspaceSwitcher";
|
|
@@ -116,6 +117,8 @@ export default function Sidebar() {
|
|
|
116
117
|
driveRootFolders,
|
|
117
118
|
loadingDriveFolders,
|
|
118
119
|
fetchDriveRootFolders,
|
|
120
|
+
fetchDriveSubfolders,
|
|
121
|
+
createDriveSubfolder,
|
|
119
122
|
selectDriveSubfolder,
|
|
120
123
|
clearDriveSubfolder,
|
|
121
124
|
syncWorkspace,
|
|
@@ -214,6 +217,10 @@ export default function Sidebar() {
|
|
|
214
217
|
const isAtDriveFolderRoot = isDriveWs && !currentSubFolder;
|
|
215
218
|
const canSyncDriveFolder =
|
|
216
219
|
!!activeWs?.driveConfig?.folderId && !isAtDriveFolderRoot;
|
|
220
|
+
const canPushLocalDrive =
|
|
221
|
+
activeWs?.type === "local" && !!activeWs.driveConfig?.folderId;
|
|
222
|
+
const canPushDriveDestination =
|
|
223
|
+
!!activeWs?.driveConfig?.folderId && (!isDriveWs || !!currentSubFolder);
|
|
217
224
|
const driveSyncTargetName =
|
|
218
225
|
currentSubFolder?.name ?? activeWs?.driveConfig?.folderName ?? "Drive";
|
|
219
226
|
const [navigating, setNavigating] = useState(false);
|
|
@@ -224,6 +231,15 @@ export default function Sidebar() {
|
|
|
224
231
|
const [topicDriveStatus, setTopicDriveStatus] = useState<
|
|
225
232
|
Record<string, string>
|
|
226
233
|
>({});
|
|
234
|
+
const [pushPicker, setPushPicker] = useState<{
|
|
235
|
+
mode: "workspace" | "topic";
|
|
236
|
+
topicId?: string;
|
|
237
|
+
folders: DriveFolder[];
|
|
238
|
+
loading: boolean;
|
|
239
|
+
} | null>(null);
|
|
240
|
+
const [pushPickerNewFolderName, setPushPickerNewFolderName] = useState("");
|
|
241
|
+
const [pushPickerCreatingFolder, setPushPickerCreatingFolder] =
|
|
242
|
+
useState(false);
|
|
227
243
|
const [wsFilesExpanded, setWsFilesExpanded] = useState(true);
|
|
228
244
|
const [driveFileSyncStatus, setDriveFileSyncStatus] = useState<string | null>(
|
|
229
245
|
null,
|
|
@@ -277,15 +293,12 @@ export default function Sidebar() {
|
|
|
277
293
|
}
|
|
278
294
|
};
|
|
279
295
|
|
|
280
|
-
const
|
|
296
|
+
const pushWorkspaceToDrive = async (targetFolderId: string) => {
|
|
281
297
|
if (!activeWorkspaceId || !activeWs?.driveConfig?.folderId) return;
|
|
282
298
|
setPushing(true);
|
|
283
299
|
setDriveFileSyncStatus(null);
|
|
284
300
|
try {
|
|
285
|
-
const result = await exportWorkspace(
|
|
286
|
-
activeWorkspaceId,
|
|
287
|
-
activeWs.driveConfig.subFolderId,
|
|
288
|
-
);
|
|
301
|
+
const result = await exportWorkspace(activeWorkspaceId, targetFolderId);
|
|
289
302
|
if ("needsAuth" in result && result.needsAuth) {
|
|
290
303
|
window.location.href = result.authUrl;
|
|
291
304
|
return;
|
|
@@ -302,58 +315,100 @@ export default function Sidebar() {
|
|
|
302
315
|
}
|
|
303
316
|
};
|
|
304
317
|
|
|
305
|
-
const
|
|
306
|
-
setTopicDriveStatus((prev) => ({ ...prev, [topicId]: value }));
|
|
307
|
-
};
|
|
308
|
-
|
|
309
|
-
const handlePullTopicFromDrive = async (topicId: string) => {
|
|
318
|
+
const pushTopicToDrive = async (topicId: string, targetFolderId: string) => {
|
|
310
319
|
if (!activeWorkspaceId || !activeWs?.driveConfig?.folderId) return;
|
|
311
|
-
|
|
312
|
-
setTopicStatus(topicId, "
|
|
320
|
+
setTopicPushingId(topicId);
|
|
321
|
+
setTopicStatus(topicId, "Pushing topic to Drive…");
|
|
313
322
|
try {
|
|
314
|
-
const result = await
|
|
323
|
+
const result = await exportTopic(activeWorkspaceId, topicId, targetFolderId);
|
|
315
324
|
if ("needsAuth" in result && result.needsAuth) {
|
|
316
325
|
window.location.href = result.authUrl;
|
|
317
326
|
return;
|
|
318
327
|
}
|
|
319
|
-
const firstError = result.errors[0];
|
|
320
328
|
setTopicStatus(
|
|
321
329
|
topicId,
|
|
322
330
|
result.errors.length > 0
|
|
323
|
-
? `Topic
|
|
324
|
-
: `
|
|
331
|
+
? `Topic push finished with ${result.errors.length} error${result.errors.length === 1 ? "" : "s"}.`
|
|
332
|
+
: `Pushed ${result.questionsExported} question${result.questionsExported === 1 ? "" : "s"} and ${result.filesExported} file${result.filesExported === 1 ? "" : "s"} to Drive.`,
|
|
325
333
|
);
|
|
326
334
|
} catch (err: any) {
|
|
327
|
-
setTopicStatus(topicId, err?.message || "Topic
|
|
335
|
+
setTopicStatus(topicId, err?.message || "Topic push failed.");
|
|
328
336
|
} finally {
|
|
329
|
-
|
|
337
|
+
setTopicPushingId(null);
|
|
330
338
|
}
|
|
331
339
|
};
|
|
332
340
|
|
|
333
|
-
const
|
|
341
|
+
const openPushDestinationPicker = async (
|
|
342
|
+
mode: "workspace" | "topic",
|
|
343
|
+
topicId?: string,
|
|
344
|
+
) => {
|
|
334
345
|
if (!activeWorkspaceId || !activeWs?.driveConfig?.folderId) return;
|
|
335
|
-
|
|
336
|
-
setTopicStatus(topicId, "Pushing topic to Drive…");
|
|
346
|
+
setPushPicker({ mode, topicId, folders: [], loading: true });
|
|
337
347
|
try {
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
348
|
+
const folders = await fetchDriveSubfolders(activeWorkspaceId);
|
|
349
|
+
setPushPicker({ mode, topicId, folders, loading: false });
|
|
350
|
+
} catch (err: any) {
|
|
351
|
+
setPushPicker({ mode, topicId, folders: [], loading: false });
|
|
352
|
+
const message = err?.message || "Failed to load Drive folders.";
|
|
353
|
+
if (mode === "topic" && topicId) setTopicStatus(topicId, message);
|
|
354
|
+
else setDriveFileSyncStatus(message);
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const handlePushToDrive = () => {
|
|
359
|
+
if (!activeWs?.driveConfig?.folderId) return;
|
|
360
|
+
if (currentSubFolder) {
|
|
361
|
+
void pushWorkspaceToDrive(currentSubFolder.id);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
void openPushDestinationPicker("workspace");
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const handlePushTopicToDrive = (topicId: string) => {
|
|
368
|
+
if (!activeWs?.driveConfig?.folderId) return;
|
|
369
|
+
if (currentSubFolder) {
|
|
370
|
+
void pushTopicToDrive(topicId, currentSubFolder.id);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
void openPushDestinationPicker("topic", topicId);
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
const handlePushPickerDestination = (targetFolderId: string) => {
|
|
377
|
+
const picker = pushPicker;
|
|
378
|
+
if (!picker) return;
|
|
379
|
+
setPushPicker(null);
|
|
380
|
+
if (picker.mode === "topic" && picker.topicId) {
|
|
381
|
+
void pushTopicToDrive(picker.topicId, targetFolderId);
|
|
382
|
+
} else {
|
|
383
|
+
void pushWorkspaceToDrive(targetFolderId);
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const setTopicStatus = (topicId: string, value: string) => {
|
|
388
|
+
setTopicDriveStatus((prev) => ({ ...prev, [topicId]: value }));
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
const handlePullTopicFromDrive = async (topicId: string) => {
|
|
392
|
+
if (!activeWorkspaceId || !activeWs?.driveConfig?.folderId) return;
|
|
393
|
+
setTopicSyncingId(topicId);
|
|
394
|
+
setTopicStatus(topicId, "Pulling topic from Drive…");
|
|
395
|
+
try {
|
|
396
|
+
const result = await syncTopic(activeWorkspaceId, topicId);
|
|
343
397
|
if ("needsAuth" in result && result.needsAuth) {
|
|
344
398
|
window.location.href = result.authUrl;
|
|
345
399
|
return;
|
|
346
400
|
}
|
|
401
|
+
const firstError = result.errors[0];
|
|
347
402
|
setTopicStatus(
|
|
348
403
|
topicId,
|
|
349
404
|
result.errors.length > 0
|
|
350
|
-
? `Topic
|
|
351
|
-
: `
|
|
405
|
+
? `Topic pull finished with ${result.errors.length} error${result.errors.length === 1 ? "" : "s"}. ${firstError ? `First: ${firstError}` : ""}`
|
|
406
|
+
: `Pulled ${result.filesImported} file${result.filesImported === 1 ? "" : "s"} into this topic.`,
|
|
352
407
|
);
|
|
353
408
|
} catch (err: any) {
|
|
354
|
-
setTopicStatus(topicId, err?.message || "Topic
|
|
409
|
+
setTopicStatus(topicId, err?.message || "Topic pull failed.");
|
|
355
410
|
} finally {
|
|
356
|
-
|
|
411
|
+
setTopicSyncingId(null);
|
|
357
412
|
}
|
|
358
413
|
};
|
|
359
414
|
|
|
@@ -1020,7 +1075,142 @@ export default function Sidebar() {
|
|
|
1020
1075
|
};
|
|
1021
1076
|
|
|
1022
1077
|
return (
|
|
1023
|
-
|
|
1078
|
+
<>
|
|
1079
|
+
{pushPicker && (
|
|
1080
|
+
<div
|
|
1081
|
+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
|
|
1082
|
+
onClick={() => !pushPicker.loading && setPushPicker(null)}
|
|
1083
|
+
>
|
|
1084
|
+
<div
|
|
1085
|
+
className="w-80 rounded-xl border border-slate-700 bg-slate-900 p-4 shadow-2xl"
|
|
1086
|
+
onClick={(e) => e.stopPropagation()}
|
|
1087
|
+
>
|
|
1088
|
+
<div className="mb-3 flex items-center justify-between gap-3">
|
|
1089
|
+
<div>
|
|
1090
|
+
<h3 className="text-sm font-semibold text-slate-200">
|
|
1091
|
+
Choose Drive destination
|
|
1092
|
+
</h3>
|
|
1093
|
+
<p className="mt-0.5 text-[11px] text-slate-500">
|
|
1094
|
+
{pushPicker.mode === "topic"
|
|
1095
|
+
? "Push this topic into the selected Drive folder."
|
|
1096
|
+
: "Push this local workspace into the selected Drive folder."}
|
|
1097
|
+
</p>
|
|
1098
|
+
</div>
|
|
1099
|
+
<button
|
|
1100
|
+
onClick={() => setPushPicker(null)}
|
|
1101
|
+
className="text-slate-500 hover:text-slate-300"
|
|
1102
|
+
disabled={pushPicker.loading}
|
|
1103
|
+
>
|
|
1104
|
+
<X className="h-4 w-4" />
|
|
1105
|
+
</button>
|
|
1106
|
+
</div>
|
|
1107
|
+
|
|
1108
|
+
{pushPicker.loading ? (
|
|
1109
|
+
<div className="flex items-center justify-center gap-2 py-6 text-xs text-slate-400">
|
|
1110
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
1111
|
+
Loading Drive folders…
|
|
1112
|
+
</div>
|
|
1113
|
+
) : (
|
|
1114
|
+
<>
|
|
1115
|
+
<div className="max-h-52 space-y-1 overflow-y-auto">
|
|
1116
|
+
{activeWs?.driveConfig?.folderId && (
|
|
1117
|
+
<button
|
|
1118
|
+
type="button"
|
|
1119
|
+
onClick={() =>
|
|
1120
|
+
handlePushPickerDestination(activeWs.driveConfig!.folderId)
|
|
1121
|
+
}
|
|
1122
|
+
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-xs text-slate-300 hover:bg-slate-800"
|
|
1123
|
+
>
|
|
1124
|
+
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-slate-500" />
|
|
1125
|
+
<span className="min-w-0 flex-1 truncate italic text-slate-400">
|
|
1126
|
+
{activeWs.driveConfig.folderName || "Linked root folder"}
|
|
1127
|
+
</span>
|
|
1128
|
+
</button>
|
|
1129
|
+
)}
|
|
1130
|
+
{pushPicker.folders.map((folder) => (
|
|
1131
|
+
<button
|
|
1132
|
+
key={folder.id}
|
|
1133
|
+
type="button"
|
|
1134
|
+
onClick={() => handlePushPickerDestination(folder.id)}
|
|
1135
|
+
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-xs text-slate-200 hover:bg-slate-800"
|
|
1136
|
+
>
|
|
1137
|
+
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-cyan-400" />
|
|
1138
|
+
<span className="min-w-0 flex-1 truncate">
|
|
1139
|
+
{folder.name}
|
|
1140
|
+
</span>
|
|
1141
|
+
</button>
|
|
1142
|
+
))}
|
|
1143
|
+
{pushPicker.folders.length === 0 && (
|
|
1144
|
+
<p className="px-3 py-2 text-xs text-slate-500">
|
|
1145
|
+
No subfolders found. Choose the linked root folder above
|
|
1146
|
+
or create a new folder below.
|
|
1147
|
+
</p>
|
|
1148
|
+
)}
|
|
1149
|
+
</div>
|
|
1150
|
+
|
|
1151
|
+
<form
|
|
1152
|
+
className="mt-3 flex gap-2"
|
|
1153
|
+
onSubmit={async (e) => {
|
|
1154
|
+
e.preventDefault();
|
|
1155
|
+
const name = pushPickerNewFolderName.trim();
|
|
1156
|
+
if (!name || !activeWorkspaceId) return;
|
|
1157
|
+
setPushPickerCreatingFolder(true);
|
|
1158
|
+
try {
|
|
1159
|
+
const created = await createDriveSubfolder(
|
|
1160
|
+
activeWorkspaceId,
|
|
1161
|
+
name,
|
|
1162
|
+
);
|
|
1163
|
+
if ("needsAuth" in created) {
|
|
1164
|
+
window.location.href = created.authUrl;
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
setPushPicker((prev) =>
|
|
1168
|
+
prev
|
|
1169
|
+
? { ...prev, folders: [...prev.folders, created] }
|
|
1170
|
+
: prev,
|
|
1171
|
+
);
|
|
1172
|
+
setPushPickerNewFolderName("");
|
|
1173
|
+
} catch (err: any) {
|
|
1174
|
+
const message = err?.message || "Failed to create folder";
|
|
1175
|
+
if (pushPicker.mode === "topic" && pushPicker.topicId) {
|
|
1176
|
+
setTopicStatus(pushPicker.topicId, message);
|
|
1177
|
+
} else {
|
|
1178
|
+
setDriveFileSyncStatus(message);
|
|
1179
|
+
}
|
|
1180
|
+
} finally {
|
|
1181
|
+
setPushPickerCreatingFolder(false);
|
|
1182
|
+
}
|
|
1183
|
+
}}
|
|
1184
|
+
>
|
|
1185
|
+
<input
|
|
1186
|
+
value={pushPickerNewFolderName}
|
|
1187
|
+
onChange={(e) => setPushPickerNewFolderName(e.target.value)}
|
|
1188
|
+
placeholder="New folder name…"
|
|
1189
|
+
className="min-w-0 flex-1 rounded-lg border border-slate-700 bg-slate-800 px-2 py-1.5 text-xs text-slate-200 placeholder:text-slate-500 focus:border-cyan-500 focus:outline-none"
|
|
1190
|
+
/>
|
|
1191
|
+
<button
|
|
1192
|
+
type="submit"
|
|
1193
|
+
disabled={
|
|
1194
|
+
!pushPickerNewFolderName.trim() ||
|
|
1195
|
+
pushPickerCreatingFolder
|
|
1196
|
+
}
|
|
1197
|
+
className="flex items-center gap-1 rounded-lg bg-cyan-700 px-2.5 py-1.5 text-xs font-medium text-white hover:bg-cyan-600 disabled:opacity-40"
|
|
1198
|
+
>
|
|
1199
|
+
{pushPickerCreatingFolder ? (
|
|
1200
|
+
<Loader2 className="h-3 w-3 animate-spin" />
|
|
1201
|
+
) : (
|
|
1202
|
+
<Plus className="h-3 w-3" />
|
|
1203
|
+
)}
|
|
1204
|
+
Create
|
|
1205
|
+
</button>
|
|
1206
|
+
</form>
|
|
1207
|
+
</>
|
|
1208
|
+
)}
|
|
1209
|
+
</div>
|
|
1210
|
+
</div>
|
|
1211
|
+
)}
|
|
1212
|
+
|
|
1213
|
+
<aside className="w-72 border-r border-slate-800 flex flex-col bg-slate-900/50 shrink-0">
|
|
1024
1214
|
{/* Workspace switcher */}
|
|
1025
1215
|
<WorkspaceSwitcher />
|
|
1026
1216
|
|
|
@@ -1060,6 +1250,32 @@ export default function Sidebar() {
|
|
|
1060
1250
|
sync from inside that selected folder.
|
|
1061
1251
|
</p>
|
|
1062
1252
|
)}
|
|
1253
|
+
{canPushLocalDrive && (
|
|
1254
|
+
<div className="mt-2 space-y-1.5">
|
|
1255
|
+
<p className="text-[10px] text-slate-600">
|
|
1256
|
+
Push local workspace to Drive
|
|
1257
|
+
</p>
|
|
1258
|
+
<button
|
|
1259
|
+
type="button"
|
|
1260
|
+
onClick={handlePushToDrive}
|
|
1261
|
+
disabled={pushing}
|
|
1262
|
+
className="flex w-full items-center justify-center gap-1 rounded-md border border-cyan-500/20 bg-cyan-500/10 px-2 py-1 text-[11px] font-medium text-cyan-300 transition-colors hover:bg-cyan-500/15 disabled:opacity-40 disabled:hover:bg-cyan-500/10"
|
|
1263
|
+
title="Choose a Drive folder and push topics, questions, and workspace files into it"
|
|
1264
|
+
>
|
|
1265
|
+
{pushing ? (
|
|
1266
|
+
<Loader2 className="w-3 h-3 animate-spin" />
|
|
1267
|
+
) : (
|
|
1268
|
+
<Upload className="w-3 h-3" />
|
|
1269
|
+
)}
|
|
1270
|
+
{pushing ? "Pushing…" : "Choose folder & push"}
|
|
1271
|
+
</button>
|
|
1272
|
+
{driveFileSyncStatus && (
|
|
1273
|
+
<p className="text-[10px] leading-relaxed text-slate-500">
|
|
1274
|
+
{driveFileSyncStatus}
|
|
1275
|
+
</p>
|
|
1276
|
+
)}
|
|
1277
|
+
</div>
|
|
1278
|
+
)}
|
|
1063
1279
|
{canSyncDriveFolder && (
|
|
1064
1280
|
<div className="mt-2 space-y-1.5">
|
|
1065
1281
|
<p className="text-[10px] text-slate-600">
|
|
@@ -1393,13 +1609,13 @@ export default function Sidebar() {
|
|
|
1393
1609
|
<Download className="w-3 h-3" /> Download
|
|
1394
1610
|
</button>
|
|
1395
1611
|
|
|
1396
|
-
{
|
|
1612
|
+
{canPushDriveDestination && (
|
|
1397
1613
|
<>
|
|
1398
1614
|
<div className="border-t border-slate-700 my-0.5" />
|
|
1399
1615
|
<button
|
|
1400
1616
|
onClick={() => {
|
|
1401
1617
|
setOpenMenuTopicId(null);
|
|
1402
|
-
|
|
1618
|
+
handlePushTopicToDrive(topic.id);
|
|
1403
1619
|
}}
|
|
1404
1620
|
disabled={topicBusy || pushing || syncing}
|
|
1405
1621
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-cyan-300 hover:bg-slate-700 hover:text-cyan-200 disabled:opacity-50 transition-colors"
|
|
@@ -1411,21 +1627,23 @@ export default function Sidebar() {
|
|
|
1411
1627
|
)}
|
|
1412
1628
|
Push topic
|
|
1413
1629
|
</button>
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1630
|
+
{canSyncDriveFolder && (
|
|
1631
|
+
<button
|
|
1632
|
+
onClick={() => {
|
|
1633
|
+
setOpenMenuTopicId(null);
|
|
1634
|
+
void handlePullTopicFromDrive(topic.id);
|
|
1635
|
+
}}
|
|
1636
|
+
disabled={topicBusy || pushing || syncing}
|
|
1637
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-slate-300 hover:bg-slate-700 hover:text-cyan-300 disabled:opacity-50 transition-colors"
|
|
1638
|
+
>
|
|
1639
|
+
{topicSyncingId === topic.id ? (
|
|
1640
|
+
<Loader2 className="w-3 h-3 animate-spin" />
|
|
1641
|
+
) : (
|
|
1642
|
+
<RefreshCw className="w-3 h-3" />
|
|
1643
|
+
)}
|
|
1644
|
+
Pull topic
|
|
1645
|
+
</button>
|
|
1646
|
+
)}
|
|
1429
1647
|
</>
|
|
1430
1648
|
)}
|
|
1431
1649
|
|
|
@@ -1555,6 +1773,7 @@ export default function Sidebar() {
|
|
|
1555
1773
|
})}
|
|
1556
1774
|
</div>
|
|
1557
1775
|
)}
|
|
1558
|
-
|
|
1776
|
+
</aside>
|
|
1777
|
+
</>
|
|
1559
1778
|
);
|
|
1560
1779
|
}
|
|
@@ -335,7 +335,12 @@ export default function WorkspaceSwitcher() {
|
|
|
335
335
|
<>
|
|
336
336
|
<div className="space-y-1 max-h-48 overflow-y-auto">
|
|
337
337
|
<button
|
|
338
|
-
onClick={() =>
|
|
338
|
+
onClick={() =>
|
|
339
|
+
runExport(
|
|
340
|
+
folderPicker.ws,
|
|
341
|
+
folderPicker.ws.driveConfig?.folderId,
|
|
342
|
+
)
|
|
343
|
+
}
|
|
339
344
|
className="w-full text-left px-3 py-2 rounded-lg text-xs text-slate-300 hover:bg-slate-800 flex items-center gap-2"
|
|
340
345
|
>
|
|
341
346
|
<FolderOpen size={13} className="text-slate-500 shrink-0" />
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// ─── Shared GitHub Actions concurrency helpers ─────────────────────────
|
|
2
|
+
// Used by the GitHub Actions Lab to evaluate `concurrency:` blocks against
|
|
3
|
+
// a simulated github context AND to enforce GitHub's queue/cancel rules on
|
|
4
|
+
// the actual `act` runs triggered from the lab UI.
|
|
5
|
+
|
|
6
|
+
import { parse as parseYaml } from "yaml";
|
|
7
|
+
|
|
8
|
+
// ─── Types ───────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Subset of `github.*` context fields used when evaluating a concurrency
|
|
12
|
+
* group expression. Kept narrow on purpose so we don't oversell what the
|
|
13
|
+
* tiny expression engine below can resolve.
|
|
14
|
+
*/
|
|
15
|
+
export interface GhaConcurrencyContext {
|
|
16
|
+
event_name: string;
|
|
17
|
+
ref: string;
|
|
18
|
+
head_ref: string;
|
|
19
|
+
workflow: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ParsedConcurrency {
|
|
23
|
+
groupExpr: string;
|
|
24
|
+
cancelExpr: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type GhaConcurrencyRunStatus =
|
|
28
|
+
| "pending"
|
|
29
|
+
| "running"
|
|
30
|
+
| "completed"
|
|
31
|
+
| "cancelled";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One record per Run button click. Records survive in memory for the
|
|
35
|
+
* lifetime of the modal so the user can see the full timeline of how
|
|
36
|
+
* GitHub's concurrency rules played out.
|
|
37
|
+
*/
|
|
38
|
+
export interface GhaConcurrencyRun {
|
|
39
|
+
id: string;
|
|
40
|
+
seq: number;
|
|
41
|
+
command: string;
|
|
42
|
+
/** github.event_name when the run was triggered. */
|
|
43
|
+
eventName: string;
|
|
44
|
+
/** Workflow file path used for the run. */
|
|
45
|
+
workflowPath: string;
|
|
46
|
+
/** Evaluated group key — empty string when no concurrency block exists. */
|
|
47
|
+
groupKey: string;
|
|
48
|
+
/** Evaluated cancel-in-progress flag — false when not declared. */
|
|
49
|
+
cancelInProgress: boolean;
|
|
50
|
+
/** Snapshot of the github.* context the run was evaluated against. */
|
|
51
|
+
context: GhaConcurrencyContext;
|
|
52
|
+
status: GhaConcurrencyRunStatus;
|
|
53
|
+
startedAt?: number;
|
|
54
|
+
endedAt?: number;
|
|
55
|
+
exitCode?: number;
|
|
56
|
+
cancelReason?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── Parsing ────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Extract the workflow-level `concurrency` block. Tolerates both shapes
|
|
63
|
+
* GitHub accepts (`concurrency: my-group` and the long `group:` form).
|
|
64
|
+
* Returns null when the workflow doesn't declare concurrency.
|
|
65
|
+
*/
|
|
66
|
+
export function parseConcurrencyBlock(
|
|
67
|
+
yaml: string | undefined,
|
|
68
|
+
): ParsedConcurrency | null {
|
|
69
|
+
if (!yaml) return null;
|
|
70
|
+
let doc: unknown;
|
|
71
|
+
try {
|
|
72
|
+
doc = parseYaml(yaml);
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
if (!doc || typeof doc !== "object") return null;
|
|
77
|
+
const raw = (doc as Record<string, unknown>).concurrency;
|
|
78
|
+
if (raw == null) return null;
|
|
79
|
+
if (typeof raw === "string") {
|
|
80
|
+
return { groupExpr: raw, cancelExpr: "false" };
|
|
81
|
+
}
|
|
82
|
+
if (typeof raw === "object") {
|
|
83
|
+
const obj = raw as Record<string, unknown>;
|
|
84
|
+
const group = typeof obj.group === "string" ? obj.group : "";
|
|
85
|
+
const cancel = obj["cancel-in-progress"];
|
|
86
|
+
const cancelExpr =
|
|
87
|
+
typeof cancel === "boolean"
|
|
88
|
+
? String(cancel)
|
|
89
|
+
: typeof cancel === "string"
|
|
90
|
+
? cancel
|
|
91
|
+
: "false";
|
|
92
|
+
return { groupExpr: group, cancelExpr };
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ─── Expression engine ─────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Resolve `github.X` against the simulated context. Returns undefined for
|
|
101
|
+
* any other namespace so unknown references render as empty (matching
|
|
102
|
+
* GitHub's behaviour for undefined expressions inside `${{ ... }}`).
|
|
103
|
+
*/
|
|
104
|
+
function resolveGithubPath(
|
|
105
|
+
path: string,
|
|
106
|
+
ctx: GhaConcurrencyContext,
|
|
107
|
+
): string | undefined {
|
|
108
|
+
if (!path.startsWith("github.")) return undefined;
|
|
109
|
+
const key = path.slice("github.".length) as keyof GhaConcurrencyContext;
|
|
110
|
+
const value = ctx[key];
|
|
111
|
+
return typeof value === "string" ? value : undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Evaluate one `${{ … }}` expression. Supports the subset real workflows
|
|
116
|
+
* use when defining concurrency keys:
|
|
117
|
+
* - github.X
|
|
118
|
+
* - 'string literal'
|
|
119
|
+
* - true / false / integer literals
|
|
120
|
+
* - X || Y (coalesce — first non-empty wins, GitHub semantics)
|
|
121
|
+
* - X == Y (string equality → 'true'/'false')
|
|
122
|
+
*/
|
|
123
|
+
function evalConcurrencyExpr(expr: string, ctx: GhaConcurrencyContext): string {
|
|
124
|
+
const trimmed = expr.trim();
|
|
125
|
+
if (!trimmed) return "";
|
|
126
|
+
|
|
127
|
+
// Equality binds tighter than `||` in our tiny grammar (matches the way
|
|
128
|
+
// real workflows are normally written: `event_name == 'pull_request'`).
|
|
129
|
+
if (trimmed.includes("==")) {
|
|
130
|
+
const [lhs, rhs] = trimmed.split("==").map((s) => s.trim());
|
|
131
|
+
const l = evalConcurrencyExpr(lhs, ctx);
|
|
132
|
+
const r = evalConcurrencyExpr(rhs, ctx);
|
|
133
|
+
return l === r ? "true" : "false";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Coalesce: walk left-to-right, return the first non-empty value.
|
|
137
|
+
if (trimmed.includes("||")) {
|
|
138
|
+
for (const part of trimmed.split("||")) {
|
|
139
|
+
const value = evalConcurrencyExpr(part, ctx);
|
|
140
|
+
if (value) return value;
|
|
141
|
+
}
|
|
142
|
+
return "";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const literal = trimmed.match(/^'([^']*)'$/);
|
|
146
|
+
if (literal) return literal[1];
|
|
147
|
+
|
|
148
|
+
if (trimmed === "true" || trimmed === "false") return trimmed;
|
|
149
|
+
if (/^-?\d+$/.test(trimmed)) return trimmed;
|
|
150
|
+
|
|
151
|
+
const resolved = resolveGithubPath(trimmed, ctx);
|
|
152
|
+
return resolved ?? "";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Replace every `${{ … }}` placeholder in `template` with the value its
|
|
157
|
+
* inner expression evaluates to against `ctx`.
|
|
158
|
+
*/
|
|
159
|
+
export function renderConcurrencyTemplate(
|
|
160
|
+
template: string,
|
|
161
|
+
ctx: GhaConcurrencyContext,
|
|
162
|
+
): string {
|
|
163
|
+
if (!template) return "";
|
|
164
|
+
return template.replace(/\$\{\{\s*([\s\S]*?)\s*\}\}/g, (_match, inner) =>
|
|
165
|
+
evalConcurrencyExpr(String(inner), ctx),
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Compute the (groupKey, cancelInProgress) pair for a hypothetical run.
|
|
171
|
+
* When the workflow doesn't declare a concurrency block, returns
|
|
172
|
+
* (empty key, false) which means "no scheduling constraints — treat as
|
|
173
|
+
* its own group with no cancellation".
|
|
174
|
+
*/
|
|
175
|
+
export function evaluateConcurrencyFor(
|
|
176
|
+
parsed: ParsedConcurrency | null,
|
|
177
|
+
ctx: GhaConcurrencyContext,
|
|
178
|
+
): { groupKey: string; cancelInProgress: boolean } {
|
|
179
|
+
if (!parsed) return { groupKey: "", cancelInProgress: false };
|
|
180
|
+
const groupKey = renderConcurrencyTemplate(parsed.groupExpr, ctx);
|
|
181
|
+
const cancelRaw = renderConcurrencyTemplate(parsed.cancelExpr, ctx)
|
|
182
|
+
.trim()
|
|
183
|
+
.toLowerCase();
|
|
184
|
+
return { groupKey, cancelInProgress: cancelRaw === "true" };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Derive a plausible default github.* context from a selected `act` event
|
|
189
|
+
* name. We don't have the real one act injects, but these defaults are
|
|
190
|
+
* close enough that the evaluated group keys feel realistic.
|
|
191
|
+
*/
|
|
192
|
+
export function defaultContextForEvent(
|
|
193
|
+
eventName: string,
|
|
194
|
+
workflowPath: string,
|
|
195
|
+
branch: string,
|
|
196
|
+
prNumber: number,
|
|
197
|
+
headRef: string,
|
|
198
|
+
): GhaConcurrencyContext {
|
|
199
|
+
const workflowName = workflowPath
|
|
200
|
+
.replace(/^\.github\/workflows\//, "")
|
|
201
|
+
.replace(/\.ya?ml$/, "");
|
|
202
|
+
if (eventName === "pull_request") {
|
|
203
|
+
return {
|
|
204
|
+
event_name: eventName,
|
|
205
|
+
ref: `refs/pull/${prNumber}/merge`,
|
|
206
|
+
head_ref: headRef,
|
|
207
|
+
workflow: workflowName,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
event_name: eventName,
|
|
212
|
+
ref: `refs/heads/${branch}`,
|
|
213
|
+
head_ref: "",
|
|
214
|
+
workflow: workflowName,
|
|
215
|
+
};
|
|
216
|
+
}
|