gh-glance 0.7.0 → 0.9.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/CHANGELOG.md +95 -1
- package/README.md +97 -47
- package/index.mjs +1069 -185
- package/package.json +3 -1
package/index.mjs
CHANGED
|
@@ -23,11 +23,16 @@
|
|
|
23
23
|
process.env.NODE_ENV ??= "production";
|
|
24
24
|
|
|
25
25
|
import { execFile, spawn } from "node:child_process";
|
|
26
|
+
import { createHash } from "node:crypto";
|
|
26
27
|
import {
|
|
28
|
+
chmodSync,
|
|
29
|
+
closeSync,
|
|
27
30
|
mkdirSync,
|
|
31
|
+
openSync,
|
|
28
32
|
readFileSync,
|
|
29
33
|
realpathSync,
|
|
30
34
|
renameSync,
|
|
35
|
+
statSync,
|
|
31
36
|
unlinkSync,
|
|
32
37
|
writeFileSync,
|
|
33
38
|
} from "node:fs";
|
|
@@ -96,18 +101,16 @@ const MAX_RUN_LIMIT = 60;
|
|
|
96
101
|
// stay generous. They are also far cheaper, being bounded by what's open.
|
|
97
102
|
const LIST_LIMIT = 150;
|
|
98
103
|
|
|
99
|
-
// Alert endpoints are filtered server-side to open items and
|
|
100
|
-
// page. The previous --paginate walked the repo's entire alert
|
|
101
|
-
// mostly closed alerts -- and then discarded them client-side.
|
|
104
|
+
// Alert endpoints are filtered server-side to open items and every lane is
|
|
105
|
+
// capped at one page. The previous --paginate walked the repo's entire alert
|
|
106
|
+
// history -- mostly closed alerts -- and then discarded them client-side.
|
|
102
107
|
const ALERT_PER_PAGE = 100;
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
// survives GitHub changing a default underneath us. All three endpoints accept
|
|
110
|
-
// these parameters.
|
|
108
|
+
// Newest-first makes the base cut deterministic. When it fills, bounded
|
|
109
|
+
// critical/high lanes for the sources that support severity filtering recover
|
|
110
|
+
// priority rows beyond that cut; a full lane remains explicitly incomplete.
|
|
111
|
+
// Secret scanning has no severity filter and every row is critical, so its one
|
|
112
|
+
// newest lane is the honest bounded shape. All three endpoints accept the base
|
|
113
|
+
// parameters below.
|
|
111
114
|
const ALERT_QUERY = `?state=open&per_page=${ALERT_PER_PAGE}&sort=created&direction=desc`;
|
|
112
115
|
|
|
113
116
|
// Inactive tabs only feed the tab-bar counts, so they refresh every Nth tick
|
|
@@ -441,6 +444,13 @@ const VERDICT_REMEDY = {
|
|
|
441
444
|
"Repository not found or inaccessible to the active `gh` account -- check `gh auth status` and the repository target",
|
|
442
445
|
};
|
|
443
446
|
|
|
447
|
+
const NARROW_VERDICT_REMEDY = {
|
|
448
|
+
"auth-problem": "Run: gh auth status (login required)",
|
|
449
|
+
"rate-limited": "Wait: GitHub rate limit; retrying",
|
|
450
|
+
unavailable: "Run: gh-glance --doctor (repo unavailable)",
|
|
451
|
+
"no-remote": "Run: gh-glance --repo owner/name",
|
|
452
|
+
};
|
|
453
|
+
|
|
444
454
|
function toTabError(err) {
|
|
445
455
|
return { kind: "fetch", verdict: classify(err), raw: shortErr(err) };
|
|
446
456
|
}
|
|
@@ -503,6 +513,16 @@ function formatTabError(error, failureContext = null) {
|
|
|
503
513
|
return pick(VERDICT_REMEDY, error.verdict, null) ?? error.raw;
|
|
504
514
|
}
|
|
505
515
|
|
|
516
|
+
function formatTabErrorForWidth(error, failureContext = null, width = MAX_ERR_LENGTH) {
|
|
517
|
+
const budget = Number.isSafeInteger(width) ? Math.max(0, width) : MAX_ERR_LENGTH;
|
|
518
|
+
const full = formatTabError(error, failureContext) ?? "";
|
|
519
|
+
const candidate =
|
|
520
|
+
budget < 60 && error?.kind === "fetch"
|
|
521
|
+
? (pick(NARROW_VERDICT_REMEDY, error.verdict, null) ?? `Run: gh-glance --doctor (${full})`)
|
|
522
|
+
: full;
|
|
523
|
+
return candidate.length <= budget ? candidate : `${candidate.slice(0, Math.max(0, budget - 1))}…`;
|
|
524
|
+
}
|
|
525
|
+
|
|
506
526
|
// Some pty wrappers (and a terminal mid-resize) report a size of 0 or
|
|
507
527
|
// undefined. Taking that literally collapses the table to a single row (or
|
|
508
528
|
// draws a zero-width border), so fall back to sane defaults until real
|
|
@@ -808,6 +828,7 @@ async function fetchActions(limit, signal) {
|
|
|
808
828
|
// re-derived from the argv shape -- a second copy of the cost model is the
|
|
809
829
|
// drift this file warns about repeatedly.
|
|
810
830
|
restSpent: REST_PER_FETCH.actions,
|
|
831
|
+
graphqlSpent: GRAPHQL_PER_FETCH.actions,
|
|
811
832
|
parse: () =>
|
|
812
833
|
JSON.parse(raw).map((r) => ({
|
|
813
834
|
databaseId: r.databaseId,
|
|
@@ -856,6 +877,7 @@ async function fetchIssues(signal) {
|
|
|
856
877
|
// Stated rather than omitted so the zero is visibly deliberate: SORT_RECENT's
|
|
857
878
|
// --search routes this through GraphQL, which is a separate budget.
|
|
858
879
|
restSpent: REST_PER_FETCH.issues,
|
|
880
|
+
graphqlSpent: GRAPHQL_PER_FETCH.issues,
|
|
859
881
|
parse: () =>
|
|
860
882
|
JSON.parse(raw).map((i) => ({
|
|
861
883
|
number: i.number,
|
|
@@ -889,6 +911,7 @@ async function fetchPRs(signal) {
|
|
|
889
911
|
limit: LIST_LIMIT,
|
|
890
912
|
// Zero for the same reason as issues: --search makes this a GraphQL call.
|
|
891
913
|
restSpent: REST_PER_FETCH.prs,
|
|
914
|
+
graphqlSpent: GRAPHQL_PER_FETCH.prs,
|
|
892
915
|
parse: () =>
|
|
893
916
|
JSON.parse(raw).map((p) => ({
|
|
894
917
|
number: p.number,
|
|
@@ -921,6 +944,7 @@ const ALERT_SOURCES = [
|
|
|
921
944
|
key: "dependabot",
|
|
922
945
|
name: "Dependabot alerts",
|
|
923
946
|
path: `repos/{owner}/{repo}/dependabot/alerts${ALERT_QUERY}`,
|
|
947
|
+
priorityQueries: ["severity=critical,high"],
|
|
924
948
|
jq: "[.[] | {number, state, created_at, severity: .security_advisory.severity, title: .security_advisory.summary, detail: .dependency.package.name}]",
|
|
925
949
|
unavailable: "Dependabot alerts: unavailable (not enabled for this repository)",
|
|
926
950
|
map: (a) => ({
|
|
@@ -936,6 +960,7 @@ const ALERT_SOURCES = [
|
|
|
936
960
|
key: "codeScanning",
|
|
937
961
|
name: "Code scanning",
|
|
938
962
|
path: `repos/{owner}/{repo}/code-scanning/alerts${ALERT_QUERY}`,
|
|
963
|
+
priorityQueries: ["severity=critical", "severity=high"],
|
|
939
964
|
jq: "[.[] | {number, state, created_at, severity: (.rule.security_severity_level // .rule.severity), title: (.rule.description // .rule.name), detail: .most_recent_instance.location.path}]",
|
|
940
965
|
unavailable: "Code scanning: not enabled (needs GitHub Advanced Security)",
|
|
941
966
|
map: (a) => ({
|
|
@@ -951,6 +976,7 @@ const ALERT_SOURCES = [
|
|
|
951
976
|
key: "secretScanning",
|
|
952
977
|
name: "Secret scanning",
|
|
953
978
|
path: `repos/{owner}/{repo}/secret-scanning/alerts${ALERT_QUERY}`,
|
|
979
|
+
priorityQueries: [],
|
|
954
980
|
jq: "[.[] | {number, state, created_at, title: (.secret_type_display_name // .secret_type)}]",
|
|
955
981
|
unavailable: "Secret scanning: not enabled for this repository",
|
|
956
982
|
map: (a) => ({
|
|
@@ -977,7 +1003,11 @@ const ALERT_SOURCES = [
|
|
|
977
1003
|
//
|
|
978
1004
|
// issues and prs are 0 REST because SORT_RECENT's --search routes both through
|
|
979
1005
|
// GraphQL entirely (2 POSTs each, confirmed by the same measurement).
|
|
980
|
-
const
|
|
1006
|
+
const SECURITY_REQUESTS_PER_FETCH = ALERT_SOURCES.reduce(
|
|
1007
|
+
(total, source) => total + 1 + source.priorityQueries.length,
|
|
1008
|
+
0,
|
|
1009
|
+
);
|
|
1010
|
+
const REST_PER_FETCH = { actions: 2, issues: 0, prs: 0, security: SECURITY_REQUESTS_PER_FETCH };
|
|
981
1011
|
const GRAPHQL_PER_FETCH = { actions: 0, issues: 2, prs: 2, security: 0 };
|
|
982
1012
|
|
|
983
1013
|
// Ceiling on adaptive widening. An adaptive interval needs one: a minute is
|
|
@@ -1012,7 +1042,7 @@ const ADAPTIVE_HYSTERESIS = 1.25;
|
|
|
1012
1042
|
// that feeds it must agree on the answer: the law uses it to decide whether to
|
|
1013
1043
|
// infer, and the loop uses it to decide whether the window may be closed.
|
|
1014
1044
|
function sampleIsUsable(sample) {
|
|
1015
|
-
return Boolean(sample) && sample.
|
|
1045
|
+
return Boolean(sample) && sample.myCalls >= MIN_SAMPLE_CALLS && sample.globalUsed > 0;
|
|
1016
1046
|
}
|
|
1017
1047
|
|
|
1018
1048
|
// How many equivalent consumers are on this token, from one probe window.
|
|
@@ -1027,7 +1057,7 @@ function sampleIsUsable(sample) {
|
|
|
1027
1057
|
// where it is until a window is long enough to say otherwise.
|
|
1028
1058
|
function inferShare(sample, lastShare = 1) {
|
|
1029
1059
|
return sampleIsUsable(sample)
|
|
1030
|
-
? Math.max(1, sample.globalUsed / sample.
|
|
1060
|
+
? Math.max(1, sample.globalUsed / sample.myCalls)
|
|
1031
1061
|
: Math.max(1, lastShare);
|
|
1032
1062
|
}
|
|
1033
1063
|
|
|
@@ -1050,7 +1080,7 @@ function nextProbeWindow(prev, budget, spentTotal) {
|
|
|
1050
1080
|
}
|
|
1051
1081
|
const sample = {
|
|
1052
1082
|
globalUsed: budget.used - prev.used,
|
|
1053
|
-
|
|
1083
|
+
myCalls: spentTotal - prev.spent,
|
|
1054
1084
|
};
|
|
1055
1085
|
return { sample, next: sampleIsUsable(sample) ? { used: budget.used, spent: spentTotal } : prev };
|
|
1056
1086
|
}
|
|
@@ -1101,15 +1131,67 @@ function adaptiveChangeWorthApplying(appliedMs, targetMs) {
|
|
|
1101
1131
|
// other three amortised over BACKGROUND_EVERY. Same table and same shape as
|
|
1102
1132
|
// projectedHourlyCost, so the controller and the report cannot disagree.
|
|
1103
1133
|
function restPerTick(activeKey) {
|
|
1104
|
-
|
|
1134
|
+
return resourcePerTick(REST_PER_FETCH, activeKey);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function resourcePerTick(table, activeKey) {
|
|
1138
|
+
let cost = table[activeKey] ?? 0;
|
|
1105
1139
|
for (const key of TAB_KEYS) {
|
|
1106
|
-
if (key !== activeKey) cost += (
|
|
1140
|
+
if (key !== activeKey) cost += (table[key] ?? 0) / BACKGROUND_EVERY;
|
|
1107
1141
|
}
|
|
1108
1142
|
return cost;
|
|
1109
1143
|
}
|
|
1110
1144
|
|
|
1111
|
-
function
|
|
1112
|
-
|
|
1145
|
+
function nextBudgetTargets({ budgets, samples, shares, previous, activeKey, floorMs, nowMs }) {
|
|
1146
|
+
const targets = { core: floorMs, graphql: floorMs, ...previous };
|
|
1147
|
+
for (const [resource, table] of [
|
|
1148
|
+
["core", REST_PER_FETCH],
|
|
1149
|
+
["graphql", GRAPHQL_PER_FETCH],
|
|
1150
|
+
]) {
|
|
1151
|
+
const callsPerTick = resourcePerTick(table, activeKey);
|
|
1152
|
+
if (callsPerTick <= 0 || !budgets?.[resource]) continue;
|
|
1153
|
+
targets[resource] = adaptiveRefreshMs({
|
|
1154
|
+
budget: budgets[resource],
|
|
1155
|
+
sample: samples?.[resource] ?? null,
|
|
1156
|
+
lastShare: shares?.[resource] ?? 1,
|
|
1157
|
+
floorMs,
|
|
1158
|
+
nowMs,
|
|
1159
|
+
restPerTick: callsPerTick,
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
return { targets, targetMs: Math.max(floorMs, ...Object.values(targets)) };
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
function alertArgs(source, path = source.path) {
|
|
1166
|
+
return ["api", apiPath(path), ...apiHostArgs(), "--jq", source.jq];
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function alertRequestArgs(source) {
|
|
1170
|
+
if (!source || typeof source.path !== "string" || !Array.isArray(source.priorityQueries)) {
|
|
1171
|
+
return [];
|
|
1172
|
+
}
|
|
1173
|
+
return [
|
|
1174
|
+
alertArgs(source),
|
|
1175
|
+
...source.priorityQueries.map((query) => alertArgs(source, `${source.path}&${query}`)),
|
|
1176
|
+
];
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
function shouldFetchAlertPriorityLanes(openCount) {
|
|
1180
|
+
return openCount >= ALERT_PER_PAGE;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
function mergeAlertRows(groups) {
|
|
1184
|
+
const seen = new Set();
|
|
1185
|
+
const merged = [];
|
|
1186
|
+
for (const rows of groups) {
|
|
1187
|
+
for (const row of Array.isArray(rows) ? rows : []) {
|
|
1188
|
+
const key = row?.number ?? row?.id;
|
|
1189
|
+
if (key == null || seen.has(key)) continue;
|
|
1190
|
+
seen.add(key);
|
|
1191
|
+
merged.push(row);
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
return merged;
|
|
1113
1195
|
}
|
|
1114
1196
|
|
|
1115
1197
|
// Per-source backoff. Keyed by source so one unavailable endpoint cannot slow
|
|
@@ -1117,17 +1199,17 @@ function alertArgs(source) {
|
|
|
1117
1199
|
// mid-session is picked up within the hour.
|
|
1118
1200
|
const alertBackoff = new Map();
|
|
1119
1201
|
|
|
1120
|
-
// REST calls this process has spent, ever. In-memory and per-process,
|
|
1121
|
-
// same spirit as alertBackoff above: lost on exit, and nothing depends on
|
|
1122
|
-
// surviving.
|
|
1123
|
-
//
|
|
1202
|
+
// REST and GraphQL calls this process has spent, ever. In-memory and per-process,
|
|
1203
|
+
// in the same spirit as alertBackoff above: lost on exit, and nothing depends on
|
|
1204
|
+
// it surviving. Each counter is compared only against its matching resource
|
|
1205
|
+
// between two budget probes, so neither needs persistence or an extra window.
|
|
1124
1206
|
//
|
|
1125
1207
|
// Deliberately incomplete: openItem, preflight and resolveFailureContext also
|
|
1126
1208
|
// spend, and are not counted, because they are occasional rather than periodic.
|
|
1127
1209
|
// Under-reporting our own spend makes the inferred share *larger* and so
|
|
1128
1210
|
// throttles slightly harder than strictly necessary -- the safe direction to err
|
|
1129
1211
|
// in, which is why the imprecision is tolerated rather than chased.
|
|
1130
|
-
|
|
1212
|
+
const spentTotal = { core: 0, graphql: 0 };
|
|
1131
1213
|
|
|
1132
1214
|
function backoffActive(key, now) {
|
|
1133
1215
|
const state = alertBackoff.get(key);
|
|
@@ -1158,6 +1240,13 @@ function clearBackoff(key) {
|
|
|
1158
1240
|
alertBackoff.delete(key);
|
|
1159
1241
|
}
|
|
1160
1242
|
|
|
1243
|
+
function forcedBackoffKeys(key) {
|
|
1244
|
+
return [
|
|
1245
|
+
`tab:${key}`,
|
|
1246
|
+
...(key === "security" ? ALERT_SOURCES.map((source) => source.key) : []),
|
|
1247
|
+
];
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1161
1250
|
async function fetchAlertSource(source, signal, now) {
|
|
1162
1251
|
if (backoffActive(source.key, now)) {
|
|
1163
1252
|
const { note, verdict } = alertBackoff.get(source.key);
|
|
@@ -1172,19 +1261,30 @@ async function fetchAlertSource(source, signal, now) {
|
|
|
1172
1261
|
parse: () => ({ alerts: [], note, verdict, truncated: false }),
|
|
1173
1262
|
};
|
|
1174
1263
|
}
|
|
1264
|
+
let spent = 0;
|
|
1175
1265
|
try {
|
|
1176
|
-
const
|
|
1266
|
+
const requests = alertRequestArgs(source);
|
|
1267
|
+
const payloads = [];
|
|
1268
|
+
const groups = [];
|
|
1269
|
+
for (const [index, args] of requests.entries()) {
|
|
1270
|
+
if (index > 0 && !shouldFetchAlertPriorityLanes(groups[0]?.length ?? 0)) break;
|
|
1271
|
+
spent += 1;
|
|
1272
|
+
const payload = await runGh(args, { signal });
|
|
1273
|
+
payloads.push(payload);
|
|
1274
|
+
groups.push(JSON.parse(payload).filter((alert) => alert.state === "open"));
|
|
1275
|
+
}
|
|
1276
|
+
const raw = payloads.join("\0");
|
|
1177
1277
|
clearBackoff(source.key);
|
|
1178
1278
|
return {
|
|
1179
1279
|
raw,
|
|
1180
|
-
spent
|
|
1280
|
+
spent,
|
|
1181
1281
|
parse: () => {
|
|
1182
|
-
const rows =
|
|
1282
|
+
const rows = mergeAlertRows(groups);
|
|
1183
1283
|
return {
|
|
1184
1284
|
alerts: rows.map(source.map),
|
|
1185
1285
|
note: null,
|
|
1186
1286
|
verdict: "ok",
|
|
1187
|
-
truncated:
|
|
1287
|
+
truncated: groups.some((group) => group.length >= ALERT_PER_PAGE),
|
|
1188
1288
|
};
|
|
1189
1289
|
},
|
|
1190
1290
|
};
|
|
@@ -1204,7 +1304,7 @@ async function fetchAlertSource(source, signal, now) {
|
|
|
1204
1304
|
raw: `unavailable:${note}`,
|
|
1205
1305
|
// A failed call still bills: the request reached GitHub and was counted
|
|
1206
1306
|
// whether it returned data, `[]`, or a 403.
|
|
1207
|
-
spent
|
|
1307
|
+
spent,
|
|
1208
1308
|
parse: () => ({ alerts: [], note, verdict, truncated: false }),
|
|
1209
1309
|
};
|
|
1210
1310
|
}
|
|
@@ -1242,6 +1342,7 @@ async function fetchSecurity(signal) {
|
|
|
1242
1342
|
// ALERT_SOURCES.length: a source held off by backoff spawned nothing and
|
|
1243
1343
|
// must not be billed for it.
|
|
1244
1344
|
restSpent: parts.reduce((n, p) => n + p.spent, 0),
|
|
1345
|
+
graphqlSpent: 0,
|
|
1245
1346
|
parse: () => {
|
|
1246
1347
|
const parsed = parts.map((p) => p.parse());
|
|
1247
1348
|
const alerts = parsed.flatMap((p) => p.alerts);
|
|
@@ -1278,6 +1379,19 @@ function itemKey(item) {
|
|
|
1278
1379
|
return item?.id ?? item?.databaseId ?? item?.number ?? null;
|
|
1279
1380
|
}
|
|
1280
1381
|
|
|
1382
|
+
function reconcileSelectionViewport({ items, key, offset = 0, rows = 1 }) {
|
|
1383
|
+
const list = Array.isArray(items) ? items : [];
|
|
1384
|
+
const rowCount = Math.max(1, Number.isSafeInteger(rows) ? rows : 1);
|
|
1385
|
+
const maxOffset = Math.max(0, list.length - rowCount);
|
|
1386
|
+
let nextOffset = Math.min(Math.max(0, Number.isSafeInteger(offset) ? offset : 0), maxOffset);
|
|
1387
|
+
if (key == null) return { key: null, offset: nextOffset };
|
|
1388
|
+
const index = list.findIndex((item) => itemKey(item) === key);
|
|
1389
|
+
if (index < 0) return { key: null, offset: nextOffset };
|
|
1390
|
+
if (index < nextOffset) nextOffset = index;
|
|
1391
|
+
else if (index >= nextOffset + rowCount) nextOffset = index - rowCount + 1;
|
|
1392
|
+
return { key, offset: nextOffset };
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1281
1395
|
// `gh <kind> view --web` for the tabs that have a per-item command. The Security
|
|
1282
1396
|
// tab is absent on purpose: alerts have no `gh` view subcommand, so there is
|
|
1283
1397
|
// nothing honest to open.
|
|
@@ -1300,6 +1414,30 @@ async function openInBrowser(tabKey, item, signal) {
|
|
|
1300
1414
|
await runGh([kind, "view", ...repoArgs(), String(id), "--web"], { signal });
|
|
1301
1415
|
}
|
|
1302
1416
|
|
|
1417
|
+
function createOpenRequestRegistry() {
|
|
1418
|
+
const requests = new Map();
|
|
1419
|
+
return {
|
|
1420
|
+
start(key, run) {
|
|
1421
|
+
if (requests.has(key)) return null;
|
|
1422
|
+
const controller = new AbortController();
|
|
1423
|
+
requests.set(key, { controller, promise: null });
|
|
1424
|
+
let started;
|
|
1425
|
+
try {
|
|
1426
|
+
started = run({ signal: controller.signal });
|
|
1427
|
+
} catch (error) {
|
|
1428
|
+
started = Promise.reject(error);
|
|
1429
|
+
}
|
|
1430
|
+
const promise = Promise.resolve(started).finally(() => requests.delete(key));
|
|
1431
|
+
requests.set(key, { controller, promise });
|
|
1432
|
+
return promise;
|
|
1433
|
+
},
|
|
1434
|
+
abortAll() {
|
|
1435
|
+
for (const { controller } of requests.values()) controller.abort();
|
|
1436
|
+
},
|
|
1437
|
+
size: () => requests.size,
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1303
1441
|
// ---------- Startup preflight ----------
|
|
1304
1442
|
|
|
1305
1443
|
// Three failures are guaranteed on a fresh machine or a terminal that happens
|
|
@@ -1394,6 +1532,20 @@ function doctorEnvNames() {
|
|
|
1394
1532
|
return [...named, ...extra];
|
|
1395
1533
|
}
|
|
1396
1534
|
|
|
1535
|
+
function summarizeDoctorEnv(name, value, { home = homedir() } = {}) {
|
|
1536
|
+
if (!value) return NOT_SET;
|
|
1537
|
+
if (name === "NO_PROXY") {
|
|
1538
|
+
const entries = String(value).split(",").map((entry) => entry.trim()).filter(Boolean).length;
|
|
1539
|
+
return `set (${entries} ${entries === 1 ? "entry" : "entries"})`;
|
|
1540
|
+
}
|
|
1541
|
+
if (name === "GH_CONFIG_DIR") {
|
|
1542
|
+
const text = String(value);
|
|
1543
|
+
const prefix = `${home}/`;
|
|
1544
|
+
return text === home ? "~" : text.startsWith(prefix) ? `~/${text.slice(prefix.length)}` : `…/${text.split(/[\\/]/).filter(Boolean).at(-1) ?? "gh"}`;
|
|
1545
|
+
}
|
|
1546
|
+
return String(value);
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1397
1549
|
// Presence-only is the DEFAULT; printing a value is the opt-in, and the opt-in
|
|
1398
1550
|
// list is curated above. This was the other way round -- print unless the *name*
|
|
1399
1551
|
// ended in _TOKEN/_SECRET/_PASSWORD/_KEY -- which meant the discovery loop above
|
|
@@ -1407,7 +1559,7 @@ function envValue(name) {
|
|
|
1407
1559
|
const value = process.env[name];
|
|
1408
1560
|
if (!value) return NOT_SET;
|
|
1409
1561
|
if (DOCTOR_ENV_PROXY.includes(name)) return proxySummary(value);
|
|
1410
|
-
if (DOCTOR_ENV_PLAIN.includes(name)) return value;
|
|
1562
|
+
if (DOCTOR_ENV_PLAIN.includes(name)) return summarizeDoctorEnv(name, value);
|
|
1411
1563
|
return "set";
|
|
1412
1564
|
}
|
|
1413
1565
|
|
|
@@ -1482,8 +1634,9 @@ function targetSource() {
|
|
|
1482
1634
|
|
|
1483
1635
|
// How much of the hourly API budget is left, and roughly how fast this
|
|
1484
1636
|
// configuration spends it. The steady-state cost is not small and was invisible:
|
|
1485
|
-
// at the default 5s refresh with the Security tab open
|
|
1486
|
-
// requests an hour -- about
|
|
1637
|
+
// at the default 5s refresh with the Security tab open the safe projection is
|
|
1638
|
+
// around 4,440 REST requests an hour -- about 89% of a personal token's 5,000 --
|
|
1639
|
+
// because a full newest page activates bounded priority lanes. `--refresh 2`
|
|
1487
1640
|
// projects past the limit outright, so it exhausts inside the hour, every hour.
|
|
1488
1641
|
// The budget is shared with everything else the token does, so the first symptom
|
|
1489
1642
|
// is usually "GitHub is broken" somewhere else entirely.
|
|
@@ -1513,28 +1666,43 @@ async function rateBudget() {
|
|
|
1513
1666
|
}
|
|
1514
1667
|
}
|
|
1515
1668
|
|
|
1516
|
-
// The same probe as rateBudget, but returning
|
|
1517
|
-
//
|
|
1518
|
-
// keeps its
|
|
1519
|
-
//
|
|
1669
|
+
// The same probe as rateBudget, but returning both resources as data rather than
|
|
1670
|
+
// sentences, because the adaptive loop has to do arithmetic on each budget.
|
|
1671
|
+
// rateBudget keeps its display shape for the --doctor path, where strings are
|
|
1672
|
+
// the product.
|
|
1520
1673
|
//
|
|
1521
1674
|
// Safe to run on a timer for the same reason it is safe on the diagnostic path:
|
|
1522
1675
|
// `gh api rate_limit` is documented as not counting against the limit, and it
|
|
1523
1676
|
// measures as free (verified: delta 0).
|
|
1524
|
-
|
|
1677
|
+
function normalizeRateBudget(resource) {
|
|
1678
|
+
if (
|
|
1679
|
+
!resource ||
|
|
1680
|
+
!Number.isFinite(resource.remaining) ||
|
|
1681
|
+
!Number.isFinite(resource.limit) ||
|
|
1682
|
+
!Number.isFinite(resource.used) ||
|
|
1683
|
+
!Number.isFinite(resource.reset)
|
|
1684
|
+
) {
|
|
1685
|
+
return null;
|
|
1686
|
+
}
|
|
1687
|
+
return {
|
|
1688
|
+
remaining: resource.remaining,
|
|
1689
|
+
limit: resource.limit,
|
|
1690
|
+
used: resource.used,
|
|
1691
|
+
resetMs: resource.reset * 1000,
|
|
1692
|
+
};
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
async function readRateBudgets(signal) {
|
|
1525
1696
|
try {
|
|
1526
1697
|
// apiHostArgs() for the same reason the alert endpoints carry it: --repo
|
|
1527
1698
|
// host/owner/name sets runtime.host without setting GH_HOST, so without the
|
|
1528
1699
|
// flag this reads github.com's budget while the pane spends against the
|
|
1529
1700
|
// tenant -- and then throttles, or fails to, against an unrelated number.
|
|
1530
1701
|
const raw = await runGh(["api", "rate_limit", ...apiHostArgs()], { signal });
|
|
1531
|
-
const
|
|
1532
|
-
if (!core || !Number.isFinite(core.remaining)) return null;
|
|
1702
|
+
const resources = JSON.parse(raw)?.resources;
|
|
1533
1703
|
return {
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
used: core.used,
|
|
1537
|
-
resetMs: core.reset * 1000,
|
|
1704
|
+
core: normalizeRateBudget(resources?.core),
|
|
1705
|
+
graphql: normalizeRateBudget(resources?.graphql),
|
|
1538
1706
|
};
|
|
1539
1707
|
} catch {
|
|
1540
1708
|
// A probe failure must never be louder than the pane's own data: the loop
|
|
@@ -1551,16 +1719,10 @@ async function readCoreBudget(signal) {
|
|
|
1551
1719
|
// two copies of them would diverge the first time one was fixed.
|
|
1552
1720
|
function projectedHourlyCost(activeKey) {
|
|
1553
1721
|
const perHour = 3_600_000 / runtime.refreshMs;
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
for (const key of TAB_KEYS) {
|
|
1559
|
-
const ticks = key === activeKey ? perHour : perHour / BACKGROUND_EVERY;
|
|
1560
|
-
rest += ticks * REST_PER_FETCH[key];
|
|
1561
|
-
graphql += ticks * GRAPHQL_PER_FETCH[key];
|
|
1562
|
-
}
|
|
1563
|
-
return { rest: Math.round(rest), graphql: Math.round(graphql) };
|
|
1722
|
+
return {
|
|
1723
|
+
rest: Math.round(resourcePerTick(REST_PER_FETCH, activeKey) * perHour),
|
|
1724
|
+
graphql: Math.round(resourcePerTick(GRAPHQL_PER_FETCH, activeKey) * perHour),
|
|
1725
|
+
};
|
|
1564
1726
|
}
|
|
1565
1727
|
|
|
1566
1728
|
async function runDoctor() {
|
|
@@ -1573,11 +1735,16 @@ async function runDoctor() {
|
|
|
1573
1735
|
["Actions (run list)", actionsArgs(MIN_RUN_LIMIT)],
|
|
1574
1736
|
["Issues (issue list)", issuesArgs()],
|
|
1575
1737
|
["Pull requests (pr list)", prsArgs()],
|
|
1576
|
-
...ALERT_SOURCES.
|
|
1738
|
+
...ALERT_SOURCES.flatMap((source) =>
|
|
1739
|
+
alertRequestArgs(source).map((args, index) => [
|
|
1740
|
+
index === 0 ? source.name : `${source.name} (priority ${index})`,
|
|
1741
|
+
args,
|
|
1742
|
+
]),
|
|
1743
|
+
),
|
|
1577
1744
|
];
|
|
1578
1745
|
|
|
1579
1746
|
// allSettled, and every probe already resolves rather than rejects, so one
|
|
1580
|
-
// slow endpoint cannot suppress the other
|
|
1747
|
+
// slow endpoint cannot suppress the other probes. runGh's own GH_TIMEOUT_MS
|
|
1581
1748
|
// bounds each one.
|
|
1582
1749
|
const [ghVersion, authStatus, remote, budget, results] = await Promise.all([
|
|
1583
1750
|
captureGh(["--version"]),
|
|
@@ -1816,6 +1983,17 @@ const KEY_TABLE = [
|
|
|
1816
1983
|
const KEY_COL = Math.max(...KEY_TABLE.map(([k]) => k.length)) + 3;
|
|
1817
1984
|
const keyTableLines = () => KEY_TABLE.map(([k, d]) => `${k.padEnd(KEY_COL)}${d}`);
|
|
1818
1985
|
|
|
1986
|
+
function helpLines(maxRows) {
|
|
1987
|
+
const rows = Math.max(1, Number.isSafeInteger(maxRows) ? maxRows : 1);
|
|
1988
|
+
const all = keyTableLines();
|
|
1989
|
+
if (all.length <= rows) return all;
|
|
1990
|
+
if (rows === 1) return [`… ${all.length} keys: gh-glance --help`];
|
|
1991
|
+
const priority = [9, 6, 5, 3, 0, 7, 8, 1, 2, 4]
|
|
1992
|
+
.slice(0, rows - 1)
|
|
1993
|
+
.map((index) => all[index]);
|
|
1994
|
+
return [...priority, `… ${all.length - priority.length} more: gh-glance --help`];
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1819
1997
|
const HELP = `gh-glance ${version} -- a live-refreshing GitHub dashboard for a narrow terminal pane.
|
|
1820
1998
|
|
|
1821
1999
|
Usage:
|
|
@@ -2097,6 +2275,10 @@ function Column({ width, grow, children, bold, color, dim, wrap, label, marginRi
|
|
|
2097
2275
|
);
|
|
2098
2276
|
}
|
|
2099
2277
|
|
|
2278
|
+
function selectionLabel(label, selected) {
|
|
2279
|
+
return selected ? `selected, ${label}` : label;
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2100
2282
|
// Each descriptor still owns one gutter cell, but an adjustable column owns
|
|
2101
2283
|
// the edge that faces the flexible TITLE/SUMMARY reservoir. Fixed columns after
|
|
2102
2284
|
// the grow cell therefore use the preceding descriptor's trailing gutter;
|
|
@@ -2328,6 +2510,10 @@ function sameElementMetrics(left, right) {
|
|
|
2328
2510
|
);
|
|
2329
2511
|
}
|
|
2330
2512
|
|
|
2513
|
+
function shouldEnableMouseReporting({ interactive, widthMode }) {
|
|
2514
|
+
return Boolean(interactive && widthMode);
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2331
2517
|
// ---------- Panel frame ----------
|
|
2332
2518
|
|
|
2333
2519
|
// Ink can draw a border but not label one, so the horizontal edges are plain
|
|
@@ -2471,7 +2657,7 @@ function ActionsRow({ item, now, spin, compact, cursor, columns }) {
|
|
|
2471
2657
|
return e(
|
|
2472
2658
|
Box,
|
|
2473
2659
|
{ flexDirection: "row" },
|
|
2474
|
-
e(Column, { ...columnProps(columns, "status"), color, label }, `${cursor ? ">" : " "}${icon}`),
|
|
2660
|
+
e(Column, { ...columnProps(columns, "status"), color, label: selectionLabel(label, cursor) }, `${cursor ? ">" : " "}${icon}`),
|
|
2475
2661
|
e(Column, columnProps(columns, "title"), item.displayTitle),
|
|
2476
2662
|
e(Column, { ...columnProps(columns, "updated"), dim: true }, formatAge(new Date(item.updatedAt), now)),
|
|
2477
2663
|
);
|
|
@@ -2479,7 +2665,7 @@ function ActionsRow({ item, now, spin, compact, cursor, columns }) {
|
|
|
2479
2665
|
return e(
|
|
2480
2666
|
Box,
|
|
2481
2667
|
{ flexDirection: "row" },
|
|
2482
|
-
e(Column, { ...columnProps(columns, "status"), color, label }, `${cursor ? ">" : " "}${icon}`),
|
|
2668
|
+
e(Column, { ...columnProps(columns, "status"), color, label: selectionLabel(label, cursor) }, `${cursor ? ">" : " "}${icon}`),
|
|
2483
2669
|
e(Column, columnProps(columns, "title"), item.displayTitle),
|
|
2484
2670
|
// The run number is the actionable half and used to be the first thing
|
|
2485
2671
|
// truncation ate, since it sat at the tail of a 10-column cell.
|
|
@@ -2508,7 +2694,7 @@ const ISSUES_HEADER_COMPACT = [
|
|
|
2508
2694
|
|
|
2509
2695
|
function IssueRow({ item, now, compact, cursor, columns }) {
|
|
2510
2696
|
const cells = [
|
|
2511
|
-
e(Column, { key: "status", ...columnProps(columns, "status"), color: OK, label: "open issue" }, `${cursor ? ">" : " "}${OCT.issueOpened}`),
|
|
2697
|
+
e(Column, { key: "status", ...columnProps(columns, "status"), color: OK, label: selectionLabel("open issue", cursor) }, `${cursor ? ">" : " "}${OCT.issueOpened}`),
|
|
2512
2698
|
e(Column, { key: "title", ...columnProps(columns, "title") }, `#${item.number} ${item.title}`),
|
|
2513
2699
|
];
|
|
2514
2700
|
if (!compact) {
|
|
@@ -2551,7 +2737,7 @@ function PRRow({ item, now, compact, cursor, columns }) {
|
|
|
2551
2737
|
: { icon: OCT.pullRequest, color: OK, label: "open pull request" };
|
|
2552
2738
|
const review = pick(REVIEW_LABEL, item.reviewDecision, REVIEW_NONE);
|
|
2553
2739
|
const cells = [
|
|
2554
|
-
e(Column, { key: "status", ...columnProps(columns, "status"), color: prIcon.color, label: prIcon.label }, `${cursor ? ">" : " "}${prIcon.icon}`),
|
|
2740
|
+
e(Column, { key: "status", ...columnProps(columns, "status"), color: prIcon.color, label: selectionLabel(prIcon.label, cursor) }, `${cursor ? ">" : " "}${prIcon.icon}`),
|
|
2555
2741
|
e(Column, { key: "title", ...columnProps(columns, "title") }, `#${item.number} ${item.title}`),
|
|
2556
2742
|
];
|
|
2557
2743
|
if (!compact) {
|
|
@@ -2605,7 +2791,7 @@ const SEVERITY_UNKNOWN = SEVERITY_STYLE.unknown;
|
|
|
2605
2791
|
function SecurityRow({ item, now, compact, cursor, columns }) {
|
|
2606
2792
|
const sev = pick(SEVERITY_STYLE, item.severity, SEVERITY_UNKNOWN);
|
|
2607
2793
|
const cells = [
|
|
2608
|
-
e(Column, { key: "status", ...columnProps(columns, "status"), color: sev.color, label: `${sev.short} severity
|
|
2794
|
+
e(Column, { key: "status", ...columnProps(columns, "status"), color: sev.color, label: selectionLabel(`${sev.short} severity`, cursor) }, `${cursor ? ">" : " "}${OCT.shield}`),
|
|
2609
2795
|
e(Column, { key: "severity", ...columnProps(columns, "severity"), color: sev.color }, sev.short),
|
|
2610
2796
|
];
|
|
2611
2797
|
if (!compact) {
|
|
@@ -2887,6 +3073,116 @@ function isRecord(value) {
|
|
|
2887
3073
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
2888
3074
|
}
|
|
2889
3075
|
|
|
3076
|
+
const persistenceWaitCell = new Int32Array(new SharedArrayBuffer(4));
|
|
3077
|
+
const PERSISTENCE_LOCK_WAIT_MS = 250;
|
|
3078
|
+
const PERSISTENCE_STALE_LOCK_MS = 5000;
|
|
3079
|
+
|
|
3080
|
+
function withPersistenceLock(path, operation) {
|
|
3081
|
+
const lockPath = `${path}.lock`;
|
|
3082
|
+
const deadline = Date.now() + PERSISTENCE_LOCK_WAIT_MS;
|
|
3083
|
+
let descriptor = null;
|
|
3084
|
+
while (descriptor === null) {
|
|
3085
|
+
try {
|
|
3086
|
+
descriptor = openSync(lockPath, "wx", 0o600);
|
|
3087
|
+
} catch (error) {
|
|
3088
|
+
if (error?.code !== "EEXIST") return { ok: false, error };
|
|
3089
|
+
try {
|
|
3090
|
+
if (Date.now() - statSync(lockPath).mtimeMs > PERSISTENCE_STALE_LOCK_MS) {
|
|
3091
|
+
unlinkSync(lockPath);
|
|
3092
|
+
continue;
|
|
3093
|
+
}
|
|
3094
|
+
} catch {
|
|
3095
|
+
continue;
|
|
3096
|
+
}
|
|
3097
|
+
if (Date.now() >= deadline) return { ok: false, busy: true, error };
|
|
3098
|
+
Atomics.wait(persistenceWaitCell, 0, 0, 10);
|
|
3099
|
+
}
|
|
3100
|
+
}
|
|
3101
|
+
try {
|
|
3102
|
+
return operation();
|
|
3103
|
+
} finally {
|
|
3104
|
+
try {
|
|
3105
|
+
closeSync(descriptor);
|
|
3106
|
+
} finally {
|
|
3107
|
+
try {
|
|
3108
|
+
unlinkSync(lockPath);
|
|
3109
|
+
} catch {
|
|
3110
|
+
// The lock is advisory; failure to remove it is recovered as stale.
|
|
3111
|
+
}
|
|
3112
|
+
}
|
|
3113
|
+
}
|
|
3114
|
+
}
|
|
3115
|
+
|
|
3116
|
+
function samePersistedValue(left, right) {
|
|
3117
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
3118
|
+
}
|
|
3119
|
+
|
|
3120
|
+
function mergeWidthPreferenceSnapshots(base, disk, next) {
|
|
3121
|
+
const merged = isRecord(disk) ? structuredClone(disk) : {};
|
|
3122
|
+
const base_ = isRecord(base) ? base : {};
|
|
3123
|
+
const next_ = isRecord(next) ? next : {};
|
|
3124
|
+
const tabKeys = new Set([...Object.keys(base_), ...Object.keys(next_)]);
|
|
3125
|
+
for (const tabKey of tabKeys) {
|
|
3126
|
+
const baseTab = isRecord(base_[tabKey]) ? base_[tabKey] : {};
|
|
3127
|
+
const nextTab = isRecord(next_[tabKey]) ? next_[tabKey] : {};
|
|
3128
|
+
const columnKeys = new Set([...Object.keys(baseTab), ...Object.keys(nextTab)]);
|
|
3129
|
+
for (const key of columnKeys) {
|
|
3130
|
+
if (samePersistedValue(baseTab[key], nextTab[key])) continue;
|
|
3131
|
+
if (!Object.hasOwn(nextTab, key)) {
|
|
3132
|
+
if (isRecord(merged[tabKey])) delete merged[tabKey][key];
|
|
3133
|
+
} else {
|
|
3134
|
+
merged[tabKey] = isRecord(merged[tabKey]) ? merged[tabKey] : {};
|
|
3135
|
+
merged[tabKey][key] = nextTab[key];
|
|
3136
|
+
}
|
|
3137
|
+
}
|
|
3138
|
+
if (isRecord(merged[tabKey]) && Object.keys(merged[tabKey]).length === 0) delete merged[tabKey];
|
|
3139
|
+
}
|
|
3140
|
+
return merged;
|
|
3141
|
+
}
|
|
3142
|
+
|
|
3143
|
+
function mergeDashboardCacheSnapshots(base, disk, next) {
|
|
3144
|
+
const merged = isRecord(disk) ? structuredClone(disk) : {};
|
|
3145
|
+
const base_ = isRecord(base) ? base : {};
|
|
3146
|
+
const next_ = isRecord(next) ? next : {};
|
|
3147
|
+
for (const target of new Set([...Object.keys(base_), ...Object.keys(next_)])) {
|
|
3148
|
+
if (samePersistedValue(base_[target], next_[target])) continue;
|
|
3149
|
+
if (!Object.hasOwn(next_, target)) {
|
|
3150
|
+
delete merged[target];
|
|
3151
|
+
continue;
|
|
3152
|
+
}
|
|
3153
|
+
|
|
3154
|
+
const baseEntry = isRecord(base_[target]) ? base_[target] : {};
|
|
3155
|
+
const diskEntry = isRecord(merged[target]) ? merged[target] : {};
|
|
3156
|
+
const nextEntry = isRecord(next_[target]) ? next_[target] : {};
|
|
3157
|
+
const mergedEntry = structuredClone(diskEntry);
|
|
3158
|
+
const baseTabs = isRecord(baseEntry.tabs) ? baseEntry.tabs : {};
|
|
3159
|
+
const nextTabs = isRecord(nextEntry.tabs) ? nextEntry.tabs : {};
|
|
3160
|
+
const mergedTabs = isRecord(mergedEntry.tabs) ? mergedEntry.tabs : {};
|
|
3161
|
+
for (const tabKey of new Set([...Object.keys(baseTabs), ...Object.keys(nextTabs)])) {
|
|
3162
|
+
if (samePersistedValue(baseTabs[tabKey], nextTabs[tabKey])) continue;
|
|
3163
|
+
if (Object.hasOwn(nextTabs, tabKey)) mergedTabs[tabKey] = nextTabs[tabKey];
|
|
3164
|
+
else delete mergedTabs[tabKey];
|
|
3165
|
+
}
|
|
3166
|
+
if (Object.keys(mergedTabs).length > 0) mergedEntry.tabs = mergedTabs;
|
|
3167
|
+
else delete mergedEntry.tabs;
|
|
3168
|
+
|
|
3169
|
+
for (const field of ["securityNotes", "securityBlind", "updatedAt"]) {
|
|
3170
|
+
if (samePersistedValue(baseEntry[field], nextEntry[field])) continue;
|
|
3171
|
+
if (Object.hasOwn(nextEntry, field)) mergedEntry[field] = nextEntry[field];
|
|
3172
|
+
else delete mergedEntry[field];
|
|
3173
|
+
}
|
|
3174
|
+
merged[target] = mergedEntry;
|
|
3175
|
+
}
|
|
3176
|
+
return merged;
|
|
3177
|
+
}
|
|
3178
|
+
|
|
3179
|
+
function adoptPersistedSnapshot(result, persistedRef, liveRef) {
|
|
3180
|
+
if (result?.ok !== true || !isRecord(result.persisted)) return false;
|
|
3181
|
+
persistedRef.current = result.persisted;
|
|
3182
|
+
liveRef.current = result.persisted;
|
|
3183
|
+
return true;
|
|
3184
|
+
}
|
|
3185
|
+
|
|
2890
3186
|
function normalizeWidthOverrides(overrides, tabs = TABS, { omitDefaults = false } = {}) {
|
|
2891
3187
|
if (!isRecord(overrides)) return {};
|
|
2892
3188
|
|
|
@@ -2953,36 +3249,45 @@ function loadWidthPreferences(path, tabs = TABS) {
|
|
|
2953
3249
|
return decodeWidthPreferences(raw, tabs);
|
|
2954
3250
|
}
|
|
2955
3251
|
|
|
2956
|
-
function saveWidthPreferences(path, overrides, tabs = TABS) {
|
|
2957
|
-
let tempPath = null;
|
|
3252
|
+
function saveWidthPreferences(path, overrides, tabs = TABS, { base = null } = {}) {
|
|
2958
3253
|
try {
|
|
2959
|
-
const payload = serializeWidthPreferences(overrides, tabs);
|
|
2960
3254
|
const parent = dirname(path);
|
|
2961
3255
|
mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
writeFileSync(tempPath, payload, {
|
|
2965
|
-
encoding: "utf8",
|
|
2966
|
-
mode: 0o600,
|
|
2967
|
-
flag: "wx",
|
|
2968
|
-
});
|
|
2969
|
-
renameSync(tempPath, path);
|
|
2970
|
-
return { ok: true };
|
|
2971
|
-
} catch (error) {
|
|
2972
|
-
if (tempPath !== null) {
|
|
3256
|
+
return withPersistenceLock(path, () => {
|
|
3257
|
+
let tempPath = null;
|
|
2973
3258
|
try {
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
3259
|
+
const disk = base === null ? {} : loadWidthPreferences(path, tabs).preferences;
|
|
3260
|
+
const merged = base === null
|
|
3261
|
+
? overrides
|
|
3262
|
+
: mergeWidthPreferenceSnapshots(base, disk, overrides);
|
|
3263
|
+
const payload = serializeWidthPreferences(merged, tabs);
|
|
3264
|
+
widthPreferenceTempSequence += 1;
|
|
3265
|
+
tempPath = `${path}.${process.pid}.${Date.now()}.${widthPreferenceTempSequence}.tmp`;
|
|
3266
|
+
writeFileSync(tempPath, payload, {
|
|
3267
|
+
encoding: "utf8",
|
|
3268
|
+
mode: 0o600,
|
|
3269
|
+
flag: "wx",
|
|
3270
|
+
});
|
|
3271
|
+
renameSync(tempPath, path);
|
|
3272
|
+
return { ok: true, persisted: normalizeWidthOverrides(merged, tabs) };
|
|
3273
|
+
} catch (error) {
|
|
3274
|
+
if (tempPath !== null) {
|
|
3275
|
+
try {
|
|
3276
|
+
unlinkSync(tempPath);
|
|
3277
|
+
} catch {
|
|
3278
|
+
// Cleanup targets only this operation's exact temporary file.
|
|
3279
|
+
}
|
|
3280
|
+
}
|
|
3281
|
+
return { ok: false, error };
|
|
2978
3282
|
}
|
|
2979
|
-
}
|
|
3283
|
+
});
|
|
3284
|
+
} catch (error) {
|
|
2980
3285
|
return { ok: false, error };
|
|
2981
3286
|
}
|
|
2982
3287
|
}
|
|
2983
3288
|
|
|
2984
|
-
function
|
|
2985
|
-
let latest
|
|
3289
|
+
function createCoalescedWriter({ write, delay = 200, onResult = () => {} }) {
|
|
3290
|
+
let latest;
|
|
2986
3291
|
let generation = 0;
|
|
2987
3292
|
let persistedGeneration = 0;
|
|
2988
3293
|
let timer = null;
|
|
@@ -3083,6 +3388,336 @@ function createWidthPreferenceWriter({ write, delay = 200, onResult = () => {} }
|
|
|
3083
3388
|
};
|
|
3084
3389
|
}
|
|
3085
3390
|
|
|
3391
|
+
const createWidthPreferenceWriter = createCoalescedWriter;
|
|
3392
|
+
|
|
3393
|
+
// ---------- Last-known-good dashboard cache ----------
|
|
3394
|
+
|
|
3395
|
+
const DASHBOARD_CACHE_VERSION = 2;
|
|
3396
|
+
const MAX_DASHBOARD_CACHE_TARGETS = 5;
|
|
3397
|
+
const MAX_DASHBOARD_CACHE_ROWS_PER_TAB = 60;
|
|
3398
|
+
let dashboardCacheTempSequence = 0;
|
|
3399
|
+
|
|
3400
|
+
function dashboardCachePath(options = {}) {
|
|
3401
|
+
return join(dirname(widthPreferencesPath(options)), "dashboard-cache.json");
|
|
3402
|
+
}
|
|
3403
|
+
|
|
3404
|
+
function effectiveGhConfigDir({ env = process.env, platform = process.platform, home = homedir() } = {}) {
|
|
3405
|
+
if (env?.GH_CONFIG_DIR) return String(env.GH_CONFIG_DIR);
|
|
3406
|
+
if (env?.XDG_CONFIG_HOME && isAbsolute(env.XDG_CONFIG_HOME)) return join(env.XDG_CONFIG_HOME, "gh");
|
|
3407
|
+
if (platform === "win32" && env?.APPDATA) return join(env.APPDATA, "GitHub CLI");
|
|
3408
|
+
return join(home, ".config", "gh");
|
|
3409
|
+
}
|
|
3410
|
+
|
|
3411
|
+
function authCacheIdentity({
|
|
3412
|
+
env = process.env,
|
|
3413
|
+
platform = process.platform,
|
|
3414
|
+
home = homedir(),
|
|
3415
|
+
stat,
|
|
3416
|
+
} = {}) {
|
|
3417
|
+
const configDir = effectiveGhConfigDir({ env, platform, home });
|
|
3418
|
+
let configStat = stat;
|
|
3419
|
+
if (configStat === undefined) {
|
|
3420
|
+
try {
|
|
3421
|
+
configStat = statSync(join(configDir, "hosts.yml"));
|
|
3422
|
+
} catch {
|
|
3423
|
+
configStat = null;
|
|
3424
|
+
}
|
|
3425
|
+
}
|
|
3426
|
+
const tokenDigests = Object.fromEntries(
|
|
3427
|
+
["GH_TOKEN", "GH_ENTERPRISE_TOKEN", "GITHUB_TOKEN", "GITHUB_ENTERPRISE_TOKEN"]
|
|
3428
|
+
.filter((name) => env?.[name])
|
|
3429
|
+
.map((name) => [
|
|
3430
|
+
name,
|
|
3431
|
+
createHash("sha256").update(String(env[name])).digest("hex"),
|
|
3432
|
+
]),
|
|
3433
|
+
);
|
|
3434
|
+
const payload = {
|
|
3435
|
+
configDir,
|
|
3436
|
+
configStat: configStat
|
|
3437
|
+
? {
|
|
3438
|
+
dev: Number(configStat.dev),
|
|
3439
|
+
ino: Number(configStat.ino),
|
|
3440
|
+
size: Number(configStat.size),
|
|
3441
|
+
mtimeMs: Math.trunc(Number(configStat.mtimeMs)),
|
|
3442
|
+
}
|
|
3443
|
+
: null,
|
|
3444
|
+
tokenDigests,
|
|
3445
|
+
};
|
|
3446
|
+
return createHash("sha256").update(JSON.stringify(payload)).digest("hex").slice(0, 24);
|
|
3447
|
+
}
|
|
3448
|
+
|
|
3449
|
+
// Explicit targets are stable across working directories. In inferred mode the
|
|
3450
|
+
// directory is the only repository identity available without making another
|
|
3451
|
+
// GitHub call -- exactly the call that cannot succeed when this cache is needed.
|
|
3452
|
+
// JSON encoding avoids delimiter ambiguity in host, repo, and path strings.
|
|
3453
|
+
function dashboardCacheTarget({
|
|
3454
|
+
repo = null,
|
|
3455
|
+
ghRepo = null,
|
|
3456
|
+
host = null,
|
|
3457
|
+
cwd = process.cwd(),
|
|
3458
|
+
account = authCacheIdentity(),
|
|
3459
|
+
} = {}) {
|
|
3460
|
+
const target = repo || ghRepo;
|
|
3461
|
+
return target
|
|
3462
|
+
? JSON.stringify({ kind: "repo", host: String(host ?? ""), repo: String(target), account })
|
|
3463
|
+
: JSON.stringify({ kind: "cwd", host: String(host ?? ""), cwd: String(cwd), account });
|
|
3464
|
+
}
|
|
3465
|
+
|
|
3466
|
+
function cacheTimestamp(value) {
|
|
3467
|
+
return Number.isFinite(value) && value >= 0 ? value : null;
|
|
3468
|
+
}
|
|
3469
|
+
|
|
3470
|
+
function normalizeCachedItem(tabKey, item) {
|
|
3471
|
+
if (!isRecord(item)) return null;
|
|
3472
|
+
if (tabKey === "actions") {
|
|
3473
|
+
if (!Number.isSafeInteger(item.databaseId)) return null;
|
|
3474
|
+
return {
|
|
3475
|
+
databaseId: item.databaseId,
|
|
3476
|
+
displayTitle: safe(item.displayTitle),
|
|
3477
|
+
workflowName: safe(item.workflowName),
|
|
3478
|
+
number: Number.isSafeInteger(item.number) ? item.number : null,
|
|
3479
|
+
headBranch: safe(item.headBranch),
|
|
3480
|
+
status: safe(item.status),
|
|
3481
|
+
conclusion: item.conclusion == null ? null : safe(item.conclusion),
|
|
3482
|
+
startedAt: safe(item.startedAt),
|
|
3483
|
+
updatedAt: safe(item.updatedAt),
|
|
3484
|
+
};
|
|
3485
|
+
}
|
|
3486
|
+
if (tabKey === "issues") {
|
|
3487
|
+
if (!Number.isSafeInteger(item.number)) return null;
|
|
3488
|
+
return {
|
|
3489
|
+
number: item.number,
|
|
3490
|
+
title: safe(item.title),
|
|
3491
|
+
author: safe(item.author),
|
|
3492
|
+
label: safe(item.label),
|
|
3493
|
+
updatedAt: safe(item.updatedAt),
|
|
3494
|
+
};
|
|
3495
|
+
}
|
|
3496
|
+
if (tabKey === "prs") {
|
|
3497
|
+
if (!Number.isSafeInteger(item.number)) return null;
|
|
3498
|
+
return {
|
|
3499
|
+
number: item.number,
|
|
3500
|
+
title: safe(item.title),
|
|
3501
|
+
author: safe(item.author),
|
|
3502
|
+
headRefName: safe(item.headRefName),
|
|
3503
|
+
isDraft: Boolean(item.isDraft),
|
|
3504
|
+
reviewDecision: safe(item.reviewDecision),
|
|
3505
|
+
updatedAt: safe(item.updatedAt),
|
|
3506
|
+
};
|
|
3507
|
+
}
|
|
3508
|
+
if (tabKey === "security") {
|
|
3509
|
+
const id = safe(item.id);
|
|
3510
|
+
if (!id) return null;
|
|
3511
|
+
return {
|
|
3512
|
+
id,
|
|
3513
|
+
kind: safe(item.kind),
|
|
3514
|
+
severity: safe(item.severity),
|
|
3515
|
+
title: safe(item.title),
|
|
3516
|
+
detail: safe(item.detail),
|
|
3517
|
+
createdAt: safe(item.createdAt),
|
|
3518
|
+
};
|
|
3519
|
+
}
|
|
3520
|
+
return null;
|
|
3521
|
+
}
|
|
3522
|
+
|
|
3523
|
+
function normalizeDashboardCacheEntry(entry) {
|
|
3524
|
+
if (!isRecord(entry) || !isRecord(entry.tabs)) return null;
|
|
3525
|
+
const tabs = {};
|
|
3526
|
+
for (const tabKey of TAB_KEYS) {
|
|
3527
|
+
const tab = entry.tabs[tabKey];
|
|
3528
|
+
if (!isRecord(tab) || !Array.isArray(tab.data)) continue;
|
|
3529
|
+
const lastOk = cacheTimestamp(tab.lastOk);
|
|
3530
|
+
if (
|
|
3531
|
+
lastOk === null ||
|
|
3532
|
+
!isRecord(tab.meta) ||
|
|
3533
|
+
cacheTimestamp(tab.meta.at) === null ||
|
|
3534
|
+
typeof tab.meta.truncated !== "boolean"
|
|
3535
|
+
) {
|
|
3536
|
+
continue;
|
|
3537
|
+
}
|
|
3538
|
+
const normalizedData = tab.data.map((item) => normalizeCachedItem(tabKey, item)).filter(Boolean);
|
|
3539
|
+
// A malformed row means this tab was not written by the current schema.
|
|
3540
|
+
// Reject the tab rather than turning a corrupt non-empty payload into a
|
|
3541
|
+
// confident empty state.
|
|
3542
|
+
if (normalizedData.length !== tab.data.length) continue;
|
|
3543
|
+
const data = normalizedData.slice(0, MAX_DASHBOARD_CACHE_ROWS_PER_TAB);
|
|
3544
|
+
const meta = {
|
|
3545
|
+
at: tab.meta.at,
|
|
3546
|
+
truncated: tab.meta.truncated || normalizedData.length > MAX_DASHBOARD_CACHE_ROWS_PER_TAB,
|
|
3547
|
+
};
|
|
3548
|
+
tabs[tabKey] = { data, meta, lastOk };
|
|
3549
|
+
}
|
|
3550
|
+
if (Object.keys(tabs).length === 0) return null;
|
|
3551
|
+
const latestTab = Math.max(...Object.values(tabs).map((tab) => tab.lastOk));
|
|
3552
|
+
return {
|
|
3553
|
+
tabs,
|
|
3554
|
+
securityNotes: Array.isArray(entry.securityNotes)
|
|
3555
|
+
? entry.securityNotes
|
|
3556
|
+
.filter((note) => typeof note === "string")
|
|
3557
|
+
.slice(0, ALERT_SOURCES.length)
|
|
3558
|
+
.map((note) => safe(note))
|
|
3559
|
+
: [],
|
|
3560
|
+
securityBlind: typeof entry.securityBlind === "boolean" ? entry.securityBlind : false,
|
|
3561
|
+
updatedAt: cacheTimestamp(entry.updatedAt) ?? latestTab,
|
|
3562
|
+
};
|
|
3563
|
+
}
|
|
3564
|
+
|
|
3565
|
+
function normalizeDashboardCache(cache) {
|
|
3566
|
+
if (!isRecord(cache)) return {};
|
|
3567
|
+
return limitDashboardCache(
|
|
3568
|
+
Object.fromEntries(
|
|
3569
|
+
Object.entries(cache)
|
|
3570
|
+
.map(([target, entry]) => [target, normalizeDashboardCacheEntry(entry)])
|
|
3571
|
+
.filter(([target, entry]) => target.length > 0 && entry !== null),
|
|
3572
|
+
),
|
|
3573
|
+
);
|
|
3574
|
+
}
|
|
3575
|
+
|
|
3576
|
+
function limitDashboardCache(cache) {
|
|
3577
|
+
return Object.fromEntries(
|
|
3578
|
+
Object.entries(cache)
|
|
3579
|
+
.sort((left, right) => right[1].updatedAt - left[1].updatedAt)
|
|
3580
|
+
.slice(0, MAX_DASHBOARD_CACHE_TARGETS),
|
|
3581
|
+
);
|
|
3582
|
+
}
|
|
3583
|
+
|
|
3584
|
+
function mergeDashboardCacheEntry(cache, target, entry) {
|
|
3585
|
+
const normalized = normalizeDashboardCacheEntry(entry);
|
|
3586
|
+
return normalized === null ? cache : limitDashboardCache({ ...cache, [target]: normalized });
|
|
3587
|
+
}
|
|
3588
|
+
|
|
3589
|
+
function decodeDashboardCache(raw) {
|
|
3590
|
+
let document;
|
|
3591
|
+
try {
|
|
3592
|
+
document = JSON.parse(raw);
|
|
3593
|
+
} catch (error) {
|
|
3594
|
+
return { cache: {}, error };
|
|
3595
|
+
}
|
|
3596
|
+
if (
|
|
3597
|
+
!isRecord(document) ||
|
|
3598
|
+
document.version !== DASHBOARD_CACHE_VERSION ||
|
|
3599
|
+
!isRecord(document.targets)
|
|
3600
|
+
) {
|
|
3601
|
+
return { cache: {}, error: new Error("Unsupported dashboard cache document") };
|
|
3602
|
+
}
|
|
3603
|
+
return { cache: normalizeDashboardCache(document.targets), error: null };
|
|
3604
|
+
}
|
|
3605
|
+
|
|
3606
|
+
function serializeDashboardCache(cache, { normalized = false } = {}) {
|
|
3607
|
+
return `${JSON.stringify(
|
|
3608
|
+
{ version: DASHBOARD_CACHE_VERSION, targets: normalized ? cache : normalizeDashboardCache(cache) },
|
|
3609
|
+
null,
|
|
3610
|
+
2,
|
|
3611
|
+
)}\n`;
|
|
3612
|
+
}
|
|
3613
|
+
|
|
3614
|
+
function loadDashboardCache(path, target) {
|
|
3615
|
+
let raw;
|
|
3616
|
+
try {
|
|
3617
|
+
raw = readFileSync(path, "utf8");
|
|
3618
|
+
} catch (error) {
|
|
3619
|
+
return error?.code === "ENOENT"
|
|
3620
|
+
? { cache: {}, entry: null, error: null }
|
|
3621
|
+
: { cache: {}, entry: null, error };
|
|
3622
|
+
}
|
|
3623
|
+
const decoded = decodeDashboardCache(raw);
|
|
3624
|
+
return {
|
|
3625
|
+
cache: decoded.cache,
|
|
3626
|
+
entry: pick(decoded.cache, target, null),
|
|
3627
|
+
error: decoded.error,
|
|
3628
|
+
};
|
|
3629
|
+
}
|
|
3630
|
+
|
|
3631
|
+
function saveDashboardCache(path, cache, { normalized = false, base = null } = {}) {
|
|
3632
|
+
try {
|
|
3633
|
+
const parent = dirname(path);
|
|
3634
|
+
mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
3635
|
+
if (process.platform !== "win32") chmodSync(parent, 0o700);
|
|
3636
|
+
return withPersistenceLock(path, () => {
|
|
3637
|
+
let tempPath = null;
|
|
3638
|
+
try {
|
|
3639
|
+
const disk = base === null ? {} : loadDashboardCache(path, "").cache;
|
|
3640
|
+
const merged = base === null
|
|
3641
|
+
? cache
|
|
3642
|
+
: limitDashboardCache(mergeDashboardCacheSnapshots(base, disk, cache));
|
|
3643
|
+
dashboardCacheTempSequence += 1;
|
|
3644
|
+
tempPath = `${path}.${process.pid}.${Date.now()}.${dashboardCacheTempSequence}.tmp`;
|
|
3645
|
+
writeFileSync(tempPath, serializeDashboardCache(merged, { normalized }), {
|
|
3646
|
+
encoding: "utf8",
|
|
3647
|
+
mode: 0o600,
|
|
3648
|
+
flag: "wx",
|
|
3649
|
+
});
|
|
3650
|
+
renameSync(tempPath, path);
|
|
3651
|
+
return { ok: true, persisted: normalizeDashboardCache(merged) };
|
|
3652
|
+
} catch (error) {
|
|
3653
|
+
if (tempPath !== null) {
|
|
3654
|
+
try {
|
|
3655
|
+
unlinkSync(tempPath);
|
|
3656
|
+
} catch {
|
|
3657
|
+
// Cleanup targets only this operation's exact temporary file.
|
|
3658
|
+
}
|
|
3659
|
+
}
|
|
3660
|
+
return { ok: false, error };
|
|
3661
|
+
}
|
|
3662
|
+
});
|
|
3663
|
+
} catch (error) {
|
|
3664
|
+
return { ok: false, error };
|
|
3665
|
+
}
|
|
3666
|
+
}
|
|
3667
|
+
|
|
3668
|
+
function nextSecurityRaw(previousRaw, raw, blind) {
|
|
3669
|
+
if (blind === true) return null;
|
|
3670
|
+
return previousRaw === raw ? previousRaw : raw;
|
|
3671
|
+
}
|
|
3672
|
+
|
|
3673
|
+
const CACHE_FRESHNESS_CHECKPOINT_MS = 60_000;
|
|
3674
|
+
const SECURITY_UNCHANGED_POLL_MS = 60_000;
|
|
3675
|
+
|
|
3676
|
+
function shouldCheckpointFreshness({ persistedAt, completedAt }) {
|
|
3677
|
+
return !Number.isFinite(persistedAt) || completedAt - persistedAt >= CACHE_FRESHNESS_CHECKPOINT_MS;
|
|
3678
|
+
}
|
|
3679
|
+
|
|
3680
|
+
function securityPollDelay({ unchangedPolls, floorMs, force = false }) {
|
|
3681
|
+
if (force) return 0;
|
|
3682
|
+
return unchangedPolls > 0 ? Math.max(floorMs, SECURITY_UNCHANGED_POLL_MS) : floorMs;
|
|
3683
|
+
}
|
|
3684
|
+
|
|
3685
|
+
function shouldShowFetchLoading({ hasData, force }) {
|
|
3686
|
+
return !hasData || force === true;
|
|
3687
|
+
}
|
|
3688
|
+
|
|
3689
|
+
function pollTickKeys({ ticks, activeKey }) {
|
|
3690
|
+
return {
|
|
3691
|
+
due: ticks % BACKGROUND_EVERY === 0 ? [...TAB_KEYS] : [activeKey],
|
|
3692
|
+
nextTicks: ticks + 1,
|
|
3693
|
+
};
|
|
3694
|
+
}
|
|
3695
|
+
|
|
3696
|
+
function pollResultTransition({ key, previousRaw, raw, parse, limit, completedAt }) {
|
|
3697
|
+
if (previousRaw === raw) return { kind: "unchanged", completedAt };
|
|
3698
|
+
const value = parse();
|
|
3699
|
+
if (key === "security" && value?.blind) {
|
|
3700
|
+
return {
|
|
3701
|
+
kind: "blind",
|
|
3702
|
+
nextRaw: nextSecurityRaw(previousRaw, raw, true),
|
|
3703
|
+
notes: value.notes ?? [],
|
|
3704
|
+
blind: true,
|
|
3705
|
+
};
|
|
3706
|
+
}
|
|
3707
|
+
const data = value?.alerts ?? value;
|
|
3708
|
+
return {
|
|
3709
|
+
kind: "changed",
|
|
3710
|
+
nextRaw: key === "security" ? nextSecurityRaw(previousRaw, raw, false) : raw,
|
|
3711
|
+
data,
|
|
3712
|
+
meta: {
|
|
3713
|
+
at: completedAt,
|
|
3714
|
+
truncated: value?.truncated ?? data.length >= limit,
|
|
3715
|
+
},
|
|
3716
|
+
notes: value?.notes,
|
|
3717
|
+
blind: Boolean(value?.blind),
|
|
3718
|
+
};
|
|
3719
|
+
}
|
|
3720
|
+
|
|
3086
3721
|
// Below this the full column set cannot render without its fixed columns
|
|
3087
3722
|
// overflowing the frame -- rows hard-wrap and ink switches to clearing and
|
|
3088
3723
|
// repainting the whole screen every frame. Worse, in the band just above the
|
|
@@ -3101,6 +3736,11 @@ const MIN_COMPACT_WIDTH = Math.max(...TABS.map((t) => minimumWidthFor(t.compactH
|
|
|
3101
3736
|
const TAB_LABEL_FULL_WIDTH = 78;
|
|
3102
3737
|
const TAB_LABEL_HYSTERESIS = 4;
|
|
3103
3738
|
|
|
3739
|
+
function tabFailureSuffix({ count, failed, brokenCI }) {
|
|
3740
|
+
if (count == null) return failed ? " x" : "";
|
|
3741
|
+
return ` (${count}${brokenCI ? "!" : ""}${failed ? "x" : ""})`;
|
|
3742
|
+
}
|
|
3743
|
+
|
|
3104
3744
|
function TabBar({ activeIndex, counts, brokenCI, firstLoad, failed, spin, useShort }) {
|
|
3105
3745
|
return e(
|
|
3106
3746
|
Box,
|
|
@@ -3114,8 +3754,12 @@ function TabBar({ activeIndex, counts, brokenCI, firstLoad, failed, spin, useSho
|
|
|
3114
3754
|
count == null
|
|
3115
3755
|
? firstLoad[tab.key]
|
|
3116
3756
|
? ` ${spin}`
|
|
3117
|
-
:
|
|
3118
|
-
:
|
|
3757
|
+
: tabFailureSuffix({ count, failed: failed[tab.key], brokenCI: false })
|
|
3758
|
+
: tabFailureSuffix({
|
|
3759
|
+
count,
|
|
3760
|
+
failed: failed[tab.key],
|
|
3761
|
+
brokenCI: brokenCI[tab.key],
|
|
3762
|
+
});
|
|
3119
3763
|
const name = useShort ? tab.short : tab.label;
|
|
3120
3764
|
// Bold and inverse are both stripped at chalk level 0 (NO_COLOR, or a
|
|
3121
3765
|
// dumb terminal), which left no indication at all of which tab was
|
|
@@ -3175,6 +3819,23 @@ const KEY_HINTS = [
|
|
|
3175
3819
|
{ label: "Quit", keys: "q" },
|
|
3176
3820
|
];
|
|
3177
3821
|
|
|
3822
|
+
function activeKeyHints({
|
|
3823
|
+
interactive,
|
|
3824
|
+
remoteSetup = false,
|
|
3825
|
+
canMove = false,
|
|
3826
|
+
canOpen = false,
|
|
3827
|
+
canResize = false,
|
|
3828
|
+
}) {
|
|
3829
|
+
if (!interactive) return [{ label: "Quit", keys: "^C" }];
|
|
3830
|
+
if (remoteSetup) return REMOTE_SETUP_HINTS;
|
|
3831
|
+
return KEY_HINTS.filter((hint) => {
|
|
3832
|
+
if (hint.label === "Move") return canMove;
|
|
3833
|
+
if (hint.label === "Open") return canOpen;
|
|
3834
|
+
if (hint.label === "Width") return canResize;
|
|
3835
|
+
return true;
|
|
3836
|
+
});
|
|
3837
|
+
}
|
|
3838
|
+
|
|
3178
3839
|
const REMOTE_SETUP_HINTS = [
|
|
3179
3840
|
{ label: "Create remote", keys: "Ent" },
|
|
3180
3841
|
{ label: "Quit", keys: "q" },
|
|
@@ -3245,6 +3906,9 @@ function StatusBar({
|
|
|
3245
3906
|
widthMode = false,
|
|
3246
3907
|
widthColumn = null,
|
|
3247
3908
|
widthSaveError = null,
|
|
3909
|
+
canMove = false,
|
|
3910
|
+
canOpen = false,
|
|
3911
|
+
canResize = false,
|
|
3248
3912
|
}) {
|
|
3249
3913
|
// Width mode owns the whole bar, so the throttle badge is deliberately not
|
|
3250
3914
|
// shown here: this state is transient and explicitly entered, and the widened
|
|
@@ -3268,11 +3932,7 @@ function StatusBar({
|
|
|
3268
3932
|
// Without raw mode none of the key handlers run, so advertising them would be
|
|
3269
3933
|
// telling the user something untrue about what the app can do. Ctrl+C still
|
|
3270
3934
|
// works there, because the tty delivers a real SIGINT.
|
|
3271
|
-
const hints = interactive
|
|
3272
|
-
? remoteSetup
|
|
3273
|
-
? REMOTE_SETUP_HINTS
|
|
3274
|
-
: KEY_HINTS
|
|
3275
|
-
: [{ label: "Quit", keys: "^C" }];
|
|
3935
|
+
const hints = activeKeyHints({ interactive, remoteSetup, canMove, canOpen, canResize });
|
|
3276
3936
|
const hintsFullWidth =
|
|
3277
3937
|
hints.reduce((sum, hint) => sum + hint.label.length + 2 + [...hint.keys].length, 0) +
|
|
3278
3938
|
(hints.length - 1) * 3;
|
|
@@ -3379,18 +4039,29 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
3379
4039
|
const { exit, suspendTerminal } = useApp();
|
|
3380
4040
|
const [activeIndex, setActiveIndex] = useState(runtime.initialTabIndex);
|
|
3381
4041
|
const [preferencePath] = useState(() => widthPreferencesPath());
|
|
3382
|
-
const [
|
|
3383
|
-
|
|
3384
|
-
);
|
|
4042
|
+
const [loadedWidthPreferences] = useState(() => loadWidthPreferences(preferencePath));
|
|
4043
|
+
const [widthOverrides, setWidthOverrides] = useState(() => loadedWidthPreferences.preferences);
|
|
3385
4044
|
const widthOverridesRef = useRef(widthOverrides);
|
|
3386
4045
|
widthOverridesRef.current = widthOverrides;
|
|
4046
|
+
const widthPersistedRef = useRef(loadedWidthPreferences.preferences);
|
|
3387
4047
|
const [widthSaveError, setWidthSaveError] = useState(null);
|
|
3388
4048
|
const widthPreferencesMountedRef = useRef(true);
|
|
3389
4049
|
const [widthPreferenceWriter] = useState(() =>
|
|
3390
4050
|
createWidthPreferenceWriter({
|
|
3391
|
-
write: (overrides) =>
|
|
4051
|
+
write: (overrides) => {
|
|
4052
|
+
const result = saveWidthPreferences(preferencePath, overrides, TABS, {
|
|
4053
|
+
base: widthPersistedRef.current,
|
|
4054
|
+
});
|
|
4055
|
+
adoptPersistedSnapshot(result, widthPersistedRef, widthOverridesRef);
|
|
4056
|
+
return result;
|
|
4057
|
+
},
|
|
3392
4058
|
onResult: (result) => {
|
|
3393
4059
|
if (!widthPreferencesMountedRef.current) return;
|
|
4060
|
+
if (result?.ok === true) {
|
|
4061
|
+
setWidthOverrides((current) =>
|
|
4062
|
+
samePersistedValue(current, result.persisted) ? current : result.persisted,
|
|
4063
|
+
);
|
|
4064
|
+
}
|
|
3394
4065
|
setWidthSaveError(
|
|
3395
4066
|
result?.ok === false
|
|
3396
4067
|
? (result.error ?? new Error("Width preferences could not be saved"))
|
|
@@ -3409,19 +4080,58 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
3409
4080
|
void widthPreferenceWriter.dispose();
|
|
3410
4081
|
};
|
|
3411
4082
|
}, [widthPreferenceWriter]);
|
|
4083
|
+
const [cachePath] = useState(() => dashboardCachePath());
|
|
4084
|
+
const [cacheTarget] = useState(() =>
|
|
4085
|
+
dashboardCacheTarget({
|
|
4086
|
+
repo: runtime.repo,
|
|
4087
|
+
ghRepo: process.env.GH_REPO,
|
|
4088
|
+
host: runtime.host ?? process.env.GH_HOST,
|
|
4089
|
+
cwd: process.cwd(),
|
|
4090
|
+
}),
|
|
4091
|
+
);
|
|
4092
|
+
const [loadedCache] = useState(() => loadDashboardCache(cachePath, cacheTarget));
|
|
4093
|
+
const dashboardCacheRef = useRef(loadedCache.cache);
|
|
4094
|
+
const dashboardCachePersistedRef = useRef(loadedCache.cache);
|
|
4095
|
+
const dashboardCacheTargetRef = useRef(cacheTarget);
|
|
4096
|
+
const [dashboardCacheWriter] = useState(() =>
|
|
4097
|
+
createCoalescedWriter({
|
|
4098
|
+
write: (cache) => {
|
|
4099
|
+
const result = saveDashboardCache(cachePath, cache, {
|
|
4100
|
+
normalized: true,
|
|
4101
|
+
base: dashboardCachePersistedRef.current,
|
|
4102
|
+
});
|
|
4103
|
+
adoptPersistedSnapshot(result, dashboardCachePersistedRef, dashboardCacheRef);
|
|
4104
|
+
return result;
|
|
4105
|
+
},
|
|
4106
|
+
}),
|
|
4107
|
+
);
|
|
4108
|
+
useEffect(() => () => {
|
|
4109
|
+
// Production writes start synchronously, so a pending latest snapshot is
|
|
4110
|
+
// not lost on quit. The cache is bounded; ignoring the advisory result keeps
|
|
4111
|
+
// a filesystem error from replacing terminal teardown.
|
|
4112
|
+
void dashboardCacheWriter.dispose();
|
|
4113
|
+
}, [dashboardCacheWriter]);
|
|
4114
|
+
const cachedEntry = loadedCache.entry;
|
|
3412
4115
|
// `null` means "never resolved" -- distinct from `[]`, which means "resolved
|
|
3413
4116
|
// and genuinely empty". The tab bar and the body render those differently.
|
|
3414
|
-
const [data, setData] = useState(
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
4117
|
+
const [data, setData] = useState(() =>
|
|
4118
|
+
Object.fromEntries(TABS.map((candidate) => [candidate.key, cachedEntry?.tabs[candidate.key]?.data ?? null])),
|
|
4119
|
+
);
|
|
4120
|
+
const dataRef = useRef(data);
|
|
4121
|
+
dataRef.current = data;
|
|
4122
|
+
const [meta, setMeta] = useState(() =>
|
|
4123
|
+
Object.fromEntries(TABS.map((candidate) => [candidate.key, cachedEntry?.tabs[candidate.key]?.meta ?? null])),
|
|
4124
|
+
);
|
|
4125
|
+
const metaRef = useRef(meta);
|
|
4126
|
+
metaRef.current = meta;
|
|
4127
|
+
const [securityNotes, setSecurityNotes] = useState(() => cachedEntry?.securityNotes ?? []);
|
|
4128
|
+
const securityNotesRef = useRef(securityNotes);
|
|
4129
|
+
securityNotesRef.current = securityNotes;
|
|
3422
4130
|
// Whether the Security tab is currently unable to see its endpoints, as
|
|
3423
4131
|
// opposed to seeing that they are switched off. Drives the count marker.
|
|
3424
|
-
const [securityBlind, setSecurityBlind] = useState(false);
|
|
4132
|
+
const [securityBlind, setSecurityBlind] = useState(() => cachedEntry?.securityBlind ?? false);
|
|
4133
|
+
const securityBlindRef = useRef(securityBlind);
|
|
4134
|
+
securityBlindRef.current = securityBlind;
|
|
3425
4135
|
// The `?` overlay. Renders only on a keypress, so consecutive idle frames are
|
|
3426
4136
|
// still byte-identical and the redraw suppression is untouched.
|
|
3427
4137
|
const [showHelp, setShowHelp] = useState(false);
|
|
@@ -3509,12 +4219,18 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
3509
4219
|
|
|
3510
4220
|
const inFlightRef = useRef({});
|
|
3511
4221
|
const rawRef = useRef({});
|
|
3512
|
-
const cleanRef = useRef({});
|
|
3513
4222
|
// Last *successful* poll per tab, wall-clock. Wall-clock on purpose: a laptop
|
|
3514
4223
|
// sleeping is exactly the gap this is meant to report, and a monotonic clock
|
|
3515
4224
|
// does not advance across suspend. Never written on the failure path, or a
|
|
3516
4225
|
// persistently failing tab would report itself fresh forever.
|
|
3517
|
-
const lastOkRef = useRef(
|
|
4226
|
+
const lastOkRef = useRef(
|
|
4227
|
+
Object.fromEntries(
|
|
4228
|
+
TABS.flatMap((candidate) => {
|
|
4229
|
+
const lastOk = cachedEntry?.tabs[candidate.key]?.lastOk;
|
|
4230
|
+
return lastOk == null ? [] : [[candidate.key, lastOk]];
|
|
4231
|
+
}),
|
|
4232
|
+
),
|
|
4233
|
+
);
|
|
3518
4234
|
const fetchTabRef = useRef(null);
|
|
3519
4235
|
const contextCoordinatorRef = useRef(null);
|
|
3520
4236
|
|
|
@@ -3553,13 +4269,16 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
3553
4269
|
});
|
|
3554
4270
|
|
|
3555
4271
|
useEffect(() => {
|
|
3556
|
-
if (!interactive)
|
|
4272
|
+
if (!shouldEnableMouseReporting({ interactive, widthMode })) {
|
|
4273
|
+
disableMouseReporting();
|
|
4274
|
+
return;
|
|
4275
|
+
}
|
|
3557
4276
|
enableMouseReporting();
|
|
3558
4277
|
return () => {
|
|
3559
4278
|
dragRef.current = null;
|
|
3560
4279
|
disableMouseReporting();
|
|
3561
4280
|
};
|
|
3562
|
-
}, [interactive]);
|
|
4281
|
+
}, [interactive, widthMode]);
|
|
3563
4282
|
|
|
3564
4283
|
function applyWidthOverrides(next) {
|
|
3565
4284
|
const current = widthOverridesRef.current;
|
|
@@ -3672,7 +4391,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
3672
4391
|
|
|
3673
4392
|
const geometry = resizeRef.current;
|
|
3674
4393
|
if (event.action === "press") {
|
|
3675
|
-
if (!interactive || !geometry.fullHeaderVisible || geometry.compact) return;
|
|
4394
|
+
if (!interactive || !geometry.active || !geometry.fullHeaderVisible || geometry.compact) return;
|
|
3676
4395
|
const metrics = measuredHeader();
|
|
3677
4396
|
if (!metrics) return;
|
|
3678
4397
|
const drag = beginDividerDrag({
|
|
@@ -3771,24 +4490,22 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
3771
4490
|
// rather than by tab so that moving to a different row and opening it
|
|
3772
4491
|
// immediately still works -- selection does not move on Enter, so a second
|
|
3773
4492
|
// press within the window is always a duplicate of the first.
|
|
3774
|
-
const
|
|
4493
|
+
const [openRequests] = useState(() => createOpenRequestRegistry());
|
|
4494
|
+
useEffect(() => () => openRequests.abortAll(), [openRequests]);
|
|
3775
4495
|
|
|
3776
4496
|
function openSelected() {
|
|
3777
4497
|
const { items, key: currentKey, tabKey } = navRef.current;
|
|
3778
4498
|
const item = items.find((i) => itemKey(i) === currentKey);
|
|
3779
4499
|
if (!item) return;
|
|
3780
4500
|
const guard = `${tabKey}:${itemKey(item)}`;
|
|
3781
|
-
if (openingRef.current[guard]) return;
|
|
3782
|
-
openingRef.current[guard] = true;
|
|
3783
4501
|
// Fire and forget: a browser launch must not block the render loop, and a
|
|
3784
4502
|
// failure surfaces through the tab's normal error line rather than as an
|
|
3785
4503
|
// unhandled rejection.
|
|
3786
|
-
|
|
3787
|
-
.
|
|
4504
|
+
openRequests
|
|
4505
|
+
.start(guard, ({ signal }) => openInBrowser(tabKey, item, signal))
|
|
4506
|
+
?.catch((err) => {
|
|
4507
|
+
if (err?.name === "AbortError") return;
|
|
3788
4508
|
setErrors((x) => ({ ...x, [tabKey]: textTabError(err) }));
|
|
3789
|
-
})
|
|
3790
|
-
.finally(() => {
|
|
3791
|
-
delete openingRef.current[guard];
|
|
3792
4509
|
});
|
|
3793
4510
|
}
|
|
3794
4511
|
|
|
@@ -3803,6 +4520,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
3803
4520
|
return;
|
|
3804
4521
|
}
|
|
3805
4522
|
if (input === "q" || (input === "c" && key.ctrl)) {
|
|
4523
|
+
openRequests.abortAll();
|
|
3806
4524
|
flushWidthPreferences();
|
|
3807
4525
|
exit();
|
|
3808
4526
|
} else if (resizeRef.current.active) {
|
|
@@ -3825,6 +4543,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
3825
4543
|
// Width mode owns every other key. In particular, digits, arrows, Tab,
|
|
3826
4544
|
// Enter and r must never fall through to their ordinary meanings.
|
|
3827
4545
|
} else if (key.escape) {
|
|
4546
|
+
openRequests.abortAll();
|
|
3828
4547
|
flushWidthPreferences();
|
|
3829
4548
|
exit();
|
|
3830
4549
|
} else if (showHelpRef.current) {
|
|
@@ -3881,10 +4600,10 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
3881
4600
|
// -- the behaviour the per-tab guard and the fetch-time limit read were
|
|
3882
4601
|
// written to prevent. Do not "fix" this by adding them.
|
|
3883
4602
|
//
|
|
3884
|
-
// exhaustive-deps is satisfied as written -- every captured value is
|
|
3885
|
-
//
|
|
3886
|
-
//
|
|
3887
|
-
//
|
|
4603
|
+
// exhaustive-deps is satisfied as written -- every captured value is a ref,
|
|
4604
|
+
// a setState function, or the stable cache writer created once above. If a
|
|
4605
|
+
// changing dependency ever becomes necessary, that is the signal that the
|
|
4606
|
+
// mount-only contract has been broken.
|
|
3888
4607
|
//
|
|
3889
4608
|
// The adaptive throttle re-arms the timer, which is not a contradiction of the
|
|
3890
4609
|
// above: `rearm` swaps the interval from *inside* this effect, so the effect
|
|
@@ -3915,23 +4634,60 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
3915
4634
|
});
|
|
3916
4635
|
contextCoordinatorRef.current = coordinator;
|
|
3917
4636
|
|
|
4637
|
+
function cacheSuccessfulTab(key, tabData, tabMeta, lastOk, security = {}) {
|
|
4638
|
+
const target = dashboardCacheTargetRef.current;
|
|
4639
|
+
const currentCache = dashboardCacheRef.current;
|
|
4640
|
+
const currentEntry = pick(currentCache, target, null) ?? {
|
|
4641
|
+
tabs: {},
|
|
4642
|
+
securityNotes: [],
|
|
4643
|
+
securityBlind: false,
|
|
4644
|
+
updatedAt: lastOk,
|
|
4645
|
+
};
|
|
4646
|
+
const nextEntry = {
|
|
4647
|
+
...currentEntry,
|
|
4648
|
+
tabs: {
|
|
4649
|
+
...currentEntry.tabs,
|
|
4650
|
+
[key]: { data: tabData, meta: tabMeta, lastOk },
|
|
4651
|
+
},
|
|
4652
|
+
securityNotes:
|
|
4653
|
+
key === "security" ? (security.notes ?? []) : currentEntry.securityNotes,
|
|
4654
|
+
securityBlind:
|
|
4655
|
+
key === "security" ? Boolean(security.blind) : currentEntry.securityBlind,
|
|
4656
|
+
updatedAt: lastOk,
|
|
4657
|
+
};
|
|
4658
|
+
const nextCache = mergeDashboardCacheEntry(currentCache, target, nextEntry);
|
|
4659
|
+
dashboardCacheRef.current = nextCache;
|
|
4660
|
+
dashboardCacheWriter.schedule(nextCache);
|
|
4661
|
+
}
|
|
4662
|
+
|
|
3918
4663
|
// Each tab commits its own result the moment it lands instead of waiting on
|
|
3919
4664
|
// a Promise.allSettled barrier. Actions is by far the slowest fetch, so
|
|
3920
4665
|
// barrelling everything together meant the three fast tabs sat invisible
|
|
3921
4666
|
// behind it and nothing at all appeared until the slowest call returned.
|
|
3922
|
-
|
|
4667
|
+
let securityUnchangedPolls = 0;
|
|
4668
|
+
let securityNextPollAt = 0;
|
|
4669
|
+
|
|
4670
|
+
function commit(key, run, { force = false } = {}) {
|
|
3923
4671
|
// Per-tab rather than one flag for the whole tick, so switching tabs can
|
|
3924
4672
|
// refresh the tab you just landed on without waiting on an unrelated
|
|
3925
4673
|
// background fetch -- and so a slow repo can't stack refreshes.
|
|
3926
4674
|
if (inFlightRef.current[key]) return Promise.resolve();
|
|
3927
4675
|
inFlightRef.current[key] = true;
|
|
3928
|
-
|
|
4676
|
+
if (force) {
|
|
4677
|
+
for (const backoffKey of forcedBackoffKeys(key)) clearBackoff(backoffKey);
|
|
4678
|
+
}
|
|
4679
|
+
const visibleLoading = shouldShowFetchLoading({
|
|
4680
|
+
hasData: dataRef.current[key] !== null,
|
|
4681
|
+
force,
|
|
4682
|
+
});
|
|
4683
|
+
if (visibleLoading) setLoading((l) => (l[key] ? l : { ...l, [key]: true }));
|
|
3929
4684
|
return run()
|
|
3930
4685
|
.then((result) => {
|
|
3931
4686
|
// Billed before the `cancelled` early return: the requests were made
|
|
3932
4687
|
// and counted by GitHub whether or not this process still wants the
|
|
3933
4688
|
// answer.
|
|
3934
|
-
|
|
4689
|
+
spentTotal.core += result?.restSpent ?? 0;
|
|
4690
|
+
spentTotal.graphql += result?.graphqlSpent ?? 0;
|
|
3935
4691
|
if (cancelled) return;
|
|
3936
4692
|
const { raw, parse, limit } = result;
|
|
3937
4693
|
// Identical payload: skip the parse *and* the state update. Returning
|
|
@@ -3953,41 +4709,88 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
3953
4709
|
// completely healthy. A ref rather than state because writing state here
|
|
3954
4710
|
// would allocate a new object every tick and permanently defeat the
|
|
3955
4711
|
// React bail-out this early return exists to preserve.
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
4712
|
+
const completedAt = Date.now();
|
|
4713
|
+
const transition = pollResultTransition({
|
|
4714
|
+
key,
|
|
4715
|
+
previousRaw: rawRef.current[key],
|
|
4716
|
+
raw,
|
|
4717
|
+
parse,
|
|
4718
|
+
limit,
|
|
4719
|
+
completedAt,
|
|
4720
|
+
});
|
|
4721
|
+
if (key === "security") {
|
|
4722
|
+
securityUnchangedPolls = transition.kind === "unchanged" ? securityUnchangedPolls + 1 : 0;
|
|
4723
|
+
securityNextPollAt =
|
|
4724
|
+
performance.now() +
|
|
4725
|
+
securityPollDelay({
|
|
4726
|
+
unchangedPolls: securityUnchangedPolls,
|
|
4727
|
+
floorMs: runtime.refreshMs,
|
|
4728
|
+
force,
|
|
4729
|
+
});
|
|
4730
|
+
}
|
|
4731
|
+
if (transition.kind === "unchanged") {
|
|
4732
|
+
lastOkRef.current[key] = completedAt;
|
|
4733
|
+
// Clear on the first success or a single failure latches the ladder.
|
|
4734
|
+
clearBackoff(`tab:${key}`);
|
|
3960
4735
|
setErrors((x) => (x[key] === null ? x : { ...x, [key]: null }));
|
|
4736
|
+
const cachedTab = pick(
|
|
4737
|
+
pick(dashboardCacheRef.current, dashboardCacheTargetRef.current, null)?.tabs ?? {},
|
|
4738
|
+
key,
|
|
4739
|
+
null,
|
|
4740
|
+
);
|
|
4741
|
+
if (
|
|
4742
|
+
cachedTab &&
|
|
4743
|
+
shouldCheckpointFreshness({ persistedAt: cachedTab.lastOk, completedAt })
|
|
4744
|
+
) {
|
|
4745
|
+
cacheSuccessfulTab(
|
|
4746
|
+
key,
|
|
4747
|
+
dataRef.current[key],
|
|
4748
|
+
metaRef.current[key],
|
|
4749
|
+
completedAt,
|
|
4750
|
+
key === "security"
|
|
4751
|
+
? { notes: securityNotesRef.current, blind: securityBlindRef.current }
|
|
4752
|
+
: {},
|
|
4753
|
+
);
|
|
4754
|
+
}
|
|
3961
4755
|
return;
|
|
3962
4756
|
}
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
|
|
4757
|
+
// Security fetches resolve each source independently so their notes
|
|
4758
|
+
// remain visible. A blind result is still a failed observation: it
|
|
4759
|
+
// must not replace known alerts with a false empty state or advance
|
|
4760
|
+
// freshness. Do not retain its raw value either, so the next source
|
|
4761
|
+
// retry is parsed instead of taking the identical-payload fast path.
|
|
4762
|
+
if (transition.kind === "blind") {
|
|
4763
|
+
rawRef.current[key] = transition.nextRaw;
|
|
4764
|
+
setErrors((x) => (x[key] === null ? x : { ...x, [key]: null }));
|
|
4765
|
+
setSecurityNotes(transition.notes);
|
|
4766
|
+
setSecurityBlind(true);
|
|
4767
|
+
return;
|
|
4768
|
+
}
|
|
4769
|
+
const tabData = transition.data;
|
|
4770
|
+
const tabMeta = transition.meta;
|
|
4771
|
+
lastOkRef.current[key] = completedAt;
|
|
4772
|
+
clearBackoff(`tab:${key}`);
|
|
4773
|
+
rawRef.current[key] = transition.nextRaw;
|
|
3966
4774
|
setErrors((x) => (x[key] === null ? x : { ...x, [key]: null }));
|
|
3967
|
-
setData((d) => ({ ...d, [key]:
|
|
3968
|
-
setMeta((m) => ({
|
|
3969
|
-
|
|
3970
|
-
[key]: {
|
|
3971
|
-
at: Date.now(),
|
|
3972
|
-
// Compare against the limit this payload was actually fetched
|
|
3973
|
-
// with. Comparing against the live pane-height ref made the "n+"
|
|
3974
|
-
// marker lie for one cycle after every resize.
|
|
3975
|
-
truncated: value.truncated ?? (value.alerts ?? value).length >= limit,
|
|
3976
|
-
},
|
|
3977
|
-
}));
|
|
3978
|
-
if (value.notes) setSecurityNotes(value.notes);
|
|
4775
|
+
setData((d) => ({ ...d, [key]: tabData }));
|
|
4776
|
+
setMeta((m) => ({ ...m, [key]: tabMeta }));
|
|
4777
|
+
if (transition.notes) setSecurityNotes(transition.notes);
|
|
3979
4778
|
if (key === "security") {
|
|
3980
|
-
setSecurityBlind((b) => (b ===
|
|
4779
|
+
setSecurityBlind((b) => (b === transition.blind ? b : transition.blind));
|
|
3981
4780
|
}
|
|
4781
|
+
cacheSuccessfulTab(key, tabData, tabMeta, completedAt, {
|
|
4782
|
+
notes: transition.notes,
|
|
4783
|
+
blind: transition.blind,
|
|
4784
|
+
});
|
|
3982
4785
|
})
|
|
3983
4786
|
.catch((err) => {
|
|
3984
4787
|
// A rejected fetch carries no result, so its own spend is unknowable
|
|
3985
4788
|
// here; bill the tab's table cost instead. A request that failed after
|
|
3986
4789
|
// reaching GitHub still counted, and over-billing by an aborted call
|
|
3987
4790
|
// errs toward throttling harder, which is the safe direction.
|
|
3988
|
-
|
|
4791
|
+
spentTotal.core += REST_PER_FETCH[key] ?? 0;
|
|
4792
|
+
spentTotal.graphql += GRAPHQL_PER_FETCH[key] ?? 0;
|
|
3989
4793
|
if (cancelled || err?.name === "AbortError") return;
|
|
3990
|
-
cleanRef.current[key] = false;
|
|
3991
4794
|
// Preserve both the verdict and the bounded raw error in state. The
|
|
3992
4795
|
// renderer translates recognized verdicts at draw time, which lets a
|
|
3993
4796
|
// later repository/account context refine the one-line remedy without
|
|
@@ -4006,7 +4809,9 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
4006
4809
|
})
|
|
4007
4810
|
.finally(() => {
|
|
4008
4811
|
inFlightRef.current[key] = false;
|
|
4009
|
-
if (!cancelled)
|
|
4812
|
+
if (!cancelled) {
|
|
4813
|
+
setLoading((l) => (l[key] ? { ...l, [key]: false } : l));
|
|
4814
|
+
}
|
|
4010
4815
|
});
|
|
4011
4816
|
}
|
|
4012
4817
|
|
|
@@ -4017,12 +4822,17 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
4017
4822
|
function fetchTab(key, { force = false } = {}) {
|
|
4018
4823
|
const signal = controller.signal;
|
|
4019
4824
|
const descriptor = tabForKey(key);
|
|
4020
|
-
|
|
4021
|
-
if (force
|
|
4825
|
+
const now_ = performance.now();
|
|
4826
|
+
if (!force && backoffActive(`tab:${key}`, now_)) return Promise.resolve();
|
|
4827
|
+
if (!force && key === "security" && now_ < securityNextPollAt) return Promise.resolve();
|
|
4022
4828
|
// Every tab carries its own fetcher on the registry, so adding a tab is a
|
|
4023
4829
|
// TABS entry plus a fetcher rather than an entry plus an edit to a chain
|
|
4024
4830
|
// in a different part of the file.
|
|
4025
|
-
return commit(
|
|
4831
|
+
return commit(
|
|
4832
|
+
key,
|
|
4833
|
+
() => descriptor.fetch({ signal, runLimit: runLimitRef.current }),
|
|
4834
|
+
{ force },
|
|
4835
|
+
);
|
|
4026
4836
|
}
|
|
4027
4837
|
fetchTabRef.current = fetchTab;
|
|
4028
4838
|
|
|
@@ -4041,8 +4851,9 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
4041
4851
|
// being a minute behind -- so they refresh every BACKGROUND_EVERY ticks
|
|
4042
4852
|
// instead, which is what keeps steady-state API usage off the rate limit.
|
|
4043
4853
|
const active = TABS[activeIndexRef.current].key;
|
|
4044
|
-
const
|
|
4045
|
-
|
|
4854
|
+
const planned = pollTickKeys({ ticks, activeKey: active });
|
|
4855
|
+
const due = planned.due;
|
|
4856
|
+
ticks = planned.nextTicks;
|
|
4046
4857
|
// Mapped explicitly rather than passing fetchTab by reference: Array.map
|
|
4047
4858
|
// hands the callback an index as its second argument, which would land in
|
|
4048
4859
|
// the options bag and make every background tab look force-refreshed.
|
|
@@ -4068,8 +4879,9 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
4068
4879
|
// The open probe window, and the last share it was able to measure. Both are
|
|
4069
4880
|
// carried by nextProbeWindow/inferShare rather than reset every probe -- see
|
|
4070
4881
|
// those functions for why a throttled pane must not re-measure from scratch.
|
|
4071
|
-
|
|
4072
|
-
|
|
4882
|
+
const probeWindows = { core: null, graphql: null };
|
|
4883
|
+
const lastShares = { core: 1, graphql: 1 };
|
|
4884
|
+
let resourceTargets = { core: runtime.refreshMs, graphql: runtime.refreshMs };
|
|
4073
4885
|
|
|
4074
4886
|
function rearm(ms) {
|
|
4075
4887
|
appliedMs = ms;
|
|
@@ -4083,23 +4895,28 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
4083
4895
|
try {
|
|
4084
4896
|
// `controller` is this effect's own AbortController, so a probe still in
|
|
4085
4897
|
// flight at unmount is killed along with everything else.
|
|
4086
|
-
const
|
|
4087
|
-
if (cancelled || !
|
|
4088
|
-
const
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
|
|
4092
|
-
|
|
4093
|
-
|
|
4094
|
-
|
|
4095
|
-
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
|
|
4898
|
+
const budgets = await readRateBudgets(controller.signal);
|
|
4899
|
+
if (cancelled || !budgets) return;
|
|
4900
|
+
const samples = { core: null, graphql: null };
|
|
4901
|
+
for (const resource of ["core", "graphql"]) {
|
|
4902
|
+
const budget = budgets[resource];
|
|
4903
|
+
if (!budget) continue;
|
|
4904
|
+
const step = nextProbeWindow(probeWindows[resource], budget, spentTotal[resource]);
|
|
4905
|
+
probeWindows[resource] = step.next;
|
|
4906
|
+
samples[resource] = step.sample;
|
|
4907
|
+
lastShares[resource] = inferShare(step.sample, lastShares[resource]);
|
|
4908
|
+
}
|
|
4909
|
+
const nextTargets = nextBudgetTargets({
|
|
4910
|
+
budgets,
|
|
4911
|
+
samples,
|
|
4912
|
+
shares: lastShares,
|
|
4913
|
+
previous: resourceTargets,
|
|
4914
|
+
activeKey: TABS[activeIndexRef.current].key,
|
|
4099
4915
|
floorMs: runtime.refreshMs,
|
|
4100
4916
|
nowMs: Date.now(),
|
|
4101
|
-
restPerTick: restPerTick(TABS[activeIndexRef.current].key),
|
|
4102
4917
|
});
|
|
4918
|
+
resourceTargets = nextTargets.targets;
|
|
4919
|
+
const target = nextTargets.targetMs;
|
|
4103
4920
|
if (adaptiveChangeWorthApplying(appliedMs, target)) {
|
|
4104
4921
|
rearm(target);
|
|
4105
4922
|
setThrottleMs(target > runtime.refreshMs ? target : null);
|
|
@@ -4121,7 +4938,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
4121
4938
|
// needed.
|
|
4122
4939
|
controller.abort();
|
|
4123
4940
|
};
|
|
4124
|
-
}, []);
|
|
4941
|
+
}, [dashboardCacheWriter]);
|
|
4125
4942
|
|
|
4126
4943
|
// Background tabs can be up to BACKGROUND_EVERY ticks stale, so the tab you
|
|
4127
4944
|
// switch to refreshes straight away rather than showing old data until its
|
|
@@ -4131,13 +4948,12 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
4131
4948
|
fetchTabRef.current?.(TABS[activeIndex].key);
|
|
4132
4949
|
}, [activeIndex]);
|
|
4133
4950
|
|
|
4134
|
-
// Animate only when something on screen is genuinely moving.
|
|
4135
|
-
//
|
|
4136
|
-
//
|
|
4137
|
-
//
|
|
4138
|
-
//
|
|
4139
|
-
//
|
|
4140
|
-
// motion reads as broken; after that, only a run actually executing is.
|
|
4951
|
+
// Animate only when something on screen is genuinely moving. Automatic polls
|
|
4952
|
+
// with settled data deliberately leave `loading` false; otherwise this timer
|
|
4953
|
+
// would restart every five seconds on an unchanged repository and undo the
|
|
4954
|
+
// redraw suppression above. The first load genuinely is worth animating,
|
|
4955
|
+
// because an empty pane with no motion reads as broken; after that, only a run
|
|
4956
|
+
// actually executing is.
|
|
4141
4957
|
// "Never resolved" and "never *succeeded*" are different states, and conflating
|
|
4142
4958
|
// them pinned the spinner on forever. setData is only ever called on success,
|
|
4143
4959
|
// so a tab whose fetch keeps failing -- offline laptop, VPN down, expired auth,
|
|
@@ -4177,7 +4993,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
4177
4993
|
}, [anyFirstLoad, iconHintDue]);
|
|
4178
4994
|
|
|
4179
4995
|
const items = data[tab.key];
|
|
4180
|
-
const displayError =
|
|
4996
|
+
const displayError = formatTabErrorForWidth(tabError, failureContext, Math.max(1, cols - 5));
|
|
4181
4997
|
const spin = SPINNER[frame % SPINNER.length];
|
|
4182
4998
|
|
|
4183
4999
|
const counts = Object.fromEntries(
|
|
@@ -4185,9 +5001,9 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
4185
5001
|
const list = data[t.key];
|
|
4186
5002
|
if (list == null) return [t.key, null];
|
|
4187
5003
|
// Every tab can be truncated, not just Actions: issues and PRs cap at
|
|
4188
|
-
// LIST_LIMIT and
|
|
4189
|
-
//
|
|
4190
|
-
//
|
|
5004
|
+
// LIST_LIMIT and each alert lane caps at 100. Reporting a filled newest or
|
|
5005
|
+
// priority lane as exact would hide that relevant rows can remain beyond
|
|
5006
|
+
// the bounded fetch.
|
|
4191
5007
|
const suffix = meta[t.key]?.truncated ? "+" : "";
|
|
4192
5008
|
// A blind Security tab reports "?" rather than a number it cannot stand
|
|
4193
5009
|
// behind. Zero alerts and zero visibility look identical otherwise.
|
|
@@ -4237,11 +5053,18 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
4237
5053
|
|
|
4238
5054
|
const allItems = items ?? [];
|
|
4239
5055
|
const tabOffsetRaw = offset[tab.key] ?? 0;
|
|
5056
|
+
const selectedKey = selected[tab.key] ?? null;
|
|
4240
5057
|
// Re-clamped on every render rather than only on resize: the payload can
|
|
4241
5058
|
// shrink under us between ticks, and a stale offset would render an empty
|
|
4242
5059
|
// body while the count in the frame said otherwise.
|
|
4243
5060
|
const maxOffset = Math.max(0, allItems.length - bodyRows);
|
|
4244
|
-
const
|
|
5061
|
+
const reconciledSelection = reconcileSelectionViewport({
|
|
5062
|
+
items: allItems,
|
|
5063
|
+
key: selectedKey,
|
|
5064
|
+
offset: Math.min(tabOffsetRaw, maxOffset),
|
|
5065
|
+
rows: bodyRows,
|
|
5066
|
+
});
|
|
5067
|
+
const tabOffset = reconciledSelection.offset;
|
|
4245
5068
|
const visibleItems = allItems.slice(tabOffset, tabOffset + bodyRows);
|
|
4246
5069
|
|
|
4247
5070
|
// Matched by key, never by position. If the selected item is gone -- closed,
|
|
@@ -4249,7 +5072,22 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
4249
5072
|
// highlighted, which is the honest state; the next arrow key selects from the
|
|
4250
5073
|
// top again. Resolving to a neighbouring index instead would silently move
|
|
4251
5074
|
// the cursor onto an unrelated row.
|
|
4252
|
-
|
|
5075
|
+
useEffect(() => {
|
|
5076
|
+
if (reconciledSelection.key !== selectedKey) {
|
|
5077
|
+
setSelected((current) => {
|
|
5078
|
+
if (reconciledSelection.key !== null) {
|
|
5079
|
+
return { ...current, [tab.key]: reconciledSelection.key };
|
|
5080
|
+
}
|
|
5081
|
+
if (!Object.hasOwn(current, tab.key)) return current;
|
|
5082
|
+
const next = { ...current };
|
|
5083
|
+
delete next[tab.key];
|
|
5084
|
+
return next;
|
|
5085
|
+
});
|
|
5086
|
+
}
|
|
5087
|
+
if (reconciledSelection.offset !== tabOffsetRaw) {
|
|
5088
|
+
setOffset((current) => ({ ...current, [tab.key]: reconciledSelection.offset }));
|
|
5089
|
+
}
|
|
5090
|
+
}, [reconciledSelection.key, reconciledSelection.offset, selectedKey, tabOffsetRaw, tab.key]);
|
|
4253
5091
|
// Bottom-right of the frame, lazygit style: how much of the tab you can
|
|
4254
5092
|
// currently see out of how much there is.
|
|
4255
5093
|
const countLabel =
|
|
@@ -4358,9 +5196,19 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
4358
5196
|
widthPreferenceWriter,
|
|
4359
5197
|
]);
|
|
4360
5198
|
|
|
5199
|
+
// Keep one physical row below Ink's output, the vertical counterpart to the
|
|
5200
|
+
// spare column in `frameCols`. Incremental rendering assumes its cursor still
|
|
5201
|
+
// sits at the bottom of the previous frame; if dynamic content occupies the
|
|
5202
|
+
// terminal's last row, a terminal scroll can invalidate that assumption and
|
|
5203
|
+
// leave old status lines behind. A non-fullscreen frame gets a trailing
|
|
5204
|
+
// newline from Ink, so the cursor parks on the unused guard row while the
|
|
5205
|
+
// status bar remains one row above the scroll edge.
|
|
5206
|
+
const liveRows = Math.min(rows, usableSize(stdout?.rows, rows));
|
|
5207
|
+
const frameRows = Math.max(1, liveRows - 1);
|
|
5208
|
+
|
|
4361
5209
|
return e(
|
|
4362
5210
|
Box,
|
|
4363
|
-
{ flexDirection: "column", width: frameCols, height:
|
|
5211
|
+
{ flexDirection: "column", width: frameCols, height: frameRows },
|
|
4364
5212
|
e(TabBar, {
|
|
4365
5213
|
activeIndex,
|
|
4366
5214
|
counts,
|
|
@@ -4398,8 +5246,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
4398
5246
|
// truncate-end so it degrades rather than wraps in a narrow pane. Every
|
|
4399
5247
|
// glyph in KEY_TABLE is plain ASCII, which is what keeps it safe here.
|
|
4400
5248
|
showHelp &&
|
|
4401
|
-
|
|
4402
|
-
.slice(0, bodyRows)
|
|
5249
|
+
helpLines(bodyRows)
|
|
4403
5250
|
.map((line, i) => e(Text, { key: `help${i}`, wrap: "truncate-end" }, line)),
|
|
4404
5251
|
!showHelp &&
|
|
4405
5252
|
tooNarrow &&
|
|
@@ -4445,7 +5292,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
4445
5292
|
const key = itemKey(item);
|
|
4446
5293
|
return e(
|
|
4447
5294
|
RowBoundary,
|
|
4448
|
-
{ key, resetKey:
|
|
5295
|
+
{ key, resetKey: item },
|
|
4449
5296
|
e(tab.Row, {
|
|
4450
5297
|
item,
|
|
4451
5298
|
now,
|
|
@@ -4465,8 +5312,8 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
4465
5312
|
// Distinguishes "still fetching" from "resolved and empty" in the body,
|
|
4466
5313
|
// which is the difference between a dashboard that looks hung and one
|
|
4467
5314
|
// that looks correct. Driven by "never resolved" rather than by the
|
|
4468
|
-
// loading flag, which
|
|
4469
|
-
// swap its message
|
|
5315
|
+
// loading flag, which also covers a manual refresh and made a
|
|
5316
|
+
// settled-empty tab swap its message. Suppressed entirely when a tab has
|
|
4470
5317
|
// an error and has never resolved: the error line directly above already
|
|
4471
5318
|
// says what happened, and "no runs" underneath it reads as a fact about the
|
|
4472
5319
|
// repository rather than the absence of an answer.
|
|
@@ -4508,6 +5355,12 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
4508
5355
|
widthMode: resizeRef.current.active,
|
|
4509
5356
|
widthColumn: selectedWidthColumn,
|
|
4510
5357
|
widthSaveError,
|
|
5358
|
+
canMove: allItems.length > 0,
|
|
5359
|
+
canOpen:
|
|
5360
|
+
selectedKey !== null &&
|
|
5361
|
+
Object.hasOwn(OPENABLE, tab.key) &&
|
|
5362
|
+
allItems.some((item) => itemKey(item) === selectedKey),
|
|
5363
|
+
canResize: fullHeaderVisible,
|
|
4511
5364
|
}),
|
|
4512
5365
|
);
|
|
4513
5366
|
}
|
|
@@ -4767,6 +5620,7 @@ export {
|
|
|
4767
5620
|
REFRESH_MS,
|
|
4768
5621
|
BACKGROUND_EVERY,
|
|
4769
5622
|
adaptiveRefreshMs,
|
|
5623
|
+
nextBudgetTargets,
|
|
4770
5624
|
adaptiveChangeWorthApplying,
|
|
4771
5625
|
sampleIsUsable,
|
|
4772
5626
|
inferShare,
|
|
@@ -4791,6 +5645,7 @@ export {
|
|
|
4791
5645
|
failureTargetHost,
|
|
4792
5646
|
unavailableRemedy,
|
|
4793
5647
|
createFailureContextCoordinator,
|
|
5648
|
+
createOpenRequestRegistry,
|
|
4794
5649
|
AUTH_RETRY_MS,
|
|
4795
5650
|
BACKOFF_STEPS_MS,
|
|
4796
5651
|
redact,
|
|
@@ -4818,6 +5673,27 @@ export {
|
|
|
4818
5673
|
loadWidthPreferences,
|
|
4819
5674
|
saveWidthPreferences,
|
|
4820
5675
|
createWidthPreferenceWriter,
|
|
5676
|
+
mergeWidthPreferenceSnapshots,
|
|
5677
|
+
adoptPersistedSnapshot,
|
|
5678
|
+
DASHBOARD_CACHE_VERSION,
|
|
5679
|
+
dashboardCachePath,
|
|
5680
|
+
dashboardCacheTarget,
|
|
5681
|
+
authCacheIdentity,
|
|
5682
|
+
serializeDashboardCache,
|
|
5683
|
+
loadDashboardCache,
|
|
5684
|
+
saveDashboardCache,
|
|
5685
|
+
mergeDashboardCacheSnapshots,
|
|
5686
|
+
nextSecurityRaw,
|
|
5687
|
+
shouldCheckpointFreshness,
|
|
5688
|
+
securityPollDelay,
|
|
5689
|
+
shouldShowFetchLoading,
|
|
5690
|
+
pollTickKeys,
|
|
5691
|
+
pollResultTransition,
|
|
5692
|
+
forcedBackoffKeys,
|
|
5693
|
+
alertRequestArgs,
|
|
5694
|
+
shouldFetchAlertPriorityLanes,
|
|
5695
|
+
mergeAlertRows,
|
|
5696
|
+
reconcileSelectionViewport,
|
|
4821
5697
|
runStatusIcon,
|
|
4822
5698
|
RUN_STATUS_ICON,
|
|
4823
5699
|
SEVERITY_STYLE,
|
|
@@ -4829,6 +5705,12 @@ export {
|
|
|
4829
5705
|
OCT_UNICODE,
|
|
4830
5706
|
KEY_TABLE,
|
|
4831
5707
|
KEY_HINTS,
|
|
5708
|
+
activeKeyHints,
|
|
5709
|
+
tabFailureSuffix,
|
|
5710
|
+
formatTabErrorForWidth,
|
|
5711
|
+
selectionLabel,
|
|
5712
|
+
helpLines,
|
|
5713
|
+
summarizeDoctorEnv,
|
|
4832
5714
|
widthStatusText,
|
|
4833
5715
|
headerGutterKey,
|
|
4834
5716
|
HeaderCells,
|
|
@@ -4837,7 +5719,9 @@ export {
|
|
|
4837
5719
|
hitDivider,
|
|
4838
5720
|
beginDividerDrag,
|
|
4839
5721
|
draggedWidth,
|
|
5722
|
+
shouldEnableMouseReporting,
|
|
4840
5723
|
createTerminalLifecycle,
|
|
5724
|
+
RowBoundary,
|
|
4841
5725
|
REMOTE_SETUP_HINTS,
|
|
4842
5726
|
REMOTE_SETUP_LINES,
|
|
4843
5727
|
REMOTE_SETUP_NONINTERACTIVE_LINES,
|