@grifhinz/logics-manager 2.4.0 → 2.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -3
- package/VERSION +1 -1
- package/clients/shared-web/media/renderMarkdown.js +1 -1
- package/clients/viewer/browser-host.js +884 -22
- package/clients/viewer/index.html +21 -5
- package/clients/viewer/viewer.css +547 -2
- package/logics_manager/audit.py +2 -2
- package/logics_manager/cli.py +7 -1
- package/logics_manager/insights.py +1 -1
- package/logics_manager/lint.py +2 -2
- package/logics_manager/mcp.py +10 -2
- package/logics_manager/viewer.py +192 -13
- package/package.json +1 -1
- package/pyproject.toml +1 -1
|
@@ -11,9 +11,14 @@
|
|
|
11
11
|
const filterCount = () => document.getElementById("viewer-filter-count");
|
|
12
12
|
const repoPill = () => document.getElementById("viewer-repo-pill");
|
|
13
13
|
const autoRefreshControl = () => document.getElementById("viewer-auto-refresh");
|
|
14
|
+
const refreshIntervalControl = () => document.getElementById("viewer-refresh-interval");
|
|
15
|
+
const refreshMenuButton = () => document.getElementById("viewer-refresh-menu-button");
|
|
16
|
+
const refreshMenuPanel = () => document.getElementById("viewer-refresh-menu");
|
|
14
17
|
const activityClearControl = () => document.getElementById("activity-clear");
|
|
15
18
|
const activityStorageLimit = 80;
|
|
16
|
-
const
|
|
19
|
+
const minAutoRefreshIntervalSeconds = 5;
|
|
20
|
+
const maxAutoRefreshIntervalSeconds = 60;
|
|
21
|
+
const defaultAutoRefreshIntervalMs = 15 * 1000;
|
|
17
22
|
const defaultFilterState = {
|
|
18
23
|
focus: "active",
|
|
19
24
|
type: "all",
|
|
@@ -29,12 +34,14 @@
|
|
|
29
34
|
let nextAutoRefreshAt = 0;
|
|
30
35
|
let autoRefreshEnabled = true;
|
|
31
36
|
let autoRefreshTimeoutId = 0;
|
|
37
|
+
let autoRefreshIntervalTouched = false;
|
|
32
38
|
let applyingLocalChrome = false;
|
|
33
39
|
let autoRefreshStarted = false;
|
|
34
40
|
let itemsLoadInFlight = false;
|
|
35
41
|
let refreshAfterVisible = false;
|
|
36
42
|
let mermaidInitialized = false;
|
|
37
43
|
let focusApplied = false;
|
|
44
|
+
let latestGitBadgeCounts = { unpushedCommits: 0, uncommittedFiles: 0 };
|
|
38
45
|
|
|
39
46
|
function readStoredState() {
|
|
40
47
|
try {
|
|
@@ -162,6 +169,38 @@
|
|
|
162
169
|
}
|
|
163
170
|
}
|
|
164
171
|
|
|
172
|
+
function normalizeAutoRefreshIntervalSeconds(value) {
|
|
173
|
+
const seconds = Math.round(Number(value));
|
|
174
|
+
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
175
|
+
return defaultAutoRefreshIntervalMs / 1000;
|
|
176
|
+
}
|
|
177
|
+
return Math.min(maxAutoRefreshIntervalSeconds, Math.max(minAutoRefreshIntervalSeconds, seconds));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function updateRefreshIntervalControl() {
|
|
181
|
+
const control = refreshIntervalControl();
|
|
182
|
+
if (!(control instanceof HTMLSelectElement)) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const seconds = String(Math.round(autoRefreshIntervalMs / 1000));
|
|
186
|
+
if (![...control.options].some((option) => option.value === seconds)) {
|
|
187
|
+
const option = document.createElement("option");
|
|
188
|
+
option.value = seconds;
|
|
189
|
+
option.textContent = `${seconds} sec`;
|
|
190
|
+
control.appendChild(option);
|
|
191
|
+
}
|
|
192
|
+
control.value = seconds;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function setAutoRefreshIntervalSeconds(value, options = {}) {
|
|
196
|
+
autoRefreshIntervalMs = normalizeAutoRefreshIntervalSeconds(value) * 1000;
|
|
197
|
+
if (options.user) {
|
|
198
|
+
autoRefreshIntervalTouched = true;
|
|
199
|
+
}
|
|
200
|
+
updateRefreshIntervalControl();
|
|
201
|
+
scheduleNextAutoRefresh();
|
|
202
|
+
}
|
|
203
|
+
|
|
165
204
|
function scheduleNextAutoRefresh() {
|
|
166
205
|
if (autoRefreshTimeoutId) {
|
|
167
206
|
window.clearTimeout(autoRefreshTimeoutId);
|
|
@@ -185,6 +224,71 @@
|
|
|
185
224
|
pill.title = latestRepoRoot || repoName;
|
|
186
225
|
}
|
|
187
226
|
|
|
227
|
+
function normalizeGitBadgeCounts(payload) {
|
|
228
|
+
const counts = payload && typeof payload === "object" ? payload.badgeCounts || {} : {};
|
|
229
|
+
return {
|
|
230
|
+
unpushedCommits: Math.max(0, Number(counts.unpushedCommits || payload?.ahead || 0)),
|
|
231
|
+
uncommittedFiles: Math.max(0, Number(counts.uncommittedFiles || 0))
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function renderGitBadge(kind, count) {
|
|
236
|
+
const value = Number(count || 0);
|
|
237
|
+
if (value <= 0) {
|
|
238
|
+
return "";
|
|
239
|
+
}
|
|
240
|
+
const label = kind === "commits"
|
|
241
|
+
? `${value} commits locaux non pushés`
|
|
242
|
+
: `${value} fichiers modifiés non commités`;
|
|
243
|
+
return `<span class="viewer-git-badge viewer-git-badge--${kind}" title="${escapeHtml(label)}" aria-label="${escapeHtml(label)}">${escapeHtml(value)}</span>`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function gitBadgeHtml(scope) {
|
|
247
|
+
const commitsVisible = latestGitBadgeCounts.unpushedCommits > 0 && (
|
|
248
|
+
scope === "main" || scope === "history"
|
|
249
|
+
);
|
|
250
|
+
const filesVisible = latestGitBadgeCounts.uncommittedFiles > 0 && (
|
|
251
|
+
scope === "main" || scope === "changes"
|
|
252
|
+
);
|
|
253
|
+
const html = [
|
|
254
|
+
commitsVisible ? renderGitBadge("commits", latestGitBadgeCounts.unpushedCommits) : "",
|
|
255
|
+
filesVisible ? renderGitBadge("files", latestGitBadgeCounts.uncommittedFiles) : ""
|
|
256
|
+
].filter(Boolean).join("");
|
|
257
|
+
return html ? `<span class="viewer-git-badges" data-viewer-git-badges="${escapeHtml(scope)}">${html}</span>` : "";
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function updateMainGitBadges() {
|
|
261
|
+
const button = document.getElementById("viewer-git");
|
|
262
|
+
if (!(button instanceof HTMLElement)) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
button.querySelector('[data-viewer-git-badges="main"]')?.remove();
|
|
266
|
+
const html = gitBadgeHtml("main");
|
|
267
|
+
if (html) {
|
|
268
|
+
button.insertAdjacentHTML("beforeend", html);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function setGitBadgeCountsFromPayload(payload, options = {}) {
|
|
273
|
+
latestGitBadgeCounts = normalizeGitBadgeCounts(payload);
|
|
274
|
+
if (options.updateMain !== false) {
|
|
275
|
+
updateMainGitBadges();
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function refreshGitBadgeCounters() {
|
|
280
|
+
try {
|
|
281
|
+
const response = await fetch("/api/git-status");
|
|
282
|
+
const data = await response.json();
|
|
283
|
+
if (response.ok && data.ok && data.payload?.state === "ok") {
|
|
284
|
+
setGitBadgeCountsFromPayload(data.payload);
|
|
285
|
+
}
|
|
286
|
+
} catch {
|
|
287
|
+
latestGitBadgeCounts = { unpushedCommits: 0, uncommittedFiles: 0 };
|
|
288
|
+
updateMainGitBadges();
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
188
292
|
function findItemByPath(relPath) {
|
|
189
293
|
const normalized = String(relPath || "").replace(/\\/g, "/").replace(/^\//, "");
|
|
190
294
|
return latestItems.find((entry) => entry.relPath === normalized || entry.path === normalized) || null;
|
|
@@ -423,10 +527,10 @@
|
|
|
423
527
|
|
|
424
528
|
function postToApp(payload, options = {}) {
|
|
425
529
|
latestItems = updateStoredActivity(Array.isArray(payload.items) ? payload.items : []);
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
530
|
+
if (!autoRefreshIntervalTouched) {
|
|
531
|
+
autoRefreshIntervalMs = normalizeAutoRefreshIntervalSeconds(payload.autoRefreshIntervalSeconds) * 1000;
|
|
532
|
+
updateRefreshIntervalControl();
|
|
533
|
+
}
|
|
430
534
|
updateRepositoryIdentity(payload);
|
|
431
535
|
const payloadWithActivity = { ...payload, items: latestItems };
|
|
432
536
|
const nextPayload = options.silent ? payloadWithActivity : applyFocusRequest(payloadWithActivity);
|
|
@@ -439,6 +543,7 @@
|
|
|
439
543
|
renderUpdateNotice(payload.updateInfo);
|
|
440
544
|
updateFilterSummary();
|
|
441
545
|
applyLocalViewerChrome();
|
|
546
|
+
bindRefreshMenuControls();
|
|
442
547
|
}
|
|
443
548
|
|
|
444
549
|
function renderUpdateNotice(updateInfo) {
|
|
@@ -476,12 +581,38 @@
|
|
|
476
581
|
throw new Error(data.error || "Unable to load viewer data.");
|
|
477
582
|
}
|
|
478
583
|
postToApp(data.payload, { silent: Boolean(options.silent) });
|
|
584
|
+
if (method !== "POST") {
|
|
585
|
+
await refreshGitBadgeCounters();
|
|
586
|
+
}
|
|
479
587
|
return true;
|
|
480
588
|
} finally {
|
|
481
589
|
itemsLoadInFlight = false;
|
|
482
590
|
}
|
|
483
591
|
}
|
|
484
592
|
|
|
593
|
+
function isGitStatusOpen() {
|
|
594
|
+
const panel = documentPanel();
|
|
595
|
+
const title = documentTitle();
|
|
596
|
+
return Boolean(panel && !panel.hidden && title && title.textContent === "Git status");
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function isCdxStatusOpen() {
|
|
600
|
+
const panel = documentPanel();
|
|
601
|
+
const title = documentTitle();
|
|
602
|
+
return Boolean(panel && !panel.hidden && title && title.textContent === "CDX status");
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
async function refreshViewer(method = "POST", options = {}) {
|
|
606
|
+
await loadItems(method, options);
|
|
607
|
+
if (isGitStatusOpen()) {
|
|
608
|
+
await showGitStatus({ preserve: true, silent: Boolean(options.silent) });
|
|
609
|
+
} else if (isCdxStatusOpen()) {
|
|
610
|
+
await showCdxStatus({ silent: Boolean(options.silent) });
|
|
611
|
+
} else if (method === "POST") {
|
|
612
|
+
await refreshGitBadgeCounters();
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
485
616
|
function autoRefreshItems() {
|
|
486
617
|
if (!autoRefreshEnabled) {
|
|
487
618
|
return;
|
|
@@ -490,7 +621,7 @@
|
|
|
490
621
|
refreshAfterVisible = true;
|
|
491
622
|
return;
|
|
492
623
|
}
|
|
493
|
-
|
|
624
|
+
refreshViewer("POST", { silent: true }).catch((error) => setMeta(error.message));
|
|
494
625
|
}
|
|
495
626
|
|
|
496
627
|
function startAutoRefresh() {
|
|
@@ -518,13 +649,48 @@
|
|
|
518
649
|
scheduleNextAutoRefresh();
|
|
519
650
|
}
|
|
520
651
|
|
|
652
|
+
function setRefreshMenuOpen(open) {
|
|
653
|
+
const panel = refreshMenuPanel();
|
|
654
|
+
const button = refreshMenuButton();
|
|
655
|
+
if (!panel) {
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
panel.hidden = !open;
|
|
659
|
+
if (button instanceof HTMLElement) {
|
|
660
|
+
button.setAttribute("aria-expanded", open ? "true" : "false");
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function bindRefreshMenuControls() {
|
|
665
|
+
const button = refreshMenuButton();
|
|
666
|
+
if (button) {
|
|
667
|
+
button.onclick = (event) => {
|
|
668
|
+
event.stopPropagation();
|
|
669
|
+
const panel = refreshMenuPanel();
|
|
670
|
+
setRefreshMenuOpen(Boolean(panel?.hidden));
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
const panel = refreshMenuPanel();
|
|
674
|
+
if (panel) {
|
|
675
|
+
panel.onclick = (event) => {
|
|
676
|
+
event.stopPropagation();
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
521
681
|
function statusValue(item) {
|
|
522
682
|
return String(item?.indicators?.Status || "").toLowerCase();
|
|
523
683
|
}
|
|
524
684
|
|
|
525
685
|
function isClosed(item) {
|
|
526
686
|
const status = statusValue(item);
|
|
527
|
-
return
|
|
687
|
+
return (
|
|
688
|
+
status.includes("done") ||
|
|
689
|
+
status.includes("archived") ||
|
|
690
|
+
status.includes("obsolete") ||
|
|
691
|
+
status.includes("superseded") ||
|
|
692
|
+
status.includes("settled")
|
|
693
|
+
);
|
|
528
694
|
}
|
|
529
695
|
|
|
530
696
|
function hasLinks(item) {
|
|
@@ -555,7 +721,22 @@
|
|
|
555
721
|
return true;
|
|
556
722
|
}
|
|
557
723
|
const normalized = rawStatus.toLowerCase();
|
|
558
|
-
return ![
|
|
724
|
+
return ![
|
|
725
|
+
"draft",
|
|
726
|
+
"ready",
|
|
727
|
+
"in progress",
|
|
728
|
+
"blocked",
|
|
729
|
+
"done",
|
|
730
|
+
"active",
|
|
731
|
+
"proposed",
|
|
732
|
+
"accepted",
|
|
733
|
+
"validated",
|
|
734
|
+
"rejected",
|
|
735
|
+
"superseded",
|
|
736
|
+
"settled",
|
|
737
|
+
"archived",
|
|
738
|
+
"obsolete"
|
|
739
|
+
].includes(normalized);
|
|
559
740
|
}
|
|
560
741
|
|
|
561
742
|
function isSafeLogicsDocPath(value) {
|
|
@@ -1060,6 +1241,471 @@
|
|
|
1060
1241
|
setMeta("Health loaded.");
|
|
1061
1242
|
}
|
|
1062
1243
|
|
|
1244
|
+
function objectEntries(value) {
|
|
1245
|
+
return value && typeof value === "object" && !Array.isArray(value) ? Object.entries(value) : [];
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
function asArray(value) {
|
|
1249
|
+
if (Array.isArray(value)) {
|
|
1250
|
+
return value;
|
|
1251
|
+
}
|
|
1252
|
+
if (value && typeof value === "object") {
|
|
1253
|
+
return Object.entries(value).map(([key, entry]) => ({ name: key, ...(entry && typeof entry === "object" ? entry : { value: entry }) }));
|
|
1254
|
+
}
|
|
1255
|
+
return [];
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
function pickFirstObject(status, keys) {
|
|
1259
|
+
for (const key of keys) {
|
|
1260
|
+
if (status?.[key] && typeof status[key] === "object" && !Array.isArray(status[key])) {
|
|
1261
|
+
return status[key];
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
return {};
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
function pickFirstArray(status, keys) {
|
|
1268
|
+
for (const key of keys) {
|
|
1269
|
+
const entries = asArray(status?.[key]);
|
|
1270
|
+
if (entries.length) {
|
|
1271
|
+
return entries;
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
return [];
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
function cdxRows(status) {
|
|
1278
|
+
return asArray(status?.rows);
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
function cdxProviders(status) {
|
|
1282
|
+
const explicitProviders = pickFirstArray(status, ["providers", "providerStatus", "provider_status"]);
|
|
1283
|
+
if (explicitProviders.length) {
|
|
1284
|
+
return explicitProviders;
|
|
1285
|
+
}
|
|
1286
|
+
const grouped = new Map();
|
|
1287
|
+
cdxRows(status).forEach((row) => {
|
|
1288
|
+
const provider = String(row.provider || "unknown");
|
|
1289
|
+
const current = grouped.get(provider) || { name: provider, enabled: 0, active: 0, authenticated: 0, sessions: 0, lowest_available_pct: null };
|
|
1290
|
+
current.sessions += 1;
|
|
1291
|
+
if (row.enabled) {
|
|
1292
|
+
current.enabled += 1;
|
|
1293
|
+
}
|
|
1294
|
+
if (row.active) {
|
|
1295
|
+
current.active += 1;
|
|
1296
|
+
}
|
|
1297
|
+
if (String(row.auth_status || "").toLowerCase() === "authenticated") {
|
|
1298
|
+
current.authenticated += 1;
|
|
1299
|
+
}
|
|
1300
|
+
if (typeof row.available_pct === "number") {
|
|
1301
|
+
current.lowest_available_pct = current.lowest_available_pct === null
|
|
1302
|
+
? row.available_pct
|
|
1303
|
+
: Math.min(current.lowest_available_pct, row.available_pct);
|
|
1304
|
+
}
|
|
1305
|
+
current.state = current.active > 0 ? "active" : current.enabled > 0 ? "enabled" : "disabled";
|
|
1306
|
+
grouped.set(provider, current);
|
|
1307
|
+
});
|
|
1308
|
+
return Array.from(grouped.values());
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
function cdxSessions(status) {
|
|
1312
|
+
const explicitSessions = pickFirstArray(status, ["sessions", "activeSessions", "active_sessions"]);
|
|
1313
|
+
return sortCdxSessionsByRemaining(explicitSessions.length ? explicitSessions : cdxRows(status));
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
function cdxReadiness(status) {
|
|
1317
|
+
const explicitReadiness = pickFirstObject(status, ["readiness", "quota", "quotas", "limits"]);
|
|
1318
|
+
if (objectEntries(explicitReadiness).length) {
|
|
1319
|
+
return explicitReadiness;
|
|
1320
|
+
}
|
|
1321
|
+
const rows = cdxRows(status);
|
|
1322
|
+
if (!rows.length) {
|
|
1323
|
+
return {};
|
|
1324
|
+
}
|
|
1325
|
+
const enabled = rows.filter((row) => row.enabled).length;
|
|
1326
|
+
const active = rows.filter((row) => row.active).length;
|
|
1327
|
+
const authenticated = rows.filter((row) => String(row.auth_status || "").toLowerCase() === "authenticated").length;
|
|
1328
|
+
const availableValues = rows.map((row) => row.available_pct).filter((value) => typeof value === "number");
|
|
1329
|
+
const lowestAvailable = availableValues.length ? Math.min(...availableValues) : null;
|
|
1330
|
+
return {
|
|
1331
|
+
enabled_sessions: enabled,
|
|
1332
|
+
active_sessions: active,
|
|
1333
|
+
authenticated_sessions: authenticated,
|
|
1334
|
+
lowest_remaining: lowestAvailable === null ? "not reported" : `${lowestAvailable}%`
|
|
1335
|
+
};
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
function renderCdxObjectRows(value, emptyText) {
|
|
1339
|
+
const rows = objectEntries(value).slice(0, 12).map(([key, entry]) => `
|
|
1340
|
+
<li class="viewer-cdx__row">
|
|
1341
|
+
<span>${escapeHtml(cdxLabel(key))}</span>
|
|
1342
|
+
<strong>${escapeHtml(typeof entry === "object" ? JSON.stringify(entry) : entry)}</strong>
|
|
1343
|
+
</li>
|
|
1344
|
+
`).join("");
|
|
1345
|
+
return rows || `<li class="viewer-cdx__empty">${escapeHtml(emptyText)}</li>`;
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
function cdxLabel(value) {
|
|
1349
|
+
return String(value || "")
|
|
1350
|
+
.replace(/[_-]+/g, " ")
|
|
1351
|
+
.replace(/\b\w/g, (letter) => letter.toUpperCase());
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
function cdxStateClass(value) {
|
|
1355
|
+
const state = String(value || "").toLowerCase();
|
|
1356
|
+
if (["ready", "ok", "active", "enabled", "authenticated"].some((entry) => state.includes(entry))) {
|
|
1357
|
+
return "ok";
|
|
1358
|
+
}
|
|
1359
|
+
if (["starting", "pending", "warning", "low", "limited"].some((entry) => state.includes(entry))) {
|
|
1360
|
+
return "warn";
|
|
1361
|
+
}
|
|
1362
|
+
if (["error", "failed", "disabled", "unavailable", "unauthenticated"].some((entry) => state.includes(entry))) {
|
|
1363
|
+
return "bad";
|
|
1364
|
+
}
|
|
1365
|
+
return "neutral";
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
function cdxRemainingPct(item) {
|
|
1369
|
+
const value = item?.remaining_pct ?? item?.remainingPct ?? item?.available_pct ?? item?.availablePct ?? item?.lowest_available_pct ?? item?.lowestAvailablePct;
|
|
1370
|
+
const percent = Number(value);
|
|
1371
|
+
return Number.isFinite(percent) ? Math.max(0, Math.min(100, Math.round(percent))) : null;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
function cdxPct(value) {
|
|
1375
|
+
const percent = Number(value);
|
|
1376
|
+
return Number.isFinite(percent) ? `${Math.max(0, Math.min(100, Math.round(percent)))}%` : "-";
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
function cdxField(item, keys, fallback = "-") {
|
|
1380
|
+
for (const key of keys) {
|
|
1381
|
+
const value = item?.[key];
|
|
1382
|
+
if (value !== undefined && value !== null && value !== "") {
|
|
1383
|
+
return value;
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
return fallback;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
function cdxRemainingClass(percent) {
|
|
1390
|
+
if (percent === null) {
|
|
1391
|
+
return "neutral";
|
|
1392
|
+
}
|
|
1393
|
+
if (percent <= 10) {
|
|
1394
|
+
return "bad";
|
|
1395
|
+
}
|
|
1396
|
+
if (percent <= 30) {
|
|
1397
|
+
return "warn";
|
|
1398
|
+
}
|
|
1399
|
+
return "ok";
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
function sortCdxSessionsByRemaining(entries) {
|
|
1403
|
+
return [...entries].sort((left, right) => {
|
|
1404
|
+
const leftRemaining = cdxRemainingPct(left);
|
|
1405
|
+
const rightRemaining = cdxRemainingPct(right);
|
|
1406
|
+
if (leftRemaining === null && rightRemaining === null) {
|
|
1407
|
+
return 0;
|
|
1408
|
+
}
|
|
1409
|
+
if (leftRemaining === null) {
|
|
1410
|
+
return 1;
|
|
1411
|
+
}
|
|
1412
|
+
if (rightRemaining === null) {
|
|
1413
|
+
return -1;
|
|
1414
|
+
}
|
|
1415
|
+
return rightRemaining - leftRemaining;
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
function formatCdxValue(key, value) {
|
|
1420
|
+
if (["reset_at", "resetAt", "resets_at", "resetsAt", "reset_5h_at", "reset5hAt", "reset_week_at", "resetWeekAt", "updated_at", "updatedAt"].includes(key)) {
|
|
1421
|
+
return formatCdxResetAt(value);
|
|
1422
|
+
}
|
|
1423
|
+
if (typeof value === "object") {
|
|
1424
|
+
return JSON.stringify(value);
|
|
1425
|
+
}
|
|
1426
|
+
return value;
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
function parseCdxDate(value) {
|
|
1430
|
+
const raw = String(value || "").trim();
|
|
1431
|
+
if (!raw) {
|
|
1432
|
+
return null;
|
|
1433
|
+
}
|
|
1434
|
+
const shortDate = raw.match(/^([A-Za-z]{3,})\s+(\d{1,2})\s+(\d{1,2}:\d{2})$/);
|
|
1435
|
+
if (shortDate) {
|
|
1436
|
+
const year = new Date().getFullYear();
|
|
1437
|
+
const timestamp = Date.parse(`${shortDate[1]} ${shortDate[2]} ${year} ${shortDate[3]}`);
|
|
1438
|
+
return Number.isFinite(timestamp) ? timestamp : null;
|
|
1439
|
+
}
|
|
1440
|
+
const timestamp = Date.parse(raw);
|
|
1441
|
+
if (Number.isFinite(timestamp)) {
|
|
1442
|
+
return timestamp;
|
|
1443
|
+
}
|
|
1444
|
+
return null;
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
function formatRelativeTime(timestamp) {
|
|
1448
|
+
const diffMs = timestamp - Date.now();
|
|
1449
|
+
const absMs = Math.abs(diffMs);
|
|
1450
|
+
const minutes = Math.round(absMs / 60000);
|
|
1451
|
+
if (minutes < 1) {
|
|
1452
|
+
return diffMs >= 0 ? "now" : "just now";
|
|
1453
|
+
}
|
|
1454
|
+
const hours = Math.floor(minutes / 60);
|
|
1455
|
+
const days = Math.floor(hours / 24);
|
|
1456
|
+
const remainingHours = hours % 24;
|
|
1457
|
+
const remainingMinutes = minutes % 60;
|
|
1458
|
+
let body = "";
|
|
1459
|
+
if (days > 0) {
|
|
1460
|
+
body = `${days}d${remainingHours > 0 ? ` ${remainingHours}h` : ""}`;
|
|
1461
|
+
} else if (hours > 0) {
|
|
1462
|
+
body = `${hours}h${remainingMinutes > 0 ? ` ${remainingMinutes}m` : ""}`;
|
|
1463
|
+
} else {
|
|
1464
|
+
body = `${minutes}m`;
|
|
1465
|
+
}
|
|
1466
|
+
return diffMs >= 0 ? `in ${body}` : `${body} ago`;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
function formatCdxResetAt(value) {
|
|
1470
|
+
const raw = String(value || "").trim();
|
|
1471
|
+
if (!raw) {
|
|
1472
|
+
return "-";
|
|
1473
|
+
}
|
|
1474
|
+
const timestamp = parseCdxDate(raw);
|
|
1475
|
+
return timestamp === null ? raw : formatRelativeTime(timestamp);
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
function formatCdxCredits(value) {
|
|
1479
|
+
const text = String(value ?? "").trim();
|
|
1480
|
+
if (!text || text === "-") {
|
|
1481
|
+
return "-";
|
|
1482
|
+
}
|
|
1483
|
+
const number = Number(text);
|
|
1484
|
+
return Number.isFinite(number) ? number.toFixed(2) : text;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
function renderCdxBadge(value, fallback = "reported") {
|
|
1488
|
+
const label = String(value || fallback || "reported");
|
|
1489
|
+
return `<span class="viewer-cdx__badge viewer-cdx__badge--${cdxStateClass(label)}">${escapeHtml(cdxLabel(label))}</span>`;
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
function cdxDetailEntries(item, excludedKeys) {
|
|
1493
|
+
return objectEntries(item)
|
|
1494
|
+
.filter(([key, value]) => !excludedKeys.includes(key) && value !== undefined && value !== null && value !== "")
|
|
1495
|
+
.slice(0, 6);
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
function renderCdxDetailPills(item, excludedKeys) {
|
|
1499
|
+
const details = cdxDetailEntries(item, excludedKeys).map(([key, value]) => `
|
|
1500
|
+
<span class="viewer-cdx__pill"><span>${escapeHtml(cdxLabel(key))}</span><strong>${escapeHtml(formatCdxValue(key, value))}</strong></span>
|
|
1501
|
+
`).join("");
|
|
1502
|
+
return details ? `<div class="viewer-cdx__pills">${details}</div>` : "";
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
function renderCdxRemainingPill(item) {
|
|
1506
|
+
const percent = cdxRemainingPct(item);
|
|
1507
|
+
if (percent === null) {
|
|
1508
|
+
return "";
|
|
1509
|
+
}
|
|
1510
|
+
return `
|
|
1511
|
+
<span class="viewer-cdx__remaining viewer-cdx__remaining--${cdxRemainingClass(percent)}" title="${escapeHtml(percent)}% usage remaining">
|
|
1512
|
+
<span>Remaining</span>
|
|
1513
|
+
<strong>${escapeHtml(percent)}%</strong>
|
|
1514
|
+
</span>
|
|
1515
|
+
`;
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
function cdxSessionBlock(item) {
|
|
1519
|
+
const explicit = cdxField(item, ["block", "blocked", "blocking"], "");
|
|
1520
|
+
if (explicit && explicit !== true) {
|
|
1521
|
+
return explicit;
|
|
1522
|
+
}
|
|
1523
|
+
const fiveHour = Number(cdxField(item, ["remaining_5h_pct", "remaining5hPct"], NaN));
|
|
1524
|
+
const week = Number(cdxField(item, ["remaining_week_pct", "remainingWeekPct"], NaN));
|
|
1525
|
+
if (Number.isFinite(fiveHour) && fiveHour <= 0) {
|
|
1526
|
+
return "5H";
|
|
1527
|
+
}
|
|
1528
|
+
if (Number.isFinite(week) && week <= 1) {
|
|
1529
|
+
return "WEEK";
|
|
1530
|
+
}
|
|
1531
|
+
return explicit === true ? "YES" : "-";
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
function renderCdxSessionTable(sessions, emptyText) {
|
|
1535
|
+
if (!sessions.length) {
|
|
1536
|
+
return `<div class="viewer-cdx__empty">${escapeHtml(emptyText)}</div>`;
|
|
1537
|
+
}
|
|
1538
|
+
const rows = sessions.slice(0, 24).map((entry) => {
|
|
1539
|
+
const item = entry && typeof entry === "object" ? entry : { value: entry };
|
|
1540
|
+
const name = cdxField(item, ["session_name", "name", "id", "value"]);
|
|
1541
|
+
const sessionName = `${name}${item.active ? "*" : ""}`;
|
|
1542
|
+
const status = cdxField(item, ["status", "state"]);
|
|
1543
|
+
const auth = String(cdxField(item, ["auth_status", "authStatus"], "-")).replace("authenticated", "logged");
|
|
1544
|
+
const block = cdxSessionBlock(item);
|
|
1545
|
+
return `
|
|
1546
|
+
<tr>
|
|
1547
|
+
<td class="viewer-cdx__session-name">${escapeHtml(sessionName)}</td>
|
|
1548
|
+
<td>${escapeHtml(cdxField(item, ["provider"], "-"))}</td>
|
|
1549
|
+
<td>${renderCdxBadge(status)}</td>
|
|
1550
|
+
<td>${escapeHtml(auth)}</td>
|
|
1551
|
+
<td>${renderCdxRemainingPill(item) || escapeHtml(cdxPct(cdxField(item, ["available_pct", "availablePct"], NaN)))}</td>
|
|
1552
|
+
<td>${escapeHtml(cdxPct(cdxField(item, ["remaining_5h_pct", "remaining5hPct"], NaN)))}</td>
|
|
1553
|
+
<td>${escapeHtml(cdxPct(cdxField(item, ["remaining_week_pct", "remainingWeekPct"], NaN)))}</td>
|
|
1554
|
+
<td>${escapeHtml(block)}</td>
|
|
1555
|
+
<td>${escapeHtml(formatCdxCredits(cdxField(item, ["credits", "cr"], "-")))}</td>
|
|
1556
|
+
<td>${escapeHtml(formatCdxResetAt(cdxField(item, ["reset_5h_at", "reset5hAt", "reset_at", "resetAt"], "")))}</td>
|
|
1557
|
+
<td>${escapeHtml(formatCdxResetAt(cdxField(item, ["reset_week_at", "resetWeekAt", "reset_at", "resetAt"], "")))}</td>
|
|
1558
|
+
<td>${escapeHtml(formatCdxResetAt(cdxField(item, ["updated_at", "updatedAt"], "")))}</td>
|
|
1559
|
+
</tr>
|
|
1560
|
+
`;
|
|
1561
|
+
}).join("");
|
|
1562
|
+
return `
|
|
1563
|
+
<div class="viewer-cdx__table-wrap">
|
|
1564
|
+
<table class="viewer-cdx__table">
|
|
1565
|
+
<thead>
|
|
1566
|
+
<tr>
|
|
1567
|
+
<th>SESSION</th>
|
|
1568
|
+
<th>PROV.</th>
|
|
1569
|
+
<th>STATUS</th>
|
|
1570
|
+
<th>AUTH</th>
|
|
1571
|
+
<th>OK</th>
|
|
1572
|
+
<th>5H</th>
|
|
1573
|
+
<th>WEEK</th>
|
|
1574
|
+
<th>BLOCK</th>
|
|
1575
|
+
<th>CR</th>
|
|
1576
|
+
<th>RESET 5H</th>
|
|
1577
|
+
<th>RESET WEEK</th>
|
|
1578
|
+
<th>UPDATED</th>
|
|
1579
|
+
</tr>
|
|
1580
|
+
</thead>
|
|
1581
|
+
<tbody>${rows}</tbody>
|
|
1582
|
+
</table>
|
|
1583
|
+
</div>
|
|
1584
|
+
`;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
function renderCdxEntityRows(entries, emptyText, options = {}) {
|
|
1588
|
+
const titleKeys = options.titleKeys || ["name", "session_name", "id", "provider", "model", "value"];
|
|
1589
|
+
const stateKeys = options.stateKeys || ["state", "status", "readiness", "available", "auth_status"];
|
|
1590
|
+
const excludedKeys = [...titleKeys, ...stateKeys, "available_pct", "availablePct", "remaining_pct", "remainingPct", "lowest_available_pct", "lowestAvailablePct"];
|
|
1591
|
+
const rows = entries.slice(0, 16).map((entry) => {
|
|
1592
|
+
const item = entry && typeof entry === "object" ? entry : { value: entry };
|
|
1593
|
+
const name = titleKeys.map((key) => item[key]).find(Boolean) || "entry";
|
|
1594
|
+
const state = stateKeys.map((key) => item[key]).find((value) => value !== undefined && value !== null && value !== "") || "";
|
|
1595
|
+
const subtitle = options.subtitleKeys
|
|
1596
|
+
? options.subtitleKeys.map((key) => item[key]).filter(Boolean).join(" · ")
|
|
1597
|
+
: "";
|
|
1598
|
+
return `
|
|
1599
|
+
<li class="viewer-cdx__entity">
|
|
1600
|
+
<div class="viewer-cdx__entity-main">
|
|
1601
|
+
<div>
|
|
1602
|
+
<strong>${escapeHtml(name)}</strong>
|
|
1603
|
+
${subtitle ? `<div class="viewer-cdx__meta">${escapeHtml(subtitle)}</div>` : ""}
|
|
1604
|
+
</div>
|
|
1605
|
+
<div class="viewer-cdx__entity-status">
|
|
1606
|
+
${renderCdxRemainingPill(item)}
|
|
1607
|
+
${renderCdxBadge(state)}
|
|
1608
|
+
</div>
|
|
1609
|
+
</div>
|
|
1610
|
+
${renderCdxDetailPills(item, excludedKeys)}
|
|
1611
|
+
</li>
|
|
1612
|
+
`;
|
|
1613
|
+
}).join("");
|
|
1614
|
+
return rows || `<li class="viewer-cdx__empty">${escapeHtml(emptyText)}</li>`;
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
function renderCdxStatus(payload) {
|
|
1618
|
+
if (!payload || payload.state !== "ok") {
|
|
1619
|
+
return `
|
|
1620
|
+
<div class="viewer-cdx">
|
|
1621
|
+
<div class="viewer-cdx__state">${escapeHtml(payload?.message || "CDX status is unavailable.")}</div>
|
|
1622
|
+
</div>
|
|
1623
|
+
`;
|
|
1624
|
+
}
|
|
1625
|
+
const status = payload.status || {};
|
|
1626
|
+
const providers = cdxProviders(status);
|
|
1627
|
+
const sessions = cdxSessions(status);
|
|
1628
|
+
const readiness = cdxReadiness(status);
|
|
1629
|
+
const commands = pickFirstArray(status, ["nextCommands", "next_commands", "safeCommands", "safe_commands", "commands"])
|
|
1630
|
+
.map((entry) => typeof entry === "string" ? entry : (entry.command || entry.value || entry.name || ""))
|
|
1631
|
+
.filter(Boolean);
|
|
1632
|
+
if (!commands.length) {
|
|
1633
|
+
commands.push("cdx status --json");
|
|
1634
|
+
}
|
|
1635
|
+
const runtimeState = status.state || status.status || status.availability || "ok";
|
|
1636
|
+
const readinessCount = objectEntries(readiness).length;
|
|
1637
|
+
const cards = [
|
|
1638
|
+
["Runtime", runtimeState],
|
|
1639
|
+
["Providers", providers.length],
|
|
1640
|
+
["Sessions", sessions.length],
|
|
1641
|
+
["Readiness", readinessCount ? `${readinessCount} signals` : "Not reported"]
|
|
1642
|
+
].map(([label, value]) => `
|
|
1643
|
+
<div class="viewer-cdx__card">
|
|
1644
|
+
<div class="viewer-cdx__label">${escapeHtml(label)}</div>
|
|
1645
|
+
<div class="viewer-cdx__value">${label === "Runtime" ? renderCdxBadge(value) : escapeHtml(value)}</div>
|
|
1646
|
+
</div>
|
|
1647
|
+
`).join("");
|
|
1648
|
+
const commandRows = commands.slice(0, 10).map((command, index) => `
|
|
1649
|
+
<li>
|
|
1650
|
+
<span>${escapeHtml(index + 1)}</span>
|
|
1651
|
+
<code>${escapeHtml(command)}</code>
|
|
1652
|
+
</li>
|
|
1653
|
+
`).join("");
|
|
1654
|
+
return `
|
|
1655
|
+
<div class="viewer-cdx">
|
|
1656
|
+
<div class="viewer-cdx__summary">${cards}</div>
|
|
1657
|
+
<div class="viewer-cdx__workspace">
|
|
1658
|
+
<div class="viewer-cdx__stack">
|
|
1659
|
+
<section class="viewer-cdx__section">
|
|
1660
|
+
<h2 class="viewer-cdx__heading">Sessions</h2>
|
|
1661
|
+
${renderCdxSessionTable(sessions, "No sessions reported.")}
|
|
1662
|
+
</section>
|
|
1663
|
+
<section class="viewer-cdx__section">
|
|
1664
|
+
<h2 class="viewer-cdx__heading">Providers</h2>
|
|
1665
|
+
<ul class="viewer-cdx__list">${renderCdxEntityRows(providers, "No provider status reported.", { subtitleKeys: ["model"] })}</ul>
|
|
1666
|
+
</section>
|
|
1667
|
+
</div>
|
|
1668
|
+
<div class="viewer-cdx__stack">
|
|
1669
|
+
<section class="viewer-cdx__section">
|
|
1670
|
+
<h2 class="viewer-cdx__heading">Readiness and quota</h2>
|
|
1671
|
+
<ul class="viewer-cdx__list">${renderCdxObjectRows(readiness, "No readiness or quota details reported.")}</ul>
|
|
1672
|
+
</section>
|
|
1673
|
+
<section class="viewer-cdx__section">
|
|
1674
|
+
<h2 class="viewer-cdx__heading">Safe next commands</h2>
|
|
1675
|
+
<ul class="viewer-cdx__commands">${commandRows || '<li class="viewer-cdx__empty">No suggested commands reported.</li>'}</ul>
|
|
1676
|
+
</section>
|
|
1677
|
+
</div>
|
|
1678
|
+
</div>
|
|
1679
|
+
</div>
|
|
1680
|
+
`;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
async function showCdxStatus(options = {}) {
|
|
1684
|
+
if (!options.silent) {
|
|
1685
|
+
setMeta("Checking CDX status...");
|
|
1686
|
+
}
|
|
1687
|
+
const response = await fetch("/api/cdx-status");
|
|
1688
|
+
let data = {};
|
|
1689
|
+
try {
|
|
1690
|
+
data = await response.json();
|
|
1691
|
+
} catch {
|
|
1692
|
+
data = {};
|
|
1693
|
+
}
|
|
1694
|
+
if (response.status === 404) {
|
|
1695
|
+
setDocument("CDX status", renderCdxStatus({
|
|
1696
|
+
state: "unavailable",
|
|
1697
|
+
message: "CDX status endpoint unavailable. Restart the local viewer so it loads the current logics-manager backend."
|
|
1698
|
+
}));
|
|
1699
|
+
setMeta("Restart the local viewer to enable CDX status.");
|
|
1700
|
+
return;
|
|
1701
|
+
}
|
|
1702
|
+
if (!response.ok || !data.ok) {
|
|
1703
|
+
throw new Error(data.error || "Unable to load CDX status.");
|
|
1704
|
+
}
|
|
1705
|
+
setDocument("CDX status", renderCdxStatus(data.payload));
|
|
1706
|
+
setMeta(options.silent ? "CDX status refreshed." : "CDX status loaded.");
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1063
1709
|
function renderGitStatus(payload) {
|
|
1064
1710
|
if (!payload || payload.state !== "ok") {
|
|
1065
1711
|
return `
|
|
@@ -1069,19 +1715,43 @@
|
|
|
1069
1715
|
`;
|
|
1070
1716
|
}
|
|
1071
1717
|
const counts = payload.counts || {};
|
|
1718
|
+
const stagedCount = Number(counts.staged || 0);
|
|
1719
|
+
const modifiedCount = Number(counts.modified || 0);
|
|
1720
|
+
const deletedCount = Number(counts.deleted || 0);
|
|
1721
|
+
const renamedCount = Number(counts.renamed || 0);
|
|
1722
|
+
const untrackedCount = Number(counts.untracked || 0);
|
|
1072
1723
|
const summary = [
|
|
1073
1724
|
["Branch", payload.branch || "HEAD"],
|
|
1074
1725
|
["Tracking", payload.tracking || "None"],
|
|
1075
1726
|
["Ahead", payload.ahead || 0],
|
|
1076
1727
|
["Behind", payload.behind || 0],
|
|
1077
1728
|
["State", payload.clean ? "Clean" : "Dirty"],
|
|
1078
|
-
["Staged",
|
|
1079
|
-
["
|
|
1080
|
-
["Untracked",
|
|
1729
|
+
["Staged", stagedCount],
|
|
1730
|
+
["Worktree", modifiedCount + deletedCount + renamedCount],
|
|
1731
|
+
["Untracked", untrackedCount]
|
|
1081
1732
|
];
|
|
1082
1733
|
const cards = renderMetricCards(summary);
|
|
1083
|
-
const
|
|
1084
|
-
|
|
1734
|
+
const groupDefs = [
|
|
1735
|
+
["staged", "Staged", "staged"],
|
|
1736
|
+
["modified", "Modified", "worktree"],
|
|
1737
|
+
["deleted", "Deleted", "worktree"],
|
|
1738
|
+
["renamed", "Renamed", "worktree"],
|
|
1739
|
+
["untracked", "Untracked", "untracked"]
|
|
1740
|
+
];
|
|
1741
|
+
const domainDefs = [
|
|
1742
|
+
["changes", "Changes", stagedCount + modifiedCount + deletedCount + renamedCount + untrackedCount],
|
|
1743
|
+
["staged", "Staged", stagedCount],
|
|
1744
|
+
["worktree", "Worktree", modifiedCount + deletedCount + renamedCount],
|
|
1745
|
+
["untracked", "Untracked", untrackedCount],
|
|
1746
|
+
["history", "History", Array.isArray(payload.recentCommits) ? payload.recentCommits.length : (payload.latestCommit ? 1 : 0)],
|
|
1747
|
+
["remote", "Remote", payload.tracking ? 1 : 0]
|
|
1748
|
+
];
|
|
1749
|
+
const domains = domainDefs.map(([key, label, count], index) => `
|
|
1750
|
+
<button class="viewer-git__domain${index === 0 ? " is-active" : ""}" type="button" data-viewer-git-domain="${escapeHtml(key)}" aria-pressed="${index === 0 ? "true" : "false"}">
|
|
1751
|
+
<span class="viewer-git__domain-label">${escapeHtml(label)}${key === "changes" ? gitBadgeHtml("changes") : ""}${key === "history" ? gitBadgeHtml("history") : ""}</span><strong>${escapeHtml(count)}</strong>
|
|
1752
|
+
</button>
|
|
1753
|
+
`).join("");
|
|
1754
|
+
const renderFileSections = (allowedKeys) => groupDefs.filter(([key]) => allowedKeys.includes(key)).map(([key, label]) => {
|
|
1085
1755
|
const entries = Array.isArray(payload.groups?.[key]) ? payload.groups[key] : [];
|
|
1086
1756
|
if (!entries.length) {
|
|
1087
1757
|
return "";
|
|
@@ -1090,24 +1760,161 @@
|
|
|
1090
1760
|
<section class="viewer-git__section">
|
|
1091
1761
|
<h2>${escapeHtml(label)}</h2>
|
|
1092
1762
|
<ul class="viewer-git__files">${entries.map((entry) => `
|
|
1093
|
-
<li
|
|
1763
|
+
<li>
|
|
1764
|
+
<button class="viewer-git__file" type="button" data-viewer-git-file="${escapeHtml(entry.path)}" data-viewer-git-cached="${key === "staged" ? "1" : "0"}">
|
|
1765
|
+
<span class="viewer-git__file-path">${escapeHtml(entry.from ? `${entry.from} -> ${entry.path}` : entry.path)}</span>
|
|
1766
|
+
${entry.logicsType ? `<span class="viewer-git__file-kind">${escapeHtml(entry.logicsType)}</span>` : ""}
|
|
1767
|
+
</button>
|
|
1768
|
+
</li>
|
|
1094
1769
|
`).join("")}</ul>
|
|
1095
1770
|
</section>
|
|
1096
1771
|
`;
|
|
1097
1772
|
}).join("");
|
|
1773
|
+
const changesSections = renderFileSections(["staged", "modified", "deleted", "renamed", "untracked"]);
|
|
1774
|
+
const stagedSections = renderFileSections(["staged"]);
|
|
1775
|
+
const worktreeSections = renderFileSections(["modified", "deleted", "renamed"]);
|
|
1776
|
+
const untrackedSections = renderFileSections(["untracked"]);
|
|
1098
1777
|
const clean = payload.clean ? '<p class="viewer-git__state">Working tree clean.</p>' : "";
|
|
1778
|
+
const recentCommits = Array.isArray(payload.recentCommits) ? payload.recentCommits : [];
|
|
1779
|
+
const historyRows = recentCommits.length
|
|
1780
|
+
? recentCommits.map((commit) => `
|
|
1781
|
+
<li class="viewer-git__commit-row">
|
|
1782
|
+
<div class="viewer-git__commit-main">
|
|
1783
|
+
<code>${escapeHtml(commit.hash || "")}</code>
|
|
1784
|
+
<strong>${escapeHtml(commit.subject || "Untitled commit")}</strong>
|
|
1785
|
+
</div>
|
|
1786
|
+
<div class="viewer-git__commit-meta">
|
|
1787
|
+
<span>${escapeHtml([commit.author, commit.date].filter(Boolean).join(" · ") || "Unknown")}</span>
|
|
1788
|
+
${commit.refs ? `<span class="viewer-git__commit-refs">${escapeHtml(commit.refs)}</span>` : ""}
|
|
1789
|
+
</div>
|
|
1790
|
+
</li>
|
|
1791
|
+
`).join("")
|
|
1792
|
+
: `<li class="viewer-git__commit-row">${escapeHtml(payload.latestCommit || "No commit history available.")}</li>`;
|
|
1793
|
+
const history = `
|
|
1794
|
+
<section class="viewer-git__section">
|
|
1795
|
+
<h2>History</h2>
|
|
1796
|
+
<ul class="viewer-git__commits">${historyRows}</ul>
|
|
1797
|
+
</section>
|
|
1798
|
+
`;
|
|
1799
|
+
const remote = `
|
|
1800
|
+
<section class="viewer-git__section">
|
|
1801
|
+
<h2>Remote</h2>
|
|
1802
|
+
<p class="viewer-git__state">${escapeHtml(payload.tracking ? `Tracking ${payload.tracking}` : "No upstream branch detected.")}</p>
|
|
1803
|
+
<p class="viewer-git__state">${escapeHtml(`Ahead ${payload.ahead || 0}, behind ${payload.behind || 0}`)}</p>
|
|
1804
|
+
</section>
|
|
1805
|
+
`;
|
|
1099
1806
|
return `
|
|
1100
1807
|
<div class="viewer-git">
|
|
1101
1808
|
<div class="viewer-git__summary">${cards}</div>
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1809
|
+
<div class="viewer-git__workspace">
|
|
1810
|
+
<nav class="viewer-git__domains" aria-label="Git domains">${domains}</nav>
|
|
1811
|
+
<div class="viewer-git__content" aria-label="Git domain content">
|
|
1812
|
+
<section class="viewer-git__panel" data-viewer-git-panel="changes">
|
|
1813
|
+
<header class="viewer-git__panel-header"><span>Changes</span><strong>${escapeHtml(stagedCount + modifiedCount + deletedCount + renamedCount + untrackedCount)} files</strong></header>
|
|
1814
|
+
${clean}
|
|
1815
|
+
${changesSections || '<p class="viewer-git__state">No file changes detected.</p>'}
|
|
1816
|
+
</section>
|
|
1817
|
+
<section class="viewer-git__panel" data-viewer-git-panel="staged" hidden>
|
|
1818
|
+
<header class="viewer-git__panel-header"><span>Staged</span><strong>${escapeHtml(stagedCount)} files</strong></header>
|
|
1819
|
+
${stagedSections || '<p class="viewer-git__state">No staged files.</p>'}
|
|
1820
|
+
</section>
|
|
1821
|
+
<section class="viewer-git__panel" data-viewer-git-panel="worktree" hidden>
|
|
1822
|
+
<header class="viewer-git__panel-header"><span>Worktree</span><strong>${escapeHtml(modifiedCount + deletedCount + renamedCount)} files</strong></header>
|
|
1823
|
+
${worktreeSections || '<p class="viewer-git__state">No modified, deleted, or renamed files.</p>'}
|
|
1824
|
+
</section>
|
|
1825
|
+
<section class="viewer-git__panel" data-viewer-git-panel="untracked" hidden>
|
|
1826
|
+
<header class="viewer-git__panel-header"><span>Untracked</span><strong>${escapeHtml(untrackedCount)} files</strong></header>
|
|
1827
|
+
${untrackedSections || '<p class="viewer-git__state">No untracked files.</p>'}
|
|
1828
|
+
</section>
|
|
1829
|
+
<section class="viewer-git__panel" data-viewer-git-panel="history" hidden>
|
|
1830
|
+
<header class="viewer-git__panel-header"><span>History</span><strong>${escapeHtml(recentCommits.length || (payload.latestCommit ? 1 : 0))} commits</strong></header>
|
|
1831
|
+
${history}
|
|
1832
|
+
</section>
|
|
1833
|
+
<section class="viewer-git__panel" data-viewer-git-panel="remote" hidden>
|
|
1834
|
+
<header class="viewer-git__panel-header"><span>Remote</span><strong>${escapeHtml(payload.tracking || "none")}</strong></header>
|
|
1835
|
+
${remote}
|
|
1836
|
+
</section>
|
|
1837
|
+
</div>
|
|
1838
|
+
<section class="viewer-git__detail" aria-label="Git diff">
|
|
1839
|
+
<div class="viewer-git__detail-title">Diff preview</div>
|
|
1840
|
+
<div class="viewer-git__diff" data-viewer-git-diff>Select a changed file to preview its diff.</div>
|
|
1841
|
+
</section>
|
|
1842
|
+
</div>
|
|
1105
1843
|
</div>
|
|
1106
1844
|
`;
|
|
1107
1845
|
}
|
|
1108
1846
|
|
|
1109
|
-
|
|
1110
|
-
|
|
1847
|
+
function setActiveGitFile(button) {
|
|
1848
|
+
document.querySelectorAll("[data-viewer-git-file]").forEach((node) => {
|
|
1849
|
+
if (node instanceof HTMLElement) {
|
|
1850
|
+
node.classList.toggle("is-active", node === button);
|
|
1851
|
+
}
|
|
1852
|
+
});
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
async function loadGitDiff(path, cached, button = null) {
|
|
1856
|
+
const diffPanel = document.querySelector("[data-viewer-git-diff]");
|
|
1857
|
+
if (!(diffPanel instanceof HTMLElement) || !path) {
|
|
1858
|
+
return;
|
|
1859
|
+
}
|
|
1860
|
+
if (button instanceof HTMLElement) {
|
|
1861
|
+
setActiveGitFile(button);
|
|
1862
|
+
}
|
|
1863
|
+
diffPanel.textContent = "Loading diff...";
|
|
1864
|
+
const params = new URLSearchParams({ path });
|
|
1865
|
+
if (cached) {
|
|
1866
|
+
params.set("cached", "1");
|
|
1867
|
+
}
|
|
1868
|
+
const response = await fetch(`/api/git-diff?${params.toString()}`);
|
|
1869
|
+
const data = await response.json();
|
|
1870
|
+
const payload = data.payload || {};
|
|
1871
|
+
if (!response.ok || !data.ok || payload.state !== "ok") {
|
|
1872
|
+
diffPanel.textContent = payload.message || data.error || "Unable to load diff.";
|
|
1873
|
+
return;
|
|
1874
|
+
}
|
|
1875
|
+
const content = payload.diff || payload.message || "No diff is available for this file.";
|
|
1876
|
+
diffPanel.innerHTML = `<div class="viewer-git__diff-meta">${escapeHtml(payload.path || path)} · ${escapeHtml(payload.mode || "worktree")}${payload.truncated ? " · truncated" : ""}</div><pre><code>${escapeHtml(content)}</code></pre>`;
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
function applyGitDomain(domain) {
|
|
1880
|
+
const selected = domain || "changes";
|
|
1881
|
+
document.querySelectorAll(".viewer-git__domain[data-viewer-git-domain]").forEach((node) => {
|
|
1882
|
+
if (node instanceof HTMLElement) {
|
|
1883
|
+
const active = node.getAttribute("data-viewer-git-domain") === selected;
|
|
1884
|
+
node.classList.toggle("is-active", active);
|
|
1885
|
+
node.setAttribute("aria-pressed", active ? "true" : "false");
|
|
1886
|
+
}
|
|
1887
|
+
});
|
|
1888
|
+
document.querySelectorAll("[data-viewer-git-panel]").forEach((node) => {
|
|
1889
|
+
if (node instanceof HTMLElement) {
|
|
1890
|
+
node.hidden = node.getAttribute("data-viewer-git-panel") !== selected;
|
|
1891
|
+
}
|
|
1892
|
+
});
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
function currentGitViewState() {
|
|
1896
|
+
const activeDomain = document.querySelector(".viewer-git__domain.is-active[data-viewer-git-domain]");
|
|
1897
|
+
const activeFile = document.querySelector(".viewer-git__file.is-active[data-viewer-git-file]");
|
|
1898
|
+
return {
|
|
1899
|
+
domain: activeDomain instanceof HTMLElement ? activeDomain.getAttribute("data-viewer-git-domain") || "changes" : "changes",
|
|
1900
|
+
path: activeFile instanceof HTMLElement ? activeFile.getAttribute("data-viewer-git-file") || "" : "",
|
|
1901
|
+
cached: activeFile instanceof HTMLElement && activeFile.getAttribute("data-viewer-git-cached") === "1",
|
|
1902
|
+
};
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
function findGitFileButton(path, cached) {
|
|
1906
|
+
return Array.from(document.querySelectorAll("[data-viewer-git-file]")).find((node) => (
|
|
1907
|
+
node instanceof HTMLElement &&
|
|
1908
|
+
node.getAttribute("data-viewer-git-file") === path &&
|
|
1909
|
+
(node.getAttribute("data-viewer-git-cached") === "1") === Boolean(cached)
|
|
1910
|
+
)) || null;
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
async function showGitStatus(options = {}) {
|
|
1914
|
+
const previous = options.preserve ? currentGitViewState() : { domain: "changes", path: "", cached: false };
|
|
1915
|
+
if (!options.silent) {
|
|
1916
|
+
setMeta("Checking Git status...");
|
|
1917
|
+
}
|
|
1111
1918
|
const response = await fetch("/api/git-status");
|
|
1112
1919
|
let data = {};
|
|
1113
1920
|
try {
|
|
@@ -1126,8 +1933,16 @@
|
|
|
1126
1933
|
if (!response.ok || !data.ok) {
|
|
1127
1934
|
throw new Error(data.error || "Unable to load Git status.");
|
|
1128
1935
|
}
|
|
1936
|
+
setGitBadgeCountsFromPayload(data.payload, { updateMain: false });
|
|
1937
|
+
updateMainGitBadges();
|
|
1129
1938
|
setDocument("Git status", renderGitStatus(data.payload));
|
|
1130
|
-
|
|
1939
|
+
applyGitDomain(previous.domain || "changes");
|
|
1940
|
+
const restoredFile = previous.path ? findGitFileButton(previous.path, previous.cached) : null;
|
|
1941
|
+
const firstFile = restoredFile || document.querySelector("[data-viewer-git-file]");
|
|
1942
|
+
if (firstFile instanceof HTMLElement) {
|
|
1943
|
+
await loadGitDiff(firstFile.getAttribute("data-viewer-git-file") || "", firstFile.getAttribute("data-viewer-git-cached") === "1", firstFile);
|
|
1944
|
+
}
|
|
1945
|
+
setMeta(options.silent ? "Git status refreshed." : "Git status loaded.");
|
|
1131
1946
|
}
|
|
1132
1947
|
|
|
1133
1948
|
window.acquireVsCodeApi = function acquireVsCodeApi() {
|
|
@@ -1141,7 +1956,7 @@
|
|
|
1141
1956
|
return;
|
|
1142
1957
|
}
|
|
1143
1958
|
if (message.type === "refresh") {
|
|
1144
|
-
|
|
1959
|
+
refreshViewer("POST").catch((error) => setMeta(error.message));
|
|
1145
1960
|
return;
|
|
1146
1961
|
}
|
|
1147
1962
|
if (message.type === "open" || message.type === "read") {
|
|
@@ -1184,12 +1999,42 @@
|
|
|
1184
1999
|
});
|
|
1185
2000
|
setAutoRefreshEnabled(autoControl.checked);
|
|
1186
2001
|
}
|
|
2002
|
+
const intervalControl = refreshIntervalControl();
|
|
2003
|
+
if (intervalControl instanceof HTMLSelectElement) {
|
|
2004
|
+
updateRefreshIntervalControl();
|
|
2005
|
+
intervalControl.addEventListener("change", () => {
|
|
2006
|
+
setAutoRefreshIntervalSeconds(intervalControl.value, { user: true });
|
|
2007
|
+
});
|
|
2008
|
+
}
|
|
2009
|
+
bindRefreshMenuControls();
|
|
2010
|
+
document.addEventListener("click", (event) => {
|
|
2011
|
+
const target = event.target;
|
|
2012
|
+
const button = refreshMenuButton();
|
|
2013
|
+
const panel = refreshMenuPanel();
|
|
2014
|
+
try {
|
|
2015
|
+
if (target && (
|
|
2016
|
+
button?.contains(target) ||
|
|
2017
|
+
panel?.contains(target)
|
|
2018
|
+
)) {
|
|
2019
|
+
return;
|
|
2020
|
+
}
|
|
2021
|
+
} catch {
|
|
2022
|
+
// Ignore non-node event targets and close the menu below.
|
|
2023
|
+
}
|
|
2024
|
+
setRefreshMenuOpen(false);
|
|
2025
|
+
});
|
|
2026
|
+
document.addEventListener("keydown", (event) => {
|
|
2027
|
+
if (event.key === "Escape") {
|
|
2028
|
+
setRefreshMenuOpen(false);
|
|
2029
|
+
}
|
|
2030
|
+
});
|
|
1187
2031
|
document.querySelectorAll('[data-action="refresh"]').forEach((element) => {
|
|
1188
2032
|
if (!(element instanceof HTMLElement)) {
|
|
1189
2033
|
return;
|
|
1190
2034
|
}
|
|
1191
2035
|
element.addEventListener("click", () => {
|
|
1192
|
-
|
|
2036
|
+
setRefreshMenuOpen(false);
|
|
2037
|
+
refreshViewer("POST").catch((error) => setMeta(error.message));
|
|
1193
2038
|
});
|
|
1194
2039
|
});
|
|
1195
2040
|
document.getElementById("viewer-health")?.addEventListener("click", () => {
|
|
@@ -1198,6 +2043,9 @@
|
|
|
1198
2043
|
document.getElementById("viewer-git")?.addEventListener("click", () => {
|
|
1199
2044
|
showGitStatus().catch((error) => setMeta(error.message));
|
|
1200
2045
|
});
|
|
2046
|
+
document.getElementById("viewer-cdx")?.addEventListener("click", () => {
|
|
2047
|
+
showCdxStatus().catch((error) => setMeta(error.message));
|
|
2048
|
+
});
|
|
1201
2049
|
activityClearControl()?.addEventListener("click", () => {
|
|
1202
2050
|
clearActivityHistory();
|
|
1203
2051
|
});
|
|
@@ -1230,6 +2078,8 @@
|
|
|
1230
2078
|
const healthTarget = event.target instanceof Element ? event.target.closest("[data-viewer-open-health]") : null;
|
|
1231
2079
|
const filterTarget = event.target instanceof Element ? event.target.closest("[data-viewer-filter-group][data-viewer-filter-value]") : null;
|
|
1232
2080
|
const revealTarget = event.target instanceof Element ? event.target.closest("[data-viewer-reveal]") : null;
|
|
2081
|
+
const gitDomainTarget = event.target instanceof Element ? event.target.closest(".viewer-git__domain[data-viewer-git-domain]") : null;
|
|
2082
|
+
const gitFileTarget = event.target instanceof Element ? event.target.closest("[data-viewer-git-file]") : null;
|
|
1233
2083
|
if (revealTarget instanceof HTMLElement) {
|
|
1234
2084
|
const list = revealTarget.closest("ul");
|
|
1235
2085
|
list?.querySelectorAll("[data-viewer-hidden-row]").forEach((row) => {
|
|
@@ -1241,6 +2091,18 @@
|
|
|
1241
2091
|
revealTarget.closest("li")?.remove();
|
|
1242
2092
|
return;
|
|
1243
2093
|
}
|
|
2094
|
+
if (gitDomainTarget instanceof HTMLElement) {
|
|
2095
|
+
applyGitDomain(gitDomainTarget.getAttribute("data-viewer-git-domain") || "changes");
|
|
2096
|
+
return;
|
|
2097
|
+
}
|
|
2098
|
+
if (gitFileTarget instanceof HTMLElement) {
|
|
2099
|
+
loadGitDiff(
|
|
2100
|
+
gitFileTarget.getAttribute("data-viewer-git-file") || "",
|
|
2101
|
+
gitFileTarget.getAttribute("data-viewer-git-cached") === "1",
|
|
2102
|
+
gitFileTarget
|
|
2103
|
+
).catch((error) => setMeta(error.message));
|
|
2104
|
+
return;
|
|
2105
|
+
}
|
|
1244
2106
|
if (healthTarget instanceof HTMLElement) {
|
|
1245
2107
|
showHealth().catch((error) => setMeta(error.message));
|
|
1246
2108
|
return;
|