gh-glance 0.8.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 +68 -1
- package/README.md +65 -51
- package/index.mjs +759 -186
- package/package.json +3 -1
package/index.mjs
CHANGED
|
@@ -23,12 +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 {
|
|
27
28
|
chmodSync,
|
|
29
|
+
closeSync,
|
|
28
30
|
mkdirSync,
|
|
31
|
+
openSync,
|
|
29
32
|
readFileSync,
|
|
30
33
|
realpathSync,
|
|
31
34
|
renameSync,
|
|
35
|
+
statSync,
|
|
32
36
|
unlinkSync,
|
|
33
37
|
writeFileSync,
|
|
34
38
|
} from "node:fs";
|
|
@@ -97,18 +101,16 @@ const MAX_RUN_LIMIT = 60;
|
|
|
97
101
|
// stay generous. They are also far cheaper, being bounded by what's open.
|
|
98
102
|
const LIST_LIMIT = 150;
|
|
99
103
|
|
|
100
|
-
// Alert endpoints are filtered server-side to open items and
|
|
101
|
-
// page. The previous --paginate walked the repo's entire alert
|
|
102
|
-
// 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.
|
|
103
107
|
const ALERT_PER_PAGE = 100;
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
// survives GitHub changing a default underneath us. All three endpoints accept
|
|
111
|
-
// 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.
|
|
112
114
|
const ALERT_QUERY = `?state=open&per_page=${ALERT_PER_PAGE}&sort=created&direction=desc`;
|
|
113
115
|
|
|
114
116
|
// Inactive tabs only feed the tab-bar counts, so they refresh every Nth tick
|
|
@@ -442,6 +444,13 @@ const VERDICT_REMEDY = {
|
|
|
442
444
|
"Repository not found or inaccessible to the active `gh` account -- check `gh auth status` and the repository target",
|
|
443
445
|
};
|
|
444
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
|
+
|
|
445
454
|
function toTabError(err) {
|
|
446
455
|
return { kind: "fetch", verdict: classify(err), raw: shortErr(err) };
|
|
447
456
|
}
|
|
@@ -504,6 +513,16 @@ function formatTabError(error, failureContext = null) {
|
|
|
504
513
|
return pick(VERDICT_REMEDY, error.verdict, null) ?? error.raw;
|
|
505
514
|
}
|
|
506
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
|
+
|
|
507
526
|
// Some pty wrappers (and a terminal mid-resize) report a size of 0 or
|
|
508
527
|
// undefined. Taking that literally collapses the table to a single row (or
|
|
509
528
|
// draws a zero-width border), so fall back to sane defaults until real
|
|
@@ -809,6 +828,7 @@ async function fetchActions(limit, signal) {
|
|
|
809
828
|
// re-derived from the argv shape -- a second copy of the cost model is the
|
|
810
829
|
// drift this file warns about repeatedly.
|
|
811
830
|
restSpent: REST_PER_FETCH.actions,
|
|
831
|
+
graphqlSpent: GRAPHQL_PER_FETCH.actions,
|
|
812
832
|
parse: () =>
|
|
813
833
|
JSON.parse(raw).map((r) => ({
|
|
814
834
|
databaseId: r.databaseId,
|
|
@@ -857,6 +877,7 @@ async function fetchIssues(signal) {
|
|
|
857
877
|
// Stated rather than omitted so the zero is visibly deliberate: SORT_RECENT's
|
|
858
878
|
// --search routes this through GraphQL, which is a separate budget.
|
|
859
879
|
restSpent: REST_PER_FETCH.issues,
|
|
880
|
+
graphqlSpent: GRAPHQL_PER_FETCH.issues,
|
|
860
881
|
parse: () =>
|
|
861
882
|
JSON.parse(raw).map((i) => ({
|
|
862
883
|
number: i.number,
|
|
@@ -890,6 +911,7 @@ async function fetchPRs(signal) {
|
|
|
890
911
|
limit: LIST_LIMIT,
|
|
891
912
|
// Zero for the same reason as issues: --search makes this a GraphQL call.
|
|
892
913
|
restSpent: REST_PER_FETCH.prs,
|
|
914
|
+
graphqlSpent: GRAPHQL_PER_FETCH.prs,
|
|
893
915
|
parse: () =>
|
|
894
916
|
JSON.parse(raw).map((p) => ({
|
|
895
917
|
number: p.number,
|
|
@@ -922,6 +944,7 @@ const ALERT_SOURCES = [
|
|
|
922
944
|
key: "dependabot",
|
|
923
945
|
name: "Dependabot alerts",
|
|
924
946
|
path: `repos/{owner}/{repo}/dependabot/alerts${ALERT_QUERY}`,
|
|
947
|
+
priorityQueries: ["severity=critical,high"],
|
|
925
948
|
jq: "[.[] | {number, state, created_at, severity: .security_advisory.severity, title: .security_advisory.summary, detail: .dependency.package.name}]",
|
|
926
949
|
unavailable: "Dependabot alerts: unavailable (not enabled for this repository)",
|
|
927
950
|
map: (a) => ({
|
|
@@ -937,6 +960,7 @@ const ALERT_SOURCES = [
|
|
|
937
960
|
key: "codeScanning",
|
|
938
961
|
name: "Code scanning",
|
|
939
962
|
path: `repos/{owner}/{repo}/code-scanning/alerts${ALERT_QUERY}`,
|
|
963
|
+
priorityQueries: ["severity=critical", "severity=high"],
|
|
940
964
|
jq: "[.[] | {number, state, created_at, severity: (.rule.security_severity_level // .rule.severity), title: (.rule.description // .rule.name), detail: .most_recent_instance.location.path}]",
|
|
941
965
|
unavailable: "Code scanning: not enabled (needs GitHub Advanced Security)",
|
|
942
966
|
map: (a) => ({
|
|
@@ -952,6 +976,7 @@ const ALERT_SOURCES = [
|
|
|
952
976
|
key: "secretScanning",
|
|
953
977
|
name: "Secret scanning",
|
|
954
978
|
path: `repos/{owner}/{repo}/secret-scanning/alerts${ALERT_QUERY}`,
|
|
979
|
+
priorityQueries: [],
|
|
955
980
|
jq: "[.[] | {number, state, created_at, title: (.secret_type_display_name // .secret_type)}]",
|
|
956
981
|
unavailable: "Secret scanning: not enabled for this repository",
|
|
957
982
|
map: (a) => ({
|
|
@@ -978,7 +1003,11 @@ const ALERT_SOURCES = [
|
|
|
978
1003
|
//
|
|
979
1004
|
// issues and prs are 0 REST because SORT_RECENT's --search routes both through
|
|
980
1005
|
// GraphQL entirely (2 POSTs each, confirmed by the same measurement).
|
|
981
|
-
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 };
|
|
982
1011
|
const GRAPHQL_PER_FETCH = { actions: 0, issues: 2, prs: 2, security: 0 };
|
|
983
1012
|
|
|
984
1013
|
// Ceiling on adaptive widening. An adaptive interval needs one: a minute is
|
|
@@ -1013,7 +1042,7 @@ const ADAPTIVE_HYSTERESIS = 1.25;
|
|
|
1013
1042
|
// that feeds it must agree on the answer: the law uses it to decide whether to
|
|
1014
1043
|
// infer, and the loop uses it to decide whether the window may be closed.
|
|
1015
1044
|
function sampleIsUsable(sample) {
|
|
1016
|
-
return Boolean(sample) && sample.
|
|
1045
|
+
return Boolean(sample) && sample.myCalls >= MIN_SAMPLE_CALLS && sample.globalUsed > 0;
|
|
1017
1046
|
}
|
|
1018
1047
|
|
|
1019
1048
|
// How many equivalent consumers are on this token, from one probe window.
|
|
@@ -1028,7 +1057,7 @@ function sampleIsUsable(sample) {
|
|
|
1028
1057
|
// where it is until a window is long enough to say otherwise.
|
|
1029
1058
|
function inferShare(sample, lastShare = 1) {
|
|
1030
1059
|
return sampleIsUsable(sample)
|
|
1031
|
-
? Math.max(1, sample.globalUsed / sample.
|
|
1060
|
+
? Math.max(1, sample.globalUsed / sample.myCalls)
|
|
1032
1061
|
: Math.max(1, lastShare);
|
|
1033
1062
|
}
|
|
1034
1063
|
|
|
@@ -1051,7 +1080,7 @@ function nextProbeWindow(prev, budget, spentTotal) {
|
|
|
1051
1080
|
}
|
|
1052
1081
|
const sample = {
|
|
1053
1082
|
globalUsed: budget.used - prev.used,
|
|
1054
|
-
|
|
1083
|
+
myCalls: spentTotal - prev.spent,
|
|
1055
1084
|
};
|
|
1056
1085
|
return { sample, next: sampleIsUsable(sample) ? { used: budget.used, spent: spentTotal } : prev };
|
|
1057
1086
|
}
|
|
@@ -1102,15 +1131,67 @@ function adaptiveChangeWorthApplying(appliedMs, targetMs) {
|
|
|
1102
1131
|
// other three amortised over BACKGROUND_EVERY. Same table and same shape as
|
|
1103
1132
|
// projectedHourlyCost, so the controller and the report cannot disagree.
|
|
1104
1133
|
function restPerTick(activeKey) {
|
|
1105
|
-
|
|
1134
|
+
return resourcePerTick(REST_PER_FETCH, activeKey);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function resourcePerTick(table, activeKey) {
|
|
1138
|
+
let cost = table[activeKey] ?? 0;
|
|
1106
1139
|
for (const key of TAB_KEYS) {
|
|
1107
|
-
if (key !== activeKey) cost += (
|
|
1140
|
+
if (key !== activeKey) cost += (table[key] ?? 0) / BACKGROUND_EVERY;
|
|
1108
1141
|
}
|
|
1109
1142
|
return cost;
|
|
1110
1143
|
}
|
|
1111
1144
|
|
|
1112
|
-
function
|
|
1113
|
-
|
|
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;
|
|
1114
1195
|
}
|
|
1115
1196
|
|
|
1116
1197
|
// Per-source backoff. Keyed by source so one unavailable endpoint cannot slow
|
|
@@ -1118,17 +1199,17 @@ function alertArgs(source) {
|
|
|
1118
1199
|
// mid-session is picked up within the hour.
|
|
1119
1200
|
const alertBackoff = new Map();
|
|
1120
1201
|
|
|
1121
|
-
// REST calls this process has spent, ever. In-memory and per-process,
|
|
1122
|
-
// same spirit as alertBackoff above: lost on exit, and nothing depends on
|
|
1123
|
-
// surviving.
|
|
1124
|
-
//
|
|
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.
|
|
1125
1206
|
//
|
|
1126
1207
|
// Deliberately incomplete: openItem, preflight and resolveFailureContext also
|
|
1127
1208
|
// spend, and are not counted, because they are occasional rather than periodic.
|
|
1128
1209
|
// Under-reporting our own spend makes the inferred share *larger* and so
|
|
1129
1210
|
// throttles slightly harder than strictly necessary -- the safe direction to err
|
|
1130
1211
|
// in, which is why the imprecision is tolerated rather than chased.
|
|
1131
|
-
|
|
1212
|
+
const spentTotal = { core: 0, graphql: 0 };
|
|
1132
1213
|
|
|
1133
1214
|
function backoffActive(key, now) {
|
|
1134
1215
|
const state = alertBackoff.get(key);
|
|
@@ -1159,6 +1240,13 @@ function clearBackoff(key) {
|
|
|
1159
1240
|
alertBackoff.delete(key);
|
|
1160
1241
|
}
|
|
1161
1242
|
|
|
1243
|
+
function forcedBackoffKeys(key) {
|
|
1244
|
+
return [
|
|
1245
|
+
`tab:${key}`,
|
|
1246
|
+
...(key === "security" ? ALERT_SOURCES.map((source) => source.key) : []),
|
|
1247
|
+
];
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1162
1250
|
async function fetchAlertSource(source, signal, now) {
|
|
1163
1251
|
if (backoffActive(source.key, now)) {
|
|
1164
1252
|
const { note, verdict } = alertBackoff.get(source.key);
|
|
@@ -1173,19 +1261,30 @@ async function fetchAlertSource(source, signal, now) {
|
|
|
1173
1261
|
parse: () => ({ alerts: [], note, verdict, truncated: false }),
|
|
1174
1262
|
};
|
|
1175
1263
|
}
|
|
1264
|
+
let spent = 0;
|
|
1176
1265
|
try {
|
|
1177
|
-
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");
|
|
1178
1277
|
clearBackoff(source.key);
|
|
1179
1278
|
return {
|
|
1180
1279
|
raw,
|
|
1181
|
-
spent
|
|
1280
|
+
spent,
|
|
1182
1281
|
parse: () => {
|
|
1183
|
-
const rows =
|
|
1282
|
+
const rows = mergeAlertRows(groups);
|
|
1184
1283
|
return {
|
|
1185
1284
|
alerts: rows.map(source.map),
|
|
1186
1285
|
note: null,
|
|
1187
1286
|
verdict: "ok",
|
|
1188
|
-
truncated:
|
|
1287
|
+
truncated: groups.some((group) => group.length >= ALERT_PER_PAGE),
|
|
1189
1288
|
};
|
|
1190
1289
|
},
|
|
1191
1290
|
};
|
|
@@ -1205,7 +1304,7 @@ async function fetchAlertSource(source, signal, now) {
|
|
|
1205
1304
|
raw: `unavailable:${note}`,
|
|
1206
1305
|
// A failed call still bills: the request reached GitHub and was counted
|
|
1207
1306
|
// whether it returned data, `[]`, or a 403.
|
|
1208
|
-
spent
|
|
1307
|
+
spent,
|
|
1209
1308
|
parse: () => ({ alerts: [], note, verdict, truncated: false }),
|
|
1210
1309
|
};
|
|
1211
1310
|
}
|
|
@@ -1243,6 +1342,7 @@ async function fetchSecurity(signal) {
|
|
|
1243
1342
|
// ALERT_SOURCES.length: a source held off by backoff spawned nothing and
|
|
1244
1343
|
// must not be billed for it.
|
|
1245
1344
|
restSpent: parts.reduce((n, p) => n + p.spent, 0),
|
|
1345
|
+
graphqlSpent: 0,
|
|
1246
1346
|
parse: () => {
|
|
1247
1347
|
const parsed = parts.map((p) => p.parse());
|
|
1248
1348
|
const alerts = parsed.flatMap((p) => p.alerts);
|
|
@@ -1279,6 +1379,19 @@ function itemKey(item) {
|
|
|
1279
1379
|
return item?.id ?? item?.databaseId ?? item?.number ?? null;
|
|
1280
1380
|
}
|
|
1281
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
|
+
|
|
1282
1395
|
// `gh <kind> view --web` for the tabs that have a per-item command. The Security
|
|
1283
1396
|
// tab is absent on purpose: alerts have no `gh` view subcommand, so there is
|
|
1284
1397
|
// nothing honest to open.
|
|
@@ -1301,6 +1414,30 @@ async function openInBrowser(tabKey, item, signal) {
|
|
|
1301
1414
|
await runGh([kind, "view", ...repoArgs(), String(id), "--web"], { signal });
|
|
1302
1415
|
}
|
|
1303
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
|
+
|
|
1304
1441
|
// ---------- Startup preflight ----------
|
|
1305
1442
|
|
|
1306
1443
|
// Three failures are guaranteed on a fresh machine or a terminal that happens
|
|
@@ -1395,6 +1532,20 @@ function doctorEnvNames() {
|
|
|
1395
1532
|
return [...named, ...extra];
|
|
1396
1533
|
}
|
|
1397
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
|
+
|
|
1398
1549
|
// Presence-only is the DEFAULT; printing a value is the opt-in, and the opt-in
|
|
1399
1550
|
// list is curated above. This was the other way round -- print unless the *name*
|
|
1400
1551
|
// ended in _TOKEN/_SECRET/_PASSWORD/_KEY -- which meant the discovery loop above
|
|
@@ -1408,7 +1559,7 @@ function envValue(name) {
|
|
|
1408
1559
|
const value = process.env[name];
|
|
1409
1560
|
if (!value) return NOT_SET;
|
|
1410
1561
|
if (DOCTOR_ENV_PROXY.includes(name)) return proxySummary(value);
|
|
1411
|
-
if (DOCTOR_ENV_PLAIN.includes(name)) return value;
|
|
1562
|
+
if (DOCTOR_ENV_PLAIN.includes(name)) return summarizeDoctorEnv(name, value);
|
|
1412
1563
|
return "set";
|
|
1413
1564
|
}
|
|
1414
1565
|
|
|
@@ -1483,8 +1634,9 @@ function targetSource() {
|
|
|
1483
1634
|
|
|
1484
1635
|
// How much of the hourly API budget is left, and roughly how fast this
|
|
1485
1636
|
// configuration spends it. The steady-state cost is not small and was invisible:
|
|
1486
|
-
// at the default 5s refresh with the Security tab open
|
|
1487
|
-
// 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`
|
|
1488
1640
|
// projects past the limit outright, so it exhausts inside the hour, every hour.
|
|
1489
1641
|
// The budget is shared with everything else the token does, so the first symptom
|
|
1490
1642
|
// is usually "GitHub is broken" somewhere else entirely.
|
|
@@ -1514,28 +1666,43 @@ async function rateBudget() {
|
|
|
1514
1666
|
}
|
|
1515
1667
|
}
|
|
1516
1668
|
|
|
1517
|
-
// The same probe as rateBudget, but returning
|
|
1518
|
-
//
|
|
1519
|
-
// keeps its
|
|
1520
|
-
//
|
|
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.
|
|
1521
1673
|
//
|
|
1522
1674
|
// Safe to run on a timer for the same reason it is safe on the diagnostic path:
|
|
1523
1675
|
// `gh api rate_limit` is documented as not counting against the limit, and it
|
|
1524
1676
|
// measures as free (verified: delta 0).
|
|
1525
|
-
|
|
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) {
|
|
1526
1696
|
try {
|
|
1527
1697
|
// apiHostArgs() for the same reason the alert endpoints carry it: --repo
|
|
1528
1698
|
// host/owner/name sets runtime.host without setting GH_HOST, so without the
|
|
1529
1699
|
// flag this reads github.com's budget while the pane spends against the
|
|
1530
1700
|
// tenant -- and then throttles, or fails to, against an unrelated number.
|
|
1531
1701
|
const raw = await runGh(["api", "rate_limit", ...apiHostArgs()], { signal });
|
|
1532
|
-
const
|
|
1533
|
-
if (!core || !Number.isFinite(core.remaining)) return null;
|
|
1702
|
+
const resources = JSON.parse(raw)?.resources;
|
|
1534
1703
|
return {
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
used: core.used,
|
|
1538
|
-
resetMs: core.reset * 1000,
|
|
1704
|
+
core: normalizeRateBudget(resources?.core),
|
|
1705
|
+
graphql: normalizeRateBudget(resources?.graphql),
|
|
1539
1706
|
};
|
|
1540
1707
|
} catch {
|
|
1541
1708
|
// A probe failure must never be louder than the pane's own data: the loop
|
|
@@ -1552,16 +1719,10 @@ async function readCoreBudget(signal) {
|
|
|
1552
1719
|
// two copies of them would diverge the first time one was fixed.
|
|
1553
1720
|
function projectedHourlyCost(activeKey) {
|
|
1554
1721
|
const perHour = 3_600_000 / runtime.refreshMs;
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
for (const key of TAB_KEYS) {
|
|
1560
|
-
const ticks = key === activeKey ? perHour : perHour / BACKGROUND_EVERY;
|
|
1561
|
-
rest += ticks * REST_PER_FETCH[key];
|
|
1562
|
-
graphql += ticks * GRAPHQL_PER_FETCH[key];
|
|
1563
|
-
}
|
|
1564
|
-
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
|
+
};
|
|
1565
1726
|
}
|
|
1566
1727
|
|
|
1567
1728
|
async function runDoctor() {
|
|
@@ -1574,11 +1735,16 @@ async function runDoctor() {
|
|
|
1574
1735
|
["Actions (run list)", actionsArgs(MIN_RUN_LIMIT)],
|
|
1575
1736
|
["Issues (issue list)", issuesArgs()],
|
|
1576
1737
|
["Pull requests (pr list)", prsArgs()],
|
|
1577
|
-
...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
|
+
),
|
|
1578
1744
|
];
|
|
1579
1745
|
|
|
1580
1746
|
// allSettled, and every probe already resolves rather than rejects, so one
|
|
1581
|
-
// slow endpoint cannot suppress the other
|
|
1747
|
+
// slow endpoint cannot suppress the other probes. runGh's own GH_TIMEOUT_MS
|
|
1582
1748
|
// bounds each one.
|
|
1583
1749
|
const [ghVersion, authStatus, remote, budget, results] = await Promise.all([
|
|
1584
1750
|
captureGh(["--version"]),
|
|
@@ -1817,6 +1983,17 @@ const KEY_TABLE = [
|
|
|
1817
1983
|
const KEY_COL = Math.max(...KEY_TABLE.map(([k]) => k.length)) + 3;
|
|
1818
1984
|
const keyTableLines = () => KEY_TABLE.map(([k, d]) => `${k.padEnd(KEY_COL)}${d}`);
|
|
1819
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
|
+
|
|
1820
1997
|
const HELP = `gh-glance ${version} -- a live-refreshing GitHub dashboard for a narrow terminal pane.
|
|
1821
1998
|
|
|
1822
1999
|
Usage:
|
|
@@ -2098,6 +2275,10 @@ function Column({ width, grow, children, bold, color, dim, wrap, label, marginRi
|
|
|
2098
2275
|
);
|
|
2099
2276
|
}
|
|
2100
2277
|
|
|
2278
|
+
function selectionLabel(label, selected) {
|
|
2279
|
+
return selected ? `selected, ${label}` : label;
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2101
2282
|
// Each descriptor still owns one gutter cell, but an adjustable column owns
|
|
2102
2283
|
// the edge that faces the flexible TITLE/SUMMARY reservoir. Fixed columns after
|
|
2103
2284
|
// the grow cell therefore use the preceding descriptor's trailing gutter;
|
|
@@ -2329,6 +2510,10 @@ function sameElementMetrics(left, right) {
|
|
|
2329
2510
|
);
|
|
2330
2511
|
}
|
|
2331
2512
|
|
|
2513
|
+
function shouldEnableMouseReporting({ interactive, widthMode }) {
|
|
2514
|
+
return Boolean(interactive && widthMode);
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2332
2517
|
// ---------- Panel frame ----------
|
|
2333
2518
|
|
|
2334
2519
|
// Ink can draw a border but not label one, so the horizontal edges are plain
|
|
@@ -2472,7 +2657,7 @@ function ActionsRow({ item, now, spin, compact, cursor, columns }) {
|
|
|
2472
2657
|
return e(
|
|
2473
2658
|
Box,
|
|
2474
2659
|
{ flexDirection: "row" },
|
|
2475
|
-
e(Column, { ...columnProps(columns, "status"), color, label }, `${cursor ? ">" : " "}${icon}`),
|
|
2660
|
+
e(Column, { ...columnProps(columns, "status"), color, label: selectionLabel(label, cursor) }, `${cursor ? ">" : " "}${icon}`),
|
|
2476
2661
|
e(Column, columnProps(columns, "title"), item.displayTitle),
|
|
2477
2662
|
e(Column, { ...columnProps(columns, "updated"), dim: true }, formatAge(new Date(item.updatedAt), now)),
|
|
2478
2663
|
);
|
|
@@ -2480,7 +2665,7 @@ function ActionsRow({ item, now, spin, compact, cursor, columns }) {
|
|
|
2480
2665
|
return e(
|
|
2481
2666
|
Box,
|
|
2482
2667
|
{ flexDirection: "row" },
|
|
2483
|
-
e(Column, { ...columnProps(columns, "status"), color, label }, `${cursor ? ">" : " "}${icon}`),
|
|
2668
|
+
e(Column, { ...columnProps(columns, "status"), color, label: selectionLabel(label, cursor) }, `${cursor ? ">" : " "}${icon}`),
|
|
2484
2669
|
e(Column, columnProps(columns, "title"), item.displayTitle),
|
|
2485
2670
|
// The run number is the actionable half and used to be the first thing
|
|
2486
2671
|
// truncation ate, since it sat at the tail of a 10-column cell.
|
|
@@ -2509,7 +2694,7 @@ const ISSUES_HEADER_COMPACT = [
|
|
|
2509
2694
|
|
|
2510
2695
|
function IssueRow({ item, now, compact, cursor, columns }) {
|
|
2511
2696
|
const cells = [
|
|
2512
|
-
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}`),
|
|
2513
2698
|
e(Column, { key: "title", ...columnProps(columns, "title") }, `#${item.number} ${item.title}`),
|
|
2514
2699
|
];
|
|
2515
2700
|
if (!compact) {
|
|
@@ -2552,7 +2737,7 @@ function PRRow({ item, now, compact, cursor, columns }) {
|
|
|
2552
2737
|
: { icon: OCT.pullRequest, color: OK, label: "open pull request" };
|
|
2553
2738
|
const review = pick(REVIEW_LABEL, item.reviewDecision, REVIEW_NONE);
|
|
2554
2739
|
const cells = [
|
|
2555
|
-
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}`),
|
|
2556
2741
|
e(Column, { key: "title", ...columnProps(columns, "title") }, `#${item.number} ${item.title}`),
|
|
2557
2742
|
];
|
|
2558
2743
|
if (!compact) {
|
|
@@ -2606,7 +2791,7 @@ const SEVERITY_UNKNOWN = SEVERITY_STYLE.unknown;
|
|
|
2606
2791
|
function SecurityRow({ item, now, compact, cursor, columns }) {
|
|
2607
2792
|
const sev = pick(SEVERITY_STYLE, item.severity, SEVERITY_UNKNOWN);
|
|
2608
2793
|
const cells = [
|
|
2609
|
-
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}`),
|
|
2610
2795
|
e(Column, { key: "severity", ...columnProps(columns, "severity"), color: sev.color }, sev.short),
|
|
2611
2796
|
];
|
|
2612
2797
|
if (!compact) {
|
|
@@ -2888,6 +3073,116 @@ function isRecord(value) {
|
|
|
2888
3073
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
2889
3074
|
}
|
|
2890
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
|
+
|
|
2891
3186
|
function normalizeWidthOverrides(overrides, tabs = TABS, { omitDefaults = false } = {}) {
|
|
2892
3187
|
if (!isRecord(overrides)) return {};
|
|
2893
3188
|
|
|
@@ -2954,30 +3249,39 @@ function loadWidthPreferences(path, tabs = TABS) {
|
|
|
2954
3249
|
return decodeWidthPreferences(raw, tabs);
|
|
2955
3250
|
}
|
|
2956
3251
|
|
|
2957
|
-
function saveWidthPreferences(path, overrides, tabs = TABS) {
|
|
2958
|
-
let tempPath = null;
|
|
3252
|
+
function saveWidthPreferences(path, overrides, tabs = TABS, { base = null } = {}) {
|
|
2959
3253
|
try {
|
|
2960
|
-
const payload = serializeWidthPreferences(overrides, tabs);
|
|
2961
3254
|
const parent = dirname(path);
|
|
2962
3255
|
mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
writeFileSync(tempPath, payload, {
|
|
2966
|
-
encoding: "utf8",
|
|
2967
|
-
mode: 0o600,
|
|
2968
|
-
flag: "wx",
|
|
2969
|
-
});
|
|
2970
|
-
renameSync(tempPath, path);
|
|
2971
|
-
return { ok: true };
|
|
2972
|
-
} catch (error) {
|
|
2973
|
-
if (tempPath !== null) {
|
|
3256
|
+
return withPersistenceLock(path, () => {
|
|
3257
|
+
let tempPath = null;
|
|
2974
3258
|
try {
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
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 };
|
|
2979
3282
|
}
|
|
2980
|
-
}
|
|
3283
|
+
});
|
|
3284
|
+
} catch (error) {
|
|
2981
3285
|
return { ok: false, error };
|
|
2982
3286
|
}
|
|
2983
3287
|
}
|
|
@@ -3088,7 +3392,7 @@ const createWidthPreferenceWriter = createCoalescedWriter;
|
|
|
3088
3392
|
|
|
3089
3393
|
// ---------- Last-known-good dashboard cache ----------
|
|
3090
3394
|
|
|
3091
|
-
const DASHBOARD_CACHE_VERSION =
|
|
3395
|
+
const DASHBOARD_CACHE_VERSION = 2;
|
|
3092
3396
|
const MAX_DASHBOARD_CACHE_TARGETS = 5;
|
|
3093
3397
|
const MAX_DASHBOARD_CACHE_ROWS_PER_TAB = 60;
|
|
3094
3398
|
let dashboardCacheTempSequence = 0;
|
|
@@ -3097,15 +3401,66 @@ function dashboardCachePath(options = {}) {
|
|
|
3097
3401
|
return join(dirname(widthPreferencesPath(options)), "dashboard-cache.json");
|
|
3098
3402
|
}
|
|
3099
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
|
+
|
|
3100
3449
|
// Explicit targets are stable across working directories. In inferred mode the
|
|
3101
3450
|
// directory is the only repository identity available without making another
|
|
3102
3451
|
// GitHub call -- exactly the call that cannot succeed when this cache is needed.
|
|
3103
3452
|
// JSON encoding avoids delimiter ambiguity in host, repo, and path strings.
|
|
3104
|
-
function dashboardCacheTarget({
|
|
3453
|
+
function dashboardCacheTarget({
|
|
3454
|
+
repo = null,
|
|
3455
|
+
ghRepo = null,
|
|
3456
|
+
host = null,
|
|
3457
|
+
cwd = process.cwd(),
|
|
3458
|
+
account = authCacheIdentity(),
|
|
3459
|
+
} = {}) {
|
|
3105
3460
|
const target = repo || ghRepo;
|
|
3106
3461
|
return target
|
|
3107
|
-
? JSON.stringify({ kind: "repo", host: String(host ?? ""), repo: String(target) })
|
|
3108
|
-
: JSON.stringify({ kind: "cwd", host: String(host ?? ""), cwd: String(cwd) });
|
|
3462
|
+
? JSON.stringify({ kind: "repo", host: String(host ?? ""), repo: String(target), account })
|
|
3463
|
+
: JSON.stringify({ kind: "cwd", host: String(host ?? ""), cwd: String(cwd), account });
|
|
3109
3464
|
}
|
|
3110
3465
|
|
|
3111
3466
|
function cacheTimestamp(value) {
|
|
@@ -3273,29 +3628,39 @@ function loadDashboardCache(path, target) {
|
|
|
3273
3628
|
};
|
|
3274
3629
|
}
|
|
3275
3630
|
|
|
3276
|
-
function saveDashboardCache(path, cache, { normalized = false } = {}) {
|
|
3277
|
-
let tempPath = null;
|
|
3631
|
+
function saveDashboardCache(path, cache, { normalized = false, base = null } = {}) {
|
|
3278
3632
|
try {
|
|
3279
3633
|
const parent = dirname(path);
|
|
3280
3634
|
mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
3281
3635
|
if (process.platform !== "win32") chmodSync(parent, 0o700);
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
writeFileSync(tempPath, serializeDashboardCache(cache, { normalized }), {
|
|
3285
|
-
encoding: "utf8",
|
|
3286
|
-
mode: 0o600,
|
|
3287
|
-
flag: "wx",
|
|
3288
|
-
});
|
|
3289
|
-
renameSync(tempPath, path);
|
|
3290
|
-
return { ok: true };
|
|
3291
|
-
} catch (error) {
|
|
3292
|
-
if (tempPath !== null) {
|
|
3636
|
+
return withPersistenceLock(path, () => {
|
|
3637
|
+
let tempPath = null;
|
|
3293
3638
|
try {
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
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 };
|
|
3297
3661
|
}
|
|
3298
|
-
}
|
|
3662
|
+
});
|
|
3663
|
+
} catch (error) {
|
|
3299
3664
|
return { ok: false, error };
|
|
3300
3665
|
}
|
|
3301
3666
|
}
|
|
@@ -3305,6 +3670,54 @@ function nextSecurityRaw(previousRaw, raw, blind) {
|
|
|
3305
3670
|
return previousRaw === raw ? previousRaw : raw;
|
|
3306
3671
|
}
|
|
3307
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
|
+
|
|
3308
3721
|
// Below this the full column set cannot render without its fixed columns
|
|
3309
3722
|
// overflowing the frame -- rows hard-wrap and ink switches to clearing and
|
|
3310
3723
|
// repainting the whole screen every frame. Worse, in the band just above the
|
|
@@ -3323,6 +3736,11 @@ const MIN_COMPACT_WIDTH = Math.max(...TABS.map((t) => minimumWidthFor(t.compactH
|
|
|
3323
3736
|
const TAB_LABEL_FULL_WIDTH = 78;
|
|
3324
3737
|
const TAB_LABEL_HYSTERESIS = 4;
|
|
3325
3738
|
|
|
3739
|
+
function tabFailureSuffix({ count, failed, brokenCI }) {
|
|
3740
|
+
if (count == null) return failed ? " x" : "";
|
|
3741
|
+
return ` (${count}${brokenCI ? "!" : ""}${failed ? "x" : ""})`;
|
|
3742
|
+
}
|
|
3743
|
+
|
|
3326
3744
|
function TabBar({ activeIndex, counts, brokenCI, firstLoad, failed, spin, useShort }) {
|
|
3327
3745
|
return e(
|
|
3328
3746
|
Box,
|
|
@@ -3336,8 +3754,12 @@ function TabBar({ activeIndex, counts, brokenCI, firstLoad, failed, spin, useSho
|
|
|
3336
3754
|
count == null
|
|
3337
3755
|
? firstLoad[tab.key]
|
|
3338
3756
|
? ` ${spin}`
|
|
3339
|
-
:
|
|
3340
|
-
:
|
|
3757
|
+
: tabFailureSuffix({ count, failed: failed[tab.key], brokenCI: false })
|
|
3758
|
+
: tabFailureSuffix({
|
|
3759
|
+
count,
|
|
3760
|
+
failed: failed[tab.key],
|
|
3761
|
+
brokenCI: brokenCI[tab.key],
|
|
3762
|
+
});
|
|
3341
3763
|
const name = useShort ? tab.short : tab.label;
|
|
3342
3764
|
// Bold and inverse are both stripped at chalk level 0 (NO_COLOR, or a
|
|
3343
3765
|
// dumb terminal), which left no indication at all of which tab was
|
|
@@ -3397,6 +3819,23 @@ const KEY_HINTS = [
|
|
|
3397
3819
|
{ label: "Quit", keys: "q" },
|
|
3398
3820
|
];
|
|
3399
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
|
+
|
|
3400
3839
|
const REMOTE_SETUP_HINTS = [
|
|
3401
3840
|
{ label: "Create remote", keys: "Ent" },
|
|
3402
3841
|
{ label: "Quit", keys: "q" },
|
|
@@ -3467,6 +3906,9 @@ function StatusBar({
|
|
|
3467
3906
|
widthMode = false,
|
|
3468
3907
|
widthColumn = null,
|
|
3469
3908
|
widthSaveError = null,
|
|
3909
|
+
canMove = false,
|
|
3910
|
+
canOpen = false,
|
|
3911
|
+
canResize = false,
|
|
3470
3912
|
}) {
|
|
3471
3913
|
// Width mode owns the whole bar, so the throttle badge is deliberately not
|
|
3472
3914
|
// shown here: this state is transient and explicitly entered, and the widened
|
|
@@ -3490,11 +3932,7 @@ function StatusBar({
|
|
|
3490
3932
|
// Without raw mode none of the key handlers run, so advertising them would be
|
|
3491
3933
|
// telling the user something untrue about what the app can do. Ctrl+C still
|
|
3492
3934
|
// works there, because the tty delivers a real SIGINT.
|
|
3493
|
-
const hints = interactive
|
|
3494
|
-
? remoteSetup
|
|
3495
|
-
? REMOTE_SETUP_HINTS
|
|
3496
|
-
: KEY_HINTS
|
|
3497
|
-
: [{ label: "Quit", keys: "^C" }];
|
|
3935
|
+
const hints = activeKeyHints({ interactive, remoteSetup, canMove, canOpen, canResize });
|
|
3498
3936
|
const hintsFullWidth =
|
|
3499
3937
|
hints.reduce((sum, hint) => sum + hint.label.length + 2 + [...hint.keys].length, 0) +
|
|
3500
3938
|
(hints.length - 1) * 3;
|
|
@@ -3601,18 +4039,29 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
3601
4039
|
const { exit, suspendTerminal } = useApp();
|
|
3602
4040
|
const [activeIndex, setActiveIndex] = useState(runtime.initialTabIndex);
|
|
3603
4041
|
const [preferencePath] = useState(() => widthPreferencesPath());
|
|
3604
|
-
const [
|
|
3605
|
-
|
|
3606
|
-
);
|
|
4042
|
+
const [loadedWidthPreferences] = useState(() => loadWidthPreferences(preferencePath));
|
|
4043
|
+
const [widthOverrides, setWidthOverrides] = useState(() => loadedWidthPreferences.preferences);
|
|
3607
4044
|
const widthOverridesRef = useRef(widthOverrides);
|
|
3608
4045
|
widthOverridesRef.current = widthOverrides;
|
|
4046
|
+
const widthPersistedRef = useRef(loadedWidthPreferences.preferences);
|
|
3609
4047
|
const [widthSaveError, setWidthSaveError] = useState(null);
|
|
3610
4048
|
const widthPreferencesMountedRef = useRef(true);
|
|
3611
4049
|
const [widthPreferenceWriter] = useState(() =>
|
|
3612
4050
|
createWidthPreferenceWriter({
|
|
3613
|
-
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
|
+
},
|
|
3614
4058
|
onResult: (result) => {
|
|
3615
4059
|
if (!widthPreferencesMountedRef.current) return;
|
|
4060
|
+
if (result?.ok === true) {
|
|
4061
|
+
setWidthOverrides((current) =>
|
|
4062
|
+
samePersistedValue(current, result.persisted) ? current : result.persisted,
|
|
4063
|
+
);
|
|
4064
|
+
}
|
|
3616
4065
|
setWidthSaveError(
|
|
3617
4066
|
result?.ok === false
|
|
3618
4067
|
? (result.error ?? new Error("Width preferences could not be saved"))
|
|
@@ -3642,10 +4091,18 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
3642
4091
|
);
|
|
3643
4092
|
const [loadedCache] = useState(() => loadDashboardCache(cachePath, cacheTarget));
|
|
3644
4093
|
const dashboardCacheRef = useRef(loadedCache.cache);
|
|
4094
|
+
const dashboardCachePersistedRef = useRef(loadedCache.cache);
|
|
3645
4095
|
const dashboardCacheTargetRef = useRef(cacheTarget);
|
|
3646
4096
|
const [dashboardCacheWriter] = useState(() =>
|
|
3647
4097
|
createCoalescedWriter({
|
|
3648
|
-
write: (cache) =>
|
|
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
|
+
},
|
|
3649
4106
|
}),
|
|
3650
4107
|
);
|
|
3651
4108
|
useEffect(() => () => {
|
|
@@ -3660,13 +4117,21 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
3660
4117
|
const [data, setData] = useState(() =>
|
|
3661
4118
|
Object.fromEntries(TABS.map((candidate) => [candidate.key, cachedEntry?.tabs[candidate.key]?.data ?? null])),
|
|
3662
4119
|
);
|
|
4120
|
+
const dataRef = useRef(data);
|
|
4121
|
+
dataRef.current = data;
|
|
3663
4122
|
const [meta, setMeta] = useState(() =>
|
|
3664
4123
|
Object.fromEntries(TABS.map((candidate) => [candidate.key, cachedEntry?.tabs[candidate.key]?.meta ?? null])),
|
|
3665
4124
|
);
|
|
4125
|
+
const metaRef = useRef(meta);
|
|
4126
|
+
metaRef.current = meta;
|
|
3666
4127
|
const [securityNotes, setSecurityNotes] = useState(() => cachedEntry?.securityNotes ?? []);
|
|
4128
|
+
const securityNotesRef = useRef(securityNotes);
|
|
4129
|
+
securityNotesRef.current = securityNotes;
|
|
3667
4130
|
// Whether the Security tab is currently unable to see its endpoints, as
|
|
3668
4131
|
// opposed to seeing that they are switched off. Drives the count marker.
|
|
3669
4132
|
const [securityBlind, setSecurityBlind] = useState(() => cachedEntry?.securityBlind ?? false);
|
|
4133
|
+
const securityBlindRef = useRef(securityBlind);
|
|
4134
|
+
securityBlindRef.current = securityBlind;
|
|
3670
4135
|
// The `?` overlay. Renders only on a keypress, so consecutive idle frames are
|
|
3671
4136
|
// still byte-identical and the redraw suppression is untouched.
|
|
3672
4137
|
const [showHelp, setShowHelp] = useState(false);
|
|
@@ -3804,13 +4269,16 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
3804
4269
|
});
|
|
3805
4270
|
|
|
3806
4271
|
useEffect(() => {
|
|
3807
|
-
if (!interactive)
|
|
4272
|
+
if (!shouldEnableMouseReporting({ interactive, widthMode })) {
|
|
4273
|
+
disableMouseReporting();
|
|
4274
|
+
return;
|
|
4275
|
+
}
|
|
3808
4276
|
enableMouseReporting();
|
|
3809
4277
|
return () => {
|
|
3810
4278
|
dragRef.current = null;
|
|
3811
4279
|
disableMouseReporting();
|
|
3812
4280
|
};
|
|
3813
|
-
}, [interactive]);
|
|
4281
|
+
}, [interactive, widthMode]);
|
|
3814
4282
|
|
|
3815
4283
|
function applyWidthOverrides(next) {
|
|
3816
4284
|
const current = widthOverridesRef.current;
|
|
@@ -3923,7 +4391,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
3923
4391
|
|
|
3924
4392
|
const geometry = resizeRef.current;
|
|
3925
4393
|
if (event.action === "press") {
|
|
3926
|
-
if (!interactive || !geometry.fullHeaderVisible || geometry.compact) return;
|
|
4394
|
+
if (!interactive || !geometry.active || !geometry.fullHeaderVisible || geometry.compact) return;
|
|
3927
4395
|
const metrics = measuredHeader();
|
|
3928
4396
|
if (!metrics) return;
|
|
3929
4397
|
const drag = beginDividerDrag({
|
|
@@ -4022,24 +4490,22 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
4022
4490
|
// rather than by tab so that moving to a different row and opening it
|
|
4023
4491
|
// immediately still works -- selection does not move on Enter, so a second
|
|
4024
4492
|
// press within the window is always a duplicate of the first.
|
|
4025
|
-
const
|
|
4493
|
+
const [openRequests] = useState(() => createOpenRequestRegistry());
|
|
4494
|
+
useEffect(() => () => openRequests.abortAll(), [openRequests]);
|
|
4026
4495
|
|
|
4027
4496
|
function openSelected() {
|
|
4028
4497
|
const { items, key: currentKey, tabKey } = navRef.current;
|
|
4029
4498
|
const item = items.find((i) => itemKey(i) === currentKey);
|
|
4030
4499
|
if (!item) return;
|
|
4031
4500
|
const guard = `${tabKey}:${itemKey(item)}`;
|
|
4032
|
-
if (openingRef.current[guard]) return;
|
|
4033
|
-
openingRef.current[guard] = true;
|
|
4034
4501
|
// Fire and forget: a browser launch must not block the render loop, and a
|
|
4035
4502
|
// failure surfaces through the tab's normal error line rather than as an
|
|
4036
4503
|
// unhandled rejection.
|
|
4037
|
-
|
|
4038
|
-
.
|
|
4504
|
+
openRequests
|
|
4505
|
+
.start(guard, ({ signal }) => openInBrowser(tabKey, item, signal))
|
|
4506
|
+
?.catch((err) => {
|
|
4507
|
+
if (err?.name === "AbortError") return;
|
|
4039
4508
|
setErrors((x) => ({ ...x, [tabKey]: textTabError(err) }));
|
|
4040
|
-
})
|
|
4041
|
-
.finally(() => {
|
|
4042
|
-
delete openingRef.current[guard];
|
|
4043
4509
|
});
|
|
4044
4510
|
}
|
|
4045
4511
|
|
|
@@ -4054,6 +4520,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
4054
4520
|
return;
|
|
4055
4521
|
}
|
|
4056
4522
|
if (input === "q" || (input === "c" && key.ctrl)) {
|
|
4523
|
+
openRequests.abortAll();
|
|
4057
4524
|
flushWidthPreferences();
|
|
4058
4525
|
exit();
|
|
4059
4526
|
} else if (resizeRef.current.active) {
|
|
@@ -4076,6 +4543,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
4076
4543
|
// Width mode owns every other key. In particular, digits, arrows, Tab,
|
|
4077
4544
|
// Enter and r must never fall through to their ordinary meanings.
|
|
4078
4545
|
} else if (key.escape) {
|
|
4546
|
+
openRequests.abortAll();
|
|
4079
4547
|
flushWidthPreferences();
|
|
4080
4548
|
exit();
|
|
4081
4549
|
} else if (showHelpRef.current) {
|
|
@@ -4196,19 +4664,30 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
4196
4664
|
// a Promise.allSettled barrier. Actions is by far the slowest fetch, so
|
|
4197
4665
|
// barrelling everything together meant the three fast tabs sat invisible
|
|
4198
4666
|
// behind it and nothing at all appeared until the slowest call returned.
|
|
4199
|
-
|
|
4667
|
+
let securityUnchangedPolls = 0;
|
|
4668
|
+
let securityNextPollAt = 0;
|
|
4669
|
+
|
|
4670
|
+
function commit(key, run, { force = false } = {}) {
|
|
4200
4671
|
// Per-tab rather than one flag for the whole tick, so switching tabs can
|
|
4201
4672
|
// refresh the tab you just landed on without waiting on an unrelated
|
|
4202
4673
|
// background fetch -- and so a slow repo can't stack refreshes.
|
|
4203
4674
|
if (inFlightRef.current[key]) return Promise.resolve();
|
|
4204
4675
|
inFlightRef.current[key] = true;
|
|
4205
|
-
|
|
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 }));
|
|
4206
4684
|
return run()
|
|
4207
4685
|
.then((result) => {
|
|
4208
4686
|
// Billed before the `cancelled` early return: the requests were made
|
|
4209
4687
|
// and counted by GitHub whether or not this process still wants the
|
|
4210
4688
|
// answer.
|
|
4211
|
-
|
|
4689
|
+
spentTotal.core += result?.restSpent ?? 0;
|
|
4690
|
+
spentTotal.graphql += result?.graphqlSpent ?? 0;
|
|
4212
4691
|
if (cancelled) return;
|
|
4213
4692
|
const { raw, parse, limit } = result;
|
|
4214
4693
|
// Identical payload: skip the parse *and* the state update. Returning
|
|
@@ -4231,48 +4710,77 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
4231
4710
|
// would allocate a new object every tick and permanently defeat the
|
|
4232
4711
|
// React bail-out this early return exists to preserve.
|
|
4233
4712
|
const completedAt = Date.now();
|
|
4234
|
-
|
|
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") {
|
|
4235
4732
|
lastOkRef.current[key] = completedAt;
|
|
4236
4733
|
// Clear on the first success or a single failure latches the ladder.
|
|
4237
4734
|
clearBackoff(`tab:${key}`);
|
|
4238
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
|
+
}
|
|
4239
4755
|
return;
|
|
4240
4756
|
}
|
|
4241
|
-
const value = parse();
|
|
4242
4757
|
// Security fetches resolve each source independently so their notes
|
|
4243
4758
|
// remain visible. A blind result is still a failed observation: it
|
|
4244
4759
|
// must not replace known alerts with a false empty state or advance
|
|
4245
4760
|
// freshness. Do not retain its raw value either, so the next source
|
|
4246
4761
|
// retry is parsed instead of taking the identical-payload fast path.
|
|
4247
|
-
if (
|
|
4248
|
-
rawRef.current[key] =
|
|
4762
|
+
if (transition.kind === "blind") {
|
|
4763
|
+
rawRef.current[key] = transition.nextRaw;
|
|
4249
4764
|
setErrors((x) => (x[key] === null ? x : { ...x, [key]: null }));
|
|
4250
|
-
setSecurityNotes(
|
|
4765
|
+
setSecurityNotes(transition.notes);
|
|
4251
4766
|
setSecurityBlind(true);
|
|
4252
4767
|
return;
|
|
4253
4768
|
}
|
|
4254
|
-
const tabData =
|
|
4255
|
-
const tabMeta =
|
|
4256
|
-
at: completedAt,
|
|
4257
|
-
// Compare against the limit this payload was actually fetched
|
|
4258
|
-
// with. Comparing against the live pane-height ref made the "n+"
|
|
4259
|
-
// marker lie for one cycle after every resize.
|
|
4260
|
-
truncated: value.truncated ?? tabData.length >= limit,
|
|
4261
|
-
};
|
|
4769
|
+
const tabData = transition.data;
|
|
4770
|
+
const tabMeta = transition.meta;
|
|
4262
4771
|
lastOkRef.current[key] = completedAt;
|
|
4263
4772
|
clearBackoff(`tab:${key}`);
|
|
4264
|
-
rawRef.current[key] =
|
|
4265
|
-
key === "security" ? nextSecurityRaw(rawRef.current[key], raw, false) : raw;
|
|
4773
|
+
rawRef.current[key] = transition.nextRaw;
|
|
4266
4774
|
setErrors((x) => (x[key] === null ? x : { ...x, [key]: null }));
|
|
4267
4775
|
setData((d) => ({ ...d, [key]: tabData }));
|
|
4268
4776
|
setMeta((m) => ({ ...m, [key]: tabMeta }));
|
|
4269
|
-
if (
|
|
4777
|
+
if (transition.notes) setSecurityNotes(transition.notes);
|
|
4270
4778
|
if (key === "security") {
|
|
4271
|
-
setSecurityBlind((b) => (b ===
|
|
4779
|
+
setSecurityBlind((b) => (b === transition.blind ? b : transition.blind));
|
|
4272
4780
|
}
|
|
4273
4781
|
cacheSuccessfulTab(key, tabData, tabMeta, completedAt, {
|
|
4274
|
-
notes:
|
|
4275
|
-
blind:
|
|
4782
|
+
notes: transition.notes,
|
|
4783
|
+
blind: transition.blind,
|
|
4276
4784
|
});
|
|
4277
4785
|
})
|
|
4278
4786
|
.catch((err) => {
|
|
@@ -4280,7 +4788,8 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
4280
4788
|
// here; bill the tab's table cost instead. A request that failed after
|
|
4281
4789
|
// reaching GitHub still counted, and over-billing by an aborted call
|
|
4282
4790
|
// errs toward throttling harder, which is the safe direction.
|
|
4283
|
-
|
|
4791
|
+
spentTotal.core += REST_PER_FETCH[key] ?? 0;
|
|
4792
|
+
spentTotal.graphql += GRAPHQL_PER_FETCH[key] ?? 0;
|
|
4284
4793
|
if (cancelled || err?.name === "AbortError") return;
|
|
4285
4794
|
// Preserve both the verdict and the bounded raw error in state. The
|
|
4286
4795
|
// renderer translates recognized verdicts at draw time, which lets a
|
|
@@ -4300,7 +4809,9 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
4300
4809
|
})
|
|
4301
4810
|
.finally(() => {
|
|
4302
4811
|
inFlightRef.current[key] = false;
|
|
4303
|
-
if (!cancelled)
|
|
4812
|
+
if (!cancelled) {
|
|
4813
|
+
setLoading((l) => (l[key] ? { ...l, [key]: false } : l));
|
|
4814
|
+
}
|
|
4304
4815
|
});
|
|
4305
4816
|
}
|
|
4306
4817
|
|
|
@@ -4311,12 +4822,17 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
4311
4822
|
function fetchTab(key, { force = false } = {}) {
|
|
4312
4823
|
const signal = controller.signal;
|
|
4313
4824
|
const descriptor = tabForKey(key);
|
|
4314
|
-
|
|
4315
|
-
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();
|
|
4316
4828
|
// Every tab carries its own fetcher on the registry, so adding a tab is a
|
|
4317
4829
|
// TABS entry plus a fetcher rather than an entry plus an edit to a chain
|
|
4318
4830
|
// in a different part of the file.
|
|
4319
|
-
return commit(
|
|
4831
|
+
return commit(
|
|
4832
|
+
key,
|
|
4833
|
+
() => descriptor.fetch({ signal, runLimit: runLimitRef.current }),
|
|
4834
|
+
{ force },
|
|
4835
|
+
);
|
|
4320
4836
|
}
|
|
4321
4837
|
fetchTabRef.current = fetchTab;
|
|
4322
4838
|
|
|
@@ -4335,8 +4851,9 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
4335
4851
|
// being a minute behind -- so they refresh every BACKGROUND_EVERY ticks
|
|
4336
4852
|
// instead, which is what keeps steady-state API usage off the rate limit.
|
|
4337
4853
|
const active = TABS[activeIndexRef.current].key;
|
|
4338
|
-
const
|
|
4339
|
-
|
|
4854
|
+
const planned = pollTickKeys({ ticks, activeKey: active });
|
|
4855
|
+
const due = planned.due;
|
|
4856
|
+
ticks = planned.nextTicks;
|
|
4340
4857
|
// Mapped explicitly rather than passing fetchTab by reference: Array.map
|
|
4341
4858
|
// hands the callback an index as its second argument, which would land in
|
|
4342
4859
|
// the options bag and make every background tab look force-refreshed.
|
|
@@ -4362,8 +4879,9 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
4362
4879
|
// The open probe window, and the last share it was able to measure. Both are
|
|
4363
4880
|
// carried by nextProbeWindow/inferShare rather than reset every probe -- see
|
|
4364
4881
|
// those functions for why a throttled pane must not re-measure from scratch.
|
|
4365
|
-
|
|
4366
|
-
|
|
4882
|
+
const probeWindows = { core: null, graphql: null };
|
|
4883
|
+
const lastShares = { core: 1, graphql: 1 };
|
|
4884
|
+
let resourceTargets = { core: runtime.refreshMs, graphql: runtime.refreshMs };
|
|
4367
4885
|
|
|
4368
4886
|
function rearm(ms) {
|
|
4369
4887
|
appliedMs = ms;
|
|
@@ -4377,23 +4895,28 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
4377
4895
|
try {
|
|
4378
4896
|
// `controller` is this effect's own AbortController, so a probe still in
|
|
4379
4897
|
// flight at unmount is killed along with everything else.
|
|
4380
|
-
const
|
|
4381
|
-
if (cancelled || !
|
|
4382
|
-
const
|
|
4383
|
-
|
|
4384
|
-
|
|
4385
|
-
|
|
4386
|
-
|
|
4387
|
-
|
|
4388
|
-
|
|
4389
|
-
|
|
4390
|
-
|
|
4391
|
-
|
|
4392
|
-
|
|
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,
|
|
4393
4915
|
floorMs: runtime.refreshMs,
|
|
4394
4916
|
nowMs: Date.now(),
|
|
4395
|
-
restPerTick: restPerTick(TABS[activeIndexRef.current].key),
|
|
4396
4917
|
});
|
|
4918
|
+
resourceTargets = nextTargets.targets;
|
|
4919
|
+
const target = nextTargets.targetMs;
|
|
4397
4920
|
if (adaptiveChangeWorthApplying(appliedMs, target)) {
|
|
4398
4921
|
rearm(target);
|
|
4399
4922
|
setThrottleMs(target > runtime.refreshMs ? target : null);
|
|
@@ -4425,13 +4948,12 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
4425
4948
|
fetchTabRef.current?.(TABS[activeIndex].key);
|
|
4426
4949
|
}, [activeIndex]);
|
|
4427
4950
|
|
|
4428
|
-
// Animate only when something on screen is genuinely moving.
|
|
4429
|
-
//
|
|
4430
|
-
//
|
|
4431
|
-
//
|
|
4432
|
-
//
|
|
4433
|
-
//
|
|
4434
|
-
// 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.
|
|
4435
4957
|
// "Never resolved" and "never *succeeded*" are different states, and conflating
|
|
4436
4958
|
// them pinned the spinner on forever. setData is only ever called on success,
|
|
4437
4959
|
// so a tab whose fetch keeps failing -- offline laptop, VPN down, expired auth,
|
|
@@ -4471,7 +4993,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
4471
4993
|
}, [anyFirstLoad, iconHintDue]);
|
|
4472
4994
|
|
|
4473
4995
|
const items = data[tab.key];
|
|
4474
|
-
const displayError =
|
|
4996
|
+
const displayError = formatTabErrorForWidth(tabError, failureContext, Math.max(1, cols - 5));
|
|
4475
4997
|
const spin = SPINNER[frame % SPINNER.length];
|
|
4476
4998
|
|
|
4477
4999
|
const counts = Object.fromEntries(
|
|
@@ -4479,9 +5001,9 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
4479
5001
|
const list = data[t.key];
|
|
4480
5002
|
if (list == null) return [t.key, null];
|
|
4481
5003
|
// Every tab can be truncated, not just Actions: issues and PRs cap at
|
|
4482
|
-
// LIST_LIMIT and
|
|
4483
|
-
//
|
|
4484
|
-
//
|
|
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.
|
|
4485
5007
|
const suffix = meta[t.key]?.truncated ? "+" : "";
|
|
4486
5008
|
// A blind Security tab reports "?" rather than a number it cannot stand
|
|
4487
5009
|
// behind. Zero alerts and zero visibility look identical otherwise.
|
|
@@ -4531,11 +5053,18 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
4531
5053
|
|
|
4532
5054
|
const allItems = items ?? [];
|
|
4533
5055
|
const tabOffsetRaw = offset[tab.key] ?? 0;
|
|
5056
|
+
const selectedKey = selected[tab.key] ?? null;
|
|
4534
5057
|
// Re-clamped on every render rather than only on resize: the payload can
|
|
4535
5058
|
// shrink under us between ticks, and a stale offset would render an empty
|
|
4536
5059
|
// body while the count in the frame said otherwise.
|
|
4537
5060
|
const maxOffset = Math.max(0, allItems.length - bodyRows);
|
|
4538
|
-
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;
|
|
4539
5068
|
const visibleItems = allItems.slice(tabOffset, tabOffset + bodyRows);
|
|
4540
5069
|
|
|
4541
5070
|
// Matched by key, never by position. If the selected item is gone -- closed,
|
|
@@ -4543,7 +5072,22 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
4543
5072
|
// highlighted, which is the honest state; the next arrow key selects from the
|
|
4544
5073
|
// top again. Resolving to a neighbouring index instead would silently move
|
|
4545
5074
|
// the cursor onto an unrelated row.
|
|
4546
|
-
|
|
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]);
|
|
4547
5091
|
// Bottom-right of the frame, lazygit style: how much of the tab you can
|
|
4548
5092
|
// currently see out of how much there is.
|
|
4549
5093
|
const countLabel =
|
|
@@ -4702,8 +5246,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
4702
5246
|
// truncate-end so it degrades rather than wraps in a narrow pane. Every
|
|
4703
5247
|
// glyph in KEY_TABLE is plain ASCII, which is what keeps it safe here.
|
|
4704
5248
|
showHelp &&
|
|
4705
|
-
|
|
4706
|
-
.slice(0, bodyRows)
|
|
5249
|
+
helpLines(bodyRows)
|
|
4707
5250
|
.map((line, i) => e(Text, { key: `help${i}`, wrap: "truncate-end" }, line)),
|
|
4708
5251
|
!showHelp &&
|
|
4709
5252
|
tooNarrow &&
|
|
@@ -4749,7 +5292,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
4749
5292
|
const key = itemKey(item);
|
|
4750
5293
|
return e(
|
|
4751
5294
|
RowBoundary,
|
|
4752
|
-
{ key, resetKey:
|
|
5295
|
+
{ key, resetKey: item },
|
|
4753
5296
|
e(tab.Row, {
|
|
4754
5297
|
item,
|
|
4755
5298
|
now,
|
|
@@ -4769,8 +5312,8 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
4769
5312
|
// Distinguishes "still fetching" from "resolved and empty" in the body,
|
|
4770
5313
|
// which is the difference between a dashboard that looks hung and one
|
|
4771
5314
|
// that looks correct. Driven by "never resolved" rather than by the
|
|
4772
|
-
// loading flag, which
|
|
4773
|
-
// 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
|
|
4774
5317
|
// an error and has never resolved: the error line directly above already
|
|
4775
5318
|
// says what happened, and "no runs" underneath it reads as a fact about the
|
|
4776
5319
|
// repository rather than the absence of an answer.
|
|
@@ -4812,6 +5355,12 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
4812
5355
|
widthMode: resizeRef.current.active,
|
|
4813
5356
|
widthColumn: selectedWidthColumn,
|
|
4814
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,
|
|
4815
5364
|
}),
|
|
4816
5365
|
);
|
|
4817
5366
|
}
|
|
@@ -5071,6 +5620,7 @@ export {
|
|
|
5071
5620
|
REFRESH_MS,
|
|
5072
5621
|
BACKGROUND_EVERY,
|
|
5073
5622
|
adaptiveRefreshMs,
|
|
5623
|
+
nextBudgetTargets,
|
|
5074
5624
|
adaptiveChangeWorthApplying,
|
|
5075
5625
|
sampleIsUsable,
|
|
5076
5626
|
inferShare,
|
|
@@ -5095,6 +5645,7 @@ export {
|
|
|
5095
5645
|
failureTargetHost,
|
|
5096
5646
|
unavailableRemedy,
|
|
5097
5647
|
createFailureContextCoordinator,
|
|
5648
|
+
createOpenRequestRegistry,
|
|
5098
5649
|
AUTH_RETRY_MS,
|
|
5099
5650
|
BACKOFF_STEPS_MS,
|
|
5100
5651
|
redact,
|
|
@@ -5122,13 +5673,27 @@ export {
|
|
|
5122
5673
|
loadWidthPreferences,
|
|
5123
5674
|
saveWidthPreferences,
|
|
5124
5675
|
createWidthPreferenceWriter,
|
|
5676
|
+
mergeWidthPreferenceSnapshots,
|
|
5677
|
+
adoptPersistedSnapshot,
|
|
5125
5678
|
DASHBOARD_CACHE_VERSION,
|
|
5126
5679
|
dashboardCachePath,
|
|
5127
5680
|
dashboardCacheTarget,
|
|
5681
|
+
authCacheIdentity,
|
|
5128
5682
|
serializeDashboardCache,
|
|
5129
5683
|
loadDashboardCache,
|
|
5130
5684
|
saveDashboardCache,
|
|
5685
|
+
mergeDashboardCacheSnapshots,
|
|
5131
5686
|
nextSecurityRaw,
|
|
5687
|
+
shouldCheckpointFreshness,
|
|
5688
|
+
securityPollDelay,
|
|
5689
|
+
shouldShowFetchLoading,
|
|
5690
|
+
pollTickKeys,
|
|
5691
|
+
pollResultTransition,
|
|
5692
|
+
forcedBackoffKeys,
|
|
5693
|
+
alertRequestArgs,
|
|
5694
|
+
shouldFetchAlertPriorityLanes,
|
|
5695
|
+
mergeAlertRows,
|
|
5696
|
+
reconcileSelectionViewport,
|
|
5132
5697
|
runStatusIcon,
|
|
5133
5698
|
RUN_STATUS_ICON,
|
|
5134
5699
|
SEVERITY_STYLE,
|
|
@@ -5140,6 +5705,12 @@ export {
|
|
|
5140
5705
|
OCT_UNICODE,
|
|
5141
5706
|
KEY_TABLE,
|
|
5142
5707
|
KEY_HINTS,
|
|
5708
|
+
activeKeyHints,
|
|
5709
|
+
tabFailureSuffix,
|
|
5710
|
+
formatTabErrorForWidth,
|
|
5711
|
+
selectionLabel,
|
|
5712
|
+
helpLines,
|
|
5713
|
+
summarizeDoctorEnv,
|
|
5143
5714
|
widthStatusText,
|
|
5144
5715
|
headerGutterKey,
|
|
5145
5716
|
HeaderCells,
|
|
@@ -5148,7 +5719,9 @@ export {
|
|
|
5148
5719
|
hitDivider,
|
|
5149
5720
|
beginDividerDrag,
|
|
5150
5721
|
draggedWidth,
|
|
5722
|
+
shouldEnableMouseReporting,
|
|
5151
5723
|
createTerminalLifecycle,
|
|
5724
|
+
RowBoundary,
|
|
5152
5725
|
REMOTE_SETUP_HINTS,
|
|
5153
5726
|
REMOTE_SETUP_LINES,
|
|
5154
5727
|
REMOTE_SETUP_NONINTERACTIVE_LINES,
|