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.
Files changed (4) hide show
  1. package/CHANGELOG.md +68 -1
  2. package/README.md +65 -51
  3. package/index.mjs +759 -186
  4. 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 capped at one
101
- // page. The previous --paginate walked the repo's entire alert history --
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
- // Ordering is pinned rather than left to each endpoint's default. Severity
105
- // sorting happens client-side over whatever this returns, so on a repo with more
106
- // than a page of open alerts the *page boundary* decides what can be ranked at
107
- // all -- a critical alert sitting 150th by some endpoint's arbitrary default was
108
- // simply never fetched, and the pane showed "100+" with a screen of moderates.
109
- // Newest-first at least makes the cut deterministic and explainable, and it
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 REST_PER_FETCH = { actions: 2, issues: 0, prs: 0, security: ALERT_SOURCES.length };
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.myRestCalls >= MIN_SAMPLE_CALLS && sample.globalUsed > 0;
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.myRestCalls)
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
- myRestCalls: spentTotal - prev.spent,
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
- let cost = REST_PER_FETCH[activeKey] ?? 0;
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 += (REST_PER_FETCH[key] ?? 0) / BACKGROUND_EVERY;
1140
+ if (key !== activeKey) cost += (table[key] ?? 0) / BACKGROUND_EVERY;
1108
1141
  }
1109
1142
  return cost;
1110
1143
  }
1111
1144
 
1112
- function alertArgs(source) {
1113
- return ["api", apiPath(source.path), ...apiHostArgs(), "--jq", source.jq];
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, in the
1122
- // same spirit as alertBackoff above: lost on exit, and nothing depends on it
1123
- // surviving. Only ever compared against itself between two budget probes, so it
1124
- // needs no windowing and cannot overflow in any realistic session.
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
- let restSpentTotal = 0;
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 raw = await runGh(alertArgs(source), { signal });
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: 1,
1280
+ spent,
1182
1281
  parse: () => {
1183
- const rows = JSON.parse(raw).filter((a) => a.state === "open");
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: rows.length >= ALERT_PER_PAGE,
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: 1,
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 it is around 2,280 REST
1487
- // requests an hour -- about 46% of a personal token's 5,000 -- and `--refresh 2`
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 `core` as data rather than a
1518
- // sentence, because the adaptive loop has to do arithmetic on it. rateBudget
1519
- // keeps its own shape: it formats `graphql` too, which the controller does not
1520
- // need, and it is on the --doctor path where a string is the product.
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
- async function readCoreBudget(signal) {
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 core = JSON.parse(raw)?.resources?.core;
1533
- if (!core || !Number.isFinite(core.remaining)) return null;
1702
+ const resources = JSON.parse(raw)?.resources;
1534
1703
  return {
1535
- remaining: core.remaining,
1536
- limit: core.limit,
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
- let rest = 0;
1556
- let graphql = 0;
1557
- // TAB_KEYS rather than TABS: this runs on the --doctor path, which deliberately
1558
- // returns before the render tree is reached, and TABS is declared down there.
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.map((source) => [source.name, alertArgs(source)]),
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 six. runGh's own GH_TIMEOUT_MS
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` }, `${cursor ? ">" : " "}${OCT.shield}`),
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
- widthPreferenceTempSequence += 1;
2964
- tempPath = `${path}.${process.pid}.${Date.now()}.${widthPreferenceTempSequence}.tmp`;
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
- unlinkSync(tempPath);
2976
- } catch {
2977
- // The write may have failed before creating it. Cleanup is best effort
2978
- // and deliberately targets only this operation's exact temporary file.
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 = 1;
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({ repo = null, ghRepo = null, host = null, cwd = process.cwd() } = {}) {
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
- dashboardCacheTempSequence += 1;
3283
- tempPath = `${path}.${process.pid}.${Date.now()}.${dashboardCacheTempSequence}.tmp`;
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
- unlinkSync(tempPath);
3295
- } catch {
3296
- // The write may have failed before creating this exact temporary file.
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
- : ` (${count}${brokenCI[tab.key] ? "!" : ""})`;
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 [widthOverrides, setWidthOverrides] = useState(
3605
- () => loadWidthPreferences(preferencePath).preferences,
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) => saveWidthPreferences(preferencePath, 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) => saveDashboardCache(cachePath, cache, { normalized: true }),
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) return;
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 openingRef = useRef({});
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
- openInBrowser(tabKey, item)
4038
- .catch((err) => {
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
- function commit(key, run) {
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
- setLoading((l) => (l[key] ? l : { ...l, [key]: true }));
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
- restSpentTotal += result?.restSpent ?? 0;
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
- if (rawRef.current[key] === raw) {
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 (key === "security" && value.blind) {
4248
- rawRef.current[key] = nextSecurityRaw(rawRef.current[key], raw, true);
4762
+ if (transition.kind === "blind") {
4763
+ rawRef.current[key] = transition.nextRaw;
4249
4764
  setErrors((x) => (x[key] === null ? x : { ...x, [key]: null }));
4250
- setSecurityNotes(value.notes ?? []);
4765
+ setSecurityNotes(transition.notes);
4251
4766
  setSecurityBlind(true);
4252
4767
  return;
4253
4768
  }
4254
- const tabData = value.alerts ?? value;
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 (value.notes) setSecurityNotes(value.notes);
4777
+ if (transition.notes) setSecurityNotes(transition.notes);
4270
4778
  if (key === "security") {
4271
- setSecurityBlind((b) => (b === Boolean(value.blind) ? b : Boolean(value.blind)));
4779
+ setSecurityBlind((b) => (b === transition.blind ? b : transition.blind));
4272
4780
  }
4273
4781
  cacheSuccessfulTab(key, tabData, tabMeta, completedAt, {
4274
- notes: value.notes,
4275
- blind: value.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
- restSpentTotal += REST_PER_FETCH[key] ?? 0;
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) setLoading((l) => (l[key] ? { ...l, [key]: false } : l));
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
- if (!force && backoffActive(`tab:${key}`, performance.now())) return Promise.resolve();
4315
- if (force) clearBackoff(`tab:${key}`);
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(key, () => descriptor.fetch({ signal, runLimit: runLimitRef.current }));
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 due = ticks % BACKGROUND_EVERY === 0 ? TABS.map((t) => t.key) : [active];
4339
- ticks += 1;
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
- let probeWindow = null;
4366
- let lastShare = 1;
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 budget = await readCoreBudget(controller.signal);
4381
- if (cancelled || !budget) return;
4382
- const { sample, next } = nextProbeWindow(probeWindow, budget, restSpentTotal);
4383
- probeWindow = next;
4384
- // Updated before the law reads it. inferShare is idempotent, so passing
4385
- // the already-updated value cannot change the answer -- it just keeps
4386
- // one variable holding the current share rather than two.
4387
- lastShare = inferShare(sample, lastShare);
4388
-
4389
- const target = adaptiveRefreshMs({
4390
- budget,
4391
- sample,
4392
- lastShare,
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. `loading` flips
4429
- // true then false on *every* tick, including one that changed nothing, so
4430
- // driving the spinner from it meant the 100ms interval restarted every 5
4431
- // seconds forever -- roughly 44MB of terminal writes and 27 CPU-seconds per
4432
- // idle hour, and the exact opposite of the redraw suppression above. The
4433
- // first load genuinely is worth animating, because an empty pane with no
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 = formatTabError(tabError, failureContext);
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 alerts at one page of 100. Reporting those as exact made
4483
- // the count stop moving through a genuine change on any repo big enough
4484
- // for this tool to matter.
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 tabOffset = Math.min(tabOffsetRaw, maxOffset);
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
- const selectedKey = selected[tab.key] ?? null;
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
- keyTableLines()
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: key },
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 toggles on every tick and made a settled-empty tab
4773
- // swap its message every five seconds. Suppressed entirely when a tab has
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,