gh-glance 0.6.1 → 0.8.0
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 +76 -0
- package/README.md +144 -28
- package/index.mjs +1911 -184
- package/package.json +1 -1
package/index.mjs
CHANGED
|
@@ -23,7 +23,17 @@
|
|
|
23
23
|
process.env.NODE_ENV ??= "production";
|
|
24
24
|
|
|
25
25
|
import { execFile, spawn } from "node:child_process";
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
chmodSync,
|
|
28
|
+
mkdirSync,
|
|
29
|
+
readFileSync,
|
|
30
|
+
realpathSync,
|
|
31
|
+
renameSync,
|
|
32
|
+
unlinkSync,
|
|
33
|
+
writeFileSync,
|
|
34
|
+
} from "node:fs";
|
|
35
|
+
import { homedir } from "node:os";
|
|
36
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
27
37
|
import { fileURLToPath } from "node:url";
|
|
28
38
|
import { promisify } from "node:util";
|
|
29
39
|
|
|
@@ -108,6 +118,8 @@ const ALERT_QUERY = `?state=open&per_page=${ALERT_PER_PAGE}&sort=created&directi
|
|
|
108
118
|
// single pane. Only the tab you are looking at needs REFRESH_MS latency; the
|
|
109
119
|
// other three exist to keep counts honest, which tolerates a minute.
|
|
110
120
|
const BACKGROUND_EVERY = 12;
|
|
121
|
+
// What one fetch of each tab costs lives in REST_PER_FETCH / GRAPHQL_PER_FETCH,
|
|
122
|
+
// declared with ALERT_SOURCES below because the security figure derives from it.
|
|
111
123
|
|
|
112
124
|
// A stalled `gh` used to wedge a tab permanently: the promise never settled, so
|
|
113
125
|
// the in-flight guard was never cleared and that tab stopped fetching for the
|
|
@@ -793,6 +805,10 @@ async function fetchActions(limit, signal) {
|
|
|
793
805
|
return {
|
|
794
806
|
raw,
|
|
795
807
|
limit,
|
|
808
|
+
// What this fetch actually cost, read from the one cost table rather than
|
|
809
|
+
// re-derived from the argv shape -- a second copy of the cost model is the
|
|
810
|
+
// drift this file warns about repeatedly.
|
|
811
|
+
restSpent: REST_PER_FETCH.actions,
|
|
796
812
|
parse: () =>
|
|
797
813
|
JSON.parse(raw).map((r) => ({
|
|
798
814
|
databaseId: r.databaseId,
|
|
@@ -838,6 +854,9 @@ async function fetchIssues(signal) {
|
|
|
838
854
|
return {
|
|
839
855
|
raw,
|
|
840
856
|
limit: LIST_LIMIT,
|
|
857
|
+
// Stated rather than omitted so the zero is visibly deliberate: SORT_RECENT's
|
|
858
|
+
// --search routes this through GraphQL, which is a separate budget.
|
|
859
|
+
restSpent: REST_PER_FETCH.issues,
|
|
841
860
|
parse: () =>
|
|
842
861
|
JSON.parse(raw).map((i) => ({
|
|
843
862
|
number: i.number,
|
|
@@ -869,6 +888,8 @@ async function fetchPRs(signal) {
|
|
|
869
888
|
return {
|
|
870
889
|
raw,
|
|
871
890
|
limit: LIST_LIMIT,
|
|
891
|
+
// Zero for the same reason as issues: --search makes this a GraphQL call.
|
|
892
|
+
restSpent: REST_PER_FETCH.prs,
|
|
872
893
|
parse: () =>
|
|
873
894
|
JSON.parse(raw).map((p) => ({
|
|
874
895
|
number: p.number,
|
|
@@ -946,6 +967,148 @@ const ALERT_SOURCES = [
|
|
|
946
967
|
},
|
|
947
968
|
];
|
|
948
969
|
|
|
970
|
+
// What one fetch of each tab costs, per budget. The single source of truth:
|
|
971
|
+
// `projectedHourlyCost` derives the hourly figure from it and the spend meter
|
|
972
|
+
// bills against it, so a corrected number cannot reach one and miss the other.
|
|
973
|
+
//
|
|
974
|
+
// actions is 2, not 1: measured 2026-08-10 with `GH_DEBUG=api`, `gh run list`
|
|
975
|
+
// issues GET /actions/runs *and* GET /actions/workflows. The 1 that stood here
|
|
976
|
+
// understated the default tab -- the tab every pane starts on, since
|
|
977
|
+
// initialTabIndex defaults to 0 -- by half.
|
|
978
|
+
//
|
|
979
|
+
// issues and prs are 0 REST because SORT_RECENT's --search routes both through
|
|
980
|
+
// 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 };
|
|
982
|
+
const GRAPHQL_PER_FETCH = { actions: 0, issues: 2, prs: 2, security: 0 };
|
|
983
|
+
|
|
984
|
+
// Ceiling on adaptive widening. An adaptive interval needs one: a minute is
|
|
985
|
+
// where the pane stops being live in any useful sense, and past it the honest
|
|
986
|
+
// answer is fewer panes, not a slower one.
|
|
987
|
+
const MAX_ADAPTIVE_REFRESH_MS = 60_000;
|
|
988
|
+
|
|
989
|
+
// Target this fraction of what the token can afford, not all of it. The margin
|
|
990
|
+
// is for the user's own `gh` and `git` commands, which are the whole reason the
|
|
991
|
+
// pane yields at all -- see RATE_LIMIT_RETRY_MS.
|
|
992
|
+
const BUDGET_SAFETY = 0.8;
|
|
993
|
+
|
|
994
|
+
// How often to re-read the budget. `gh api rate_limit` does not count against
|
|
995
|
+
// the limit (verified, delta 0 -- see rateBudget) but it is still a subprocess,
|
|
996
|
+
// so once a minute rather than once a tick: the quantity it measures moves on
|
|
997
|
+
// the scale of minutes.
|
|
998
|
+
const BUDGET_PROBE_MS = 60_000;
|
|
999
|
+
|
|
1000
|
+
// Below this many of our own REST calls in a probe window, the share inference
|
|
1001
|
+
// is noise -- one call against a global delta of one reads as "we are the only
|
|
1002
|
+
// consumer" whether or not that is true. Under the threshold the loop declines
|
|
1003
|
+
// to infer a new share rather than guessing.
|
|
1004
|
+
const MIN_SAMPLE_CALLS = 5;
|
|
1005
|
+
|
|
1006
|
+
// Only move the applied interval when the target differs by more than this
|
|
1007
|
+
// factor either way, so a budget wobbling around a boundary cannot make the
|
|
1008
|
+
// poll rate flap. Same reasoning as TAB_LABEL_HYSTERESIS.
|
|
1009
|
+
const ADAPTIVE_HYSTERESIS = 1.25;
|
|
1010
|
+
|
|
1011
|
+
// Whether a probe window holds enough of our own calls for the ratio below to
|
|
1012
|
+
// mean anything. Named rather than inlined because the control law and the loop
|
|
1013
|
+
// that feeds it must agree on the answer: the law uses it to decide whether to
|
|
1014
|
+
// infer, and the loop uses it to decide whether the window may be closed.
|
|
1015
|
+
function sampleIsUsable(sample) {
|
|
1016
|
+
return Boolean(sample) && sample.myRestCalls >= MIN_SAMPLE_CALLS && sample.globalUsed > 0;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// How many equivalent consumers are on this token, from one probe window.
|
|
1020
|
+
//
|
|
1021
|
+
// `lastShare` is what the previous measurable window said, and it is the answer
|
|
1022
|
+
// whenever this window is not measurable. Falling back to 1 instead would be the
|
|
1023
|
+
// controller starving its own input: a pane that has widened to 40s makes about
|
|
1024
|
+
// three REST calls per probe window, below MIN_SAMPLE_CALLS, so it would read
|
|
1025
|
+
// "no other consumers", snap back to the floor, spend fast enough to measure
|
|
1026
|
+
// again, and re-throttle -- a flap on a two-minute cycle at exactly the instance
|
|
1027
|
+
// counts the widening exists for. Holding the last measurement keeps the pane
|
|
1028
|
+
// where it is until a window is long enough to say otherwise.
|
|
1029
|
+
function inferShare(sample, lastShare = 1) {
|
|
1030
|
+
return sampleIsUsable(sample)
|
|
1031
|
+
? Math.max(1, sample.globalUsed / sample.myRestCalls)
|
|
1032
|
+
: Math.max(1, lastShare);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// One probe window's bookkeeping. Pure, so the flap it prevents is testable
|
|
1036
|
+
// without a timer: `prev` is `{ used, spent }` from the last window that could
|
|
1037
|
+
// be measured, or null before the first probe.
|
|
1038
|
+
//
|
|
1039
|
+
// The baseline only advances when the window was usable, so an unmeasurable
|
|
1040
|
+
// window is not thrown away -- it grows until it holds MIN_SAMPLE_CALLS of our
|
|
1041
|
+
// own calls, with both halves of the ratio still spanning the same stretch of
|
|
1042
|
+
// time. The cost is latency, not accuracy: a pane at the 60s cap needs two or
|
|
1043
|
+
// three probes rather than one to notice that the other panes have exited.
|
|
1044
|
+
function nextProbeWindow(prev, budget, spentTotal) {
|
|
1045
|
+
// A window reset makes `used` fall. The span before the reset cannot be
|
|
1046
|
+
// compared against the counter after it, so the window restarts here and this
|
|
1047
|
+
// cycle infers nothing -- the caller keeps the share it last measured, which
|
|
1048
|
+
// is the one direction that cannot spike the poll rate at the top of an hour.
|
|
1049
|
+
if (prev === null || budget.used < prev.used) {
|
|
1050
|
+
return { sample: null, next: { used: budget.used, spent: spentTotal } };
|
|
1051
|
+
}
|
|
1052
|
+
const sample = {
|
|
1053
|
+
globalUsed: budget.used - prev.used,
|
|
1054
|
+
myRestCalls: spentTotal - prev.spent,
|
|
1055
|
+
};
|
|
1056
|
+
return { sample, next: sampleIsUsable(sample) ? { used: budget.used, spent: spentTotal } : prev };
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// How wide the active-tab interval has to be for this instance to fit its share
|
|
1060
|
+
// of the token's remaining budget.
|
|
1061
|
+
//
|
|
1062
|
+
// The share is *inferred*, not configured: this instance knows exactly how many
|
|
1063
|
+
// REST calls it has spent since the last probe, and `rate_limit` reports how
|
|
1064
|
+
// many the token spent in total over the same window. The ratio is how many
|
|
1065
|
+
// equivalent consumers are on this token -- other panes, a `gh pr checks
|
|
1066
|
+
// --watch`, an agent shelling out to `gh`. That is why no IPC and no on-disk
|
|
1067
|
+
// registry is needed: the token's own counter is the shared channel, and it
|
|
1068
|
+
// already aggregates everything.
|
|
1069
|
+
//
|
|
1070
|
+
// Deliberately one-directional: the returned value is never below `floorMs`, so
|
|
1071
|
+
// --refresh and GH_GLANCE_REFRESH stay a floor the loop widens from and cannot
|
|
1072
|
+
// tighten past. A single instance on a fresh window computes a required interval
|
|
1073
|
+
// below the floor and therefore stays at exactly the configured refresh, which
|
|
1074
|
+
// is what keeps this from changing ordinary single-pane behaviour.
|
|
1075
|
+
//
|
|
1076
|
+
// MAX_ADAPTIVE_REFRESH_MS is wrapped in Math.max(floorMs, ...) at every exit, so
|
|
1077
|
+
// a user who configures a refresh wider than the cap is never silently sped up.
|
|
1078
|
+
//
|
|
1079
|
+
// `lastShare` defaults to 1, so a caller with no history behaves exactly as this
|
|
1080
|
+
// did before it existed: one instance, no adaptation.
|
|
1081
|
+
function adaptiveRefreshMs({ budget, sample, restPerTick, floorMs, nowMs, lastShare = 1 }) {
|
|
1082
|
+
if (!budget || !Number.isFinite(budget.remaining) || restPerTick <= 0) return floorMs;
|
|
1083
|
+
if (budget.remaining <= 0) return Math.max(floorMs, MAX_ADAPTIVE_REFRESH_MS);
|
|
1084
|
+
|
|
1085
|
+
const secondsToReset = Math.max(1, (budget.resetMs - nowMs) / 1000);
|
|
1086
|
+
const affordable = (budget.remaining / secondsToReset) * BUDGET_SAFETY;
|
|
1087
|
+
if (affordable <= 0) return Math.max(floorMs, MAX_ADAPTIVE_REFRESH_MS);
|
|
1088
|
+
|
|
1089
|
+
const mine = affordable / inferShare(sample, lastShare);
|
|
1090
|
+
const requiredMs = (restPerTick / mine) * 1000;
|
|
1091
|
+
return Math.min(Math.max(floorMs, requiredMs), Math.max(floorMs, MAX_ADAPTIVE_REFRESH_MS));
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// Whether a newly computed target is different enough to act on.
|
|
1095
|
+
function adaptiveChangeWorthApplying(appliedMs, targetMs) {
|
|
1096
|
+
if (appliedMs <= 0) return true;
|
|
1097
|
+
const ratio = targetMs / appliedMs;
|
|
1098
|
+
return ratio >= ADAPTIVE_HYSTERESIS || ratio <= 1 / ADAPTIVE_HYSTERESIS;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// REST cost of one tick in this configuration: the active tab every tick, the
|
|
1102
|
+
// other three amortised over BACKGROUND_EVERY. Same table and same shape as
|
|
1103
|
+
// projectedHourlyCost, so the controller and the report cannot disagree.
|
|
1104
|
+
function restPerTick(activeKey) {
|
|
1105
|
+
let cost = REST_PER_FETCH[activeKey] ?? 0;
|
|
1106
|
+
for (const key of TAB_KEYS) {
|
|
1107
|
+
if (key !== activeKey) cost += (REST_PER_FETCH[key] ?? 0) / BACKGROUND_EVERY;
|
|
1108
|
+
}
|
|
1109
|
+
return cost;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
949
1112
|
function alertArgs(source) {
|
|
950
1113
|
return ["api", apiPath(source.path), ...apiHostArgs(), "--jq", source.jq];
|
|
951
1114
|
}
|
|
@@ -955,6 +1118,18 @@ function alertArgs(source) {
|
|
|
955
1118
|
// mid-session is picked up within the hour.
|
|
956
1119
|
const alertBackoff = new Map();
|
|
957
1120
|
|
|
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.
|
|
1125
|
+
//
|
|
1126
|
+
// Deliberately incomplete: openItem, preflight and resolveFailureContext also
|
|
1127
|
+
// spend, and are not counted, because they are occasional rather than periodic.
|
|
1128
|
+
// Under-reporting our own spend makes the inferred share *larger* and so
|
|
1129
|
+
// throttles slightly harder than strictly necessary -- the safe direction to err
|
|
1130
|
+
// in, which is why the imprecision is tolerated rather than chased.
|
|
1131
|
+
let restSpentTotal = 0;
|
|
1132
|
+
|
|
958
1133
|
function backoffActive(key, now) {
|
|
959
1134
|
const state = alertBackoff.get(key);
|
|
960
1135
|
return Boolean(state) && now < state.until;
|
|
@@ -990,13 +1165,20 @@ async function fetchAlertSource(source, signal, now) {
|
|
|
990
1165
|
// The verdict is replayed alongside the note. Replaying only the note left
|
|
991
1166
|
// the tab unable to tell "Dependabot is switched off here" from "we cannot
|
|
992
1167
|
// see Dependabot" for the whole length of a backoff window.
|
|
993
|
-
|
|
1168
|
+
// Nothing was spawned, so nothing was billed -- which is the whole reason
|
|
1169
|
+
// the meter is fed by the fetchers rather than by counting ticks.
|
|
1170
|
+
return {
|
|
1171
|
+
raw: `backoff:${note}`,
|
|
1172
|
+
spent: 0,
|
|
1173
|
+
parse: () => ({ alerts: [], note, verdict, truncated: false }),
|
|
1174
|
+
};
|
|
994
1175
|
}
|
|
995
1176
|
try {
|
|
996
1177
|
const raw = await runGh(alertArgs(source), { signal });
|
|
997
1178
|
clearBackoff(source.key);
|
|
998
1179
|
return {
|
|
999
1180
|
raw,
|
|
1181
|
+
spent: 1,
|
|
1000
1182
|
parse: () => {
|
|
1001
1183
|
const rows = JSON.parse(raw).filter((a) => a.state === "open");
|
|
1002
1184
|
return {
|
|
@@ -1021,6 +1203,9 @@ async function fetchAlertSource(source, signal, now) {
|
|
|
1021
1203
|
}
|
|
1022
1204
|
return {
|
|
1023
1205
|
raw: `unavailable:${note}`,
|
|
1206
|
+
// A failed call still bills: the request reached GitHub and was counted
|
|
1207
|
+
// whether it returned data, `[]`, or a 403.
|
|
1208
|
+
spent: 1,
|
|
1024
1209
|
parse: () => ({ alerts: [], note, verdict, truncated: false }),
|
|
1025
1210
|
};
|
|
1026
1211
|
}
|
|
@@ -1054,6 +1239,10 @@ async function fetchSecurity(signal) {
|
|
|
1054
1239
|
// colliding on the same string.
|
|
1055
1240
|
raw: parts.map((p) => p.raw).join("\0"),
|
|
1056
1241
|
limit: ALERT_PER_PAGE,
|
|
1242
|
+
// Summed from what each source reported rather than assumed to be
|
|
1243
|
+
// ALERT_SOURCES.length: a source held off by backoff spawned nothing and
|
|
1244
|
+
// must not be billed for it.
|
|
1245
|
+
restSpent: parts.reduce((n, p) => n + p.spent, 0),
|
|
1057
1246
|
parse: () => {
|
|
1058
1247
|
const parsed = parts.map((p) => p.parse());
|
|
1059
1248
|
const alerts = parsed.flatMap((p) => p.alerts);
|
|
@@ -1168,6 +1357,7 @@ const DOCTOR_ENV_PLAIN = [
|
|
|
1168
1357
|
"NO_PROXY",
|
|
1169
1358
|
"GH_GLANCE_ICONS",
|
|
1170
1359
|
"GH_GLANCE_NO_ANIMATION",
|
|
1360
|
+
"GH_GLANCE_REFRESH",
|
|
1171
1361
|
"NO_COLOR",
|
|
1172
1362
|
"NODE_ENV",
|
|
1173
1363
|
];
|
|
@@ -1293,8 +1483,8 @@ function targetSource() {
|
|
|
1293
1483
|
|
|
1294
1484
|
// How much of the hourly API budget is left, and roughly how fast this
|
|
1295
1485
|
// configuration spends it. The steady-state cost is not small and was invisible:
|
|
1296
|
-
// at the default 5s refresh with the Security tab open it is around 2,
|
|
1297
|
-
// requests an hour -- about
|
|
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`
|
|
1298
1488
|
// projects past the limit outright, so it exhausts inside the hour, every hour.
|
|
1299
1489
|
// The budget is shared with everything else the token does, so the first symptom
|
|
1300
1490
|
// is usually "GitHub is broken" somewhere else entirely.
|
|
@@ -1305,7 +1495,10 @@ function targetSource() {
|
|
|
1305
1495
|
// exactly why this reports the server's own numbers rather than asserting 5,000.
|
|
1306
1496
|
async function rateBudget() {
|
|
1307
1497
|
try {
|
|
1308
|
-
|
|
1498
|
+
// Host-routed like every other `gh api` call: a budget is per token *per
|
|
1499
|
+
// server*, so on a GHES tenant the github.com numbers are not merely stale,
|
|
1500
|
+
// they belong to a different limit entirely.
|
|
1501
|
+
const raw = await runGh(["api", "rate_limit", ...apiHostArgs()]);
|
|
1309
1502
|
const { resources } = JSON.parse(raw);
|
|
1310
1503
|
// formatAge() is for timestamps in the past and returns "-" for a future one,
|
|
1311
1504
|
// so the reset is rendered as a forward interval instead.
|
|
@@ -1321,24 +1514,52 @@ async function rateBudget() {
|
|
|
1321
1514
|
}
|
|
1322
1515
|
}
|
|
1323
1516
|
|
|
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.
|
|
1521
|
+
//
|
|
1522
|
+
// Safe to run on a timer for the same reason it is safe on the diagnostic path:
|
|
1523
|
+
// `gh api rate_limit` is documented as not counting against the limit, and it
|
|
1524
|
+
// measures as free (verified: delta 0).
|
|
1525
|
+
async function readCoreBudget(signal) {
|
|
1526
|
+
try {
|
|
1527
|
+
// apiHostArgs() for the same reason the alert endpoints carry it: --repo
|
|
1528
|
+
// host/owner/name sets runtime.host without setting GH_HOST, so without the
|
|
1529
|
+
// flag this reads github.com's budget while the pane spends against the
|
|
1530
|
+
// tenant -- and then throttles, or fails to, against an unrelated number.
|
|
1531
|
+
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;
|
|
1534
|
+
return {
|
|
1535
|
+
remaining: core.remaining,
|
|
1536
|
+
limit: core.limit,
|
|
1537
|
+
used: core.used,
|
|
1538
|
+
resetMs: core.reset * 1000,
|
|
1539
|
+
};
|
|
1540
|
+
} catch {
|
|
1541
|
+
// A probe failure must never be louder than the pane's own data: the loop
|
|
1542
|
+
// simply does not adapt this cycle and stays at whatever it last applied.
|
|
1543
|
+
return null;
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1324
1547
|
// Requests per hour this configuration will spend once it settles, derived from
|
|
1325
1548
|
// the same constants the poll loop uses rather than from a number written down
|
|
1326
1549
|
// once and left to rot. The active tab refreshes every tick; the other three
|
|
1327
|
-
// every BACKGROUND_EVERY ticks.
|
|
1328
|
-
//
|
|
1329
|
-
//
|
|
1550
|
+
// every BACKGROUND_EVERY ticks. The per-fetch prices come from REST_PER_FETCH
|
|
1551
|
+
// and GRAPHQL_PER_FETCH, which are also what the spend meter bills against --
|
|
1552
|
+
// two copies of them would diverge the first time one was fixed.
|
|
1330
1553
|
function projectedHourlyCost(activeKey) {
|
|
1331
1554
|
const perHour = 3_600_000 / runtime.refreshMs;
|
|
1332
|
-
const restCalls = { actions: 1, issues: 0, prs: 0, security: ALERT_SOURCES.length };
|
|
1333
|
-
const graphqlCalls = { actions: 0, issues: 2, prs: 2, security: 0 };
|
|
1334
1555
|
let rest = 0;
|
|
1335
1556
|
let graphql = 0;
|
|
1336
1557
|
// TAB_KEYS rather than TABS: this runs on the --doctor path, which deliberately
|
|
1337
1558
|
// returns before the render tree is reached, and TABS is declared down there.
|
|
1338
1559
|
for (const key of TAB_KEYS) {
|
|
1339
1560
|
const ticks = key === activeKey ? perHour : perHour / BACKGROUND_EVERY;
|
|
1340
|
-
rest += ticks *
|
|
1341
|
-
graphql += ticks *
|
|
1561
|
+
rest += ticks * REST_PER_FETCH[key];
|
|
1562
|
+
graphql += ticks * GRAPHQL_PER_FETCH[key];
|
|
1342
1563
|
}
|
|
1343
1564
|
return { rest: Math.round(rest), graphql: Math.round(graphql) };
|
|
1344
1565
|
}
|
|
@@ -1495,6 +1716,11 @@ function parseArgs(argv) {
|
|
|
1495
1716
|
doctor: false,
|
|
1496
1717
|
repo: null,
|
|
1497
1718
|
refresh: null,
|
|
1719
|
+
// Which surface supplied `refresh`, so validateArgs can name it in the
|
|
1720
|
+
// bounds messages. argv never sets it -- the entry block does, when it
|
|
1721
|
+
// falls back to GH_GLANCE_REFRESH -- but it is declared here so the shape
|
|
1722
|
+
// parseArgs returns stays stated in one place.
|
|
1723
|
+
refreshSource: null,
|
|
1498
1724
|
tab: null,
|
|
1499
1725
|
verbose: false,
|
|
1500
1726
|
};
|
|
@@ -1537,13 +1763,16 @@ function validateArgs(opts, tabKeys) {
|
|
|
1537
1763
|
|
|
1538
1764
|
let refreshMs = null;
|
|
1539
1765
|
if (opts.refresh !== null) {
|
|
1766
|
+
// Named so the message points at whichever surface supplied the value: the
|
|
1767
|
+
// interval can now come from GH_GLANCE_REFRESH as well as --refresh.
|
|
1768
|
+
const refreshLabel = opts.refreshSource ?? "--refresh";
|
|
1540
1769
|
const seconds = Number(opts.refresh);
|
|
1541
1770
|
if (!Number.isFinite(seconds) || !Number.isInteger(seconds)) {
|
|
1542
|
-
throw new Error(
|
|
1771
|
+
throw new Error(`${refreshLabel} must be a whole number of seconds, got: ${opts.refresh}`);
|
|
1543
1772
|
}
|
|
1544
1773
|
if (seconds < MIN_REFRESH_SECONDS || seconds > MAX_REFRESH_SECONDS) {
|
|
1545
1774
|
throw new Error(
|
|
1546
|
-
|
|
1775
|
+
`${refreshLabel} must be between ${MIN_REFRESH_SECONDS} and ${MAX_REFRESH_SECONDS} seconds, got: ${seconds}`,
|
|
1547
1776
|
);
|
|
1548
1777
|
}
|
|
1549
1778
|
refreshMs = seconds * 1000;
|
|
@@ -1581,8 +1810,9 @@ const KEY_TABLE = [
|
|
|
1581
1810
|
["PgUp / PgDn", "Move a page at a time"],
|
|
1582
1811
|
["Enter", "Open the selected item or accept a prompt"],
|
|
1583
1812
|
["r", "Refresh the current tab now"],
|
|
1813
|
+
["w", "Adjust table column widths"],
|
|
1584
1814
|
["?", "Show the keys (any key closes it)"],
|
|
1585
|
-
["q / Esc / Ctrl+C", "Quit"],
|
|
1815
|
+
["q / Esc / Ctrl+C", "Quit (Esc leaves width mode)"],
|
|
1586
1816
|
];
|
|
1587
1817
|
const KEY_COL = Math.max(...KEY_TABLE.map(([k]) => k.length)) + 3;
|
|
1588
1818
|
const keyTableLines = () => KEY_TABLE.map(([k, d]) => `${k.padEnd(KEY_COL)}${d}`);
|
|
@@ -1643,6 +1873,9 @@ Environment:
|
|
|
1643
1873
|
GH_HOST=host GitHub Enterprise or EMU host to send every call to.
|
|
1644
1874
|
A host-qualified GH_REPO does not route \`gh api\`;
|
|
1645
1875
|
this and --repo host/owner/name both do.
|
|
1876
|
+
GH_GLANCE_REFRESH=<seconds>
|
|
1877
|
+
Active-tab poll interval, ${MIN_REFRESH_SECONDS}-${MAX_REFRESH_SECONDS}
|
|
1878
|
+
(--refresh takes precedence)
|
|
1646
1879
|
GH_GLANCE_ICONS=unicode Plain-unicode status icons (no Nerd Font needed)
|
|
1647
1880
|
GH_GLANCE_NO_ANIMATION=1 Freeze the spinner (no motion)
|
|
1648
1881
|
NO_COLOR=1 Disable colour (status stays readable)
|
|
@@ -1660,7 +1893,20 @@ Environment:
|
|
|
1660
1893
|
if (IS_MAIN) {
|
|
1661
1894
|
let opts;
|
|
1662
1895
|
try {
|
|
1663
|
-
|
|
1896
|
+
const argvOpts = parseArgs(process.argv.slice(2));
|
|
1897
|
+
// The flag wins, which is the precedence GH_REPO already advertises -- but
|
|
1898
|
+
// not its mechanism: GH_REPO is read at each use site, because `gh` honours
|
|
1899
|
+
// it natively and a repo slug needs no validation. An interval does, so it
|
|
1900
|
+
// is resolved once, here, and substituted before validation -- an
|
|
1901
|
+
// out-of-range GH_GLANCE_REFRESH is then refused by the same two messages
|
|
1902
|
+
// an out-of-range --refresh gets, rather than by a second copy of the
|
|
1903
|
+
// bounds. It still reaches runtime through the one write site below rather
|
|
1904
|
+
// than being assigned beside it.
|
|
1905
|
+
if (argvOpts.refresh === null && process.env.GH_GLANCE_REFRESH) {
|
|
1906
|
+
argvOpts.refresh = process.env.GH_GLANCE_REFRESH;
|
|
1907
|
+
argvOpts.refreshSource = "GH_GLANCE_REFRESH";
|
|
1908
|
+
}
|
|
1909
|
+
opts = validateArgs(argvOpts, TAB_KEYS);
|
|
1664
1910
|
} catch (err) {
|
|
1665
1911
|
// Exit 2 for every argv problem, unchanged from when the only possible
|
|
1666
1912
|
// problem was an unrecognised flag. CI asserts this code.
|
|
@@ -1734,10 +1980,10 @@ if (IS_MAIN) {
|
|
|
1734
1980
|
}
|
|
1735
1981
|
|
|
1736
1982
|
const ReactModule = await import("react");
|
|
1737
|
-
const { render, Box, Text, useStdout, useInput, useStdin, useApp } = await import("ink");
|
|
1983
|
+
const { render, measureElement, Box, Text, useStdout, useInput, useStdin, useApp } = await import("ink");
|
|
1738
1984
|
|
|
1739
1985
|
const React = ReactModule.default;
|
|
1740
|
-
const { useState, useEffect, useRef } = ReactModule;
|
|
1986
|
+
const { useState, useEffect, useMemo, useRef, useCallback } = ReactModule;
|
|
1741
1987
|
const e = React.createElement;
|
|
1742
1988
|
|
|
1743
1989
|
// The NODE_ENV escape hatch above is genuinely useful -- React's warnings are
|
|
@@ -1840,10 +2086,10 @@ const ERROR_TEXT = "red";
|
|
|
1840
2086
|
// column is otherwise a private-use codepoint that announces as nothing --
|
|
1841
2087
|
// leaving every row with no status at all. It is derived from the same lookup
|
|
1842
2088
|
// that picks the glyph, so the two cannot drift apart.
|
|
1843
|
-
function Column({ width, grow, children, bold, color, dim, wrap, label }) {
|
|
2089
|
+
function Column({ width, grow, children, bold, color, dim, wrap, label, marginRight = 1 }) {
|
|
1844
2090
|
return e(
|
|
1845
2091
|
Box,
|
|
1846
|
-
{ width, flexGrow: grow ? 1 : 0, flexShrink: grow ? 1 : 0, marginRight
|
|
2092
|
+
{ width, flexGrow: grow ? 1 : 0, flexShrink: grow ? 1 : 0, marginRight },
|
|
1847
2093
|
e(
|
|
1848
2094
|
Text,
|
|
1849
2095
|
{ bold, color, dimColor: dim, wrap: wrap ?? "truncate-end", "aria-label": label },
|
|
@@ -1852,10 +2098,35 @@ function Column({ width, grow, children, bold, color, dim, wrap, label }) {
|
|
|
1852
2098
|
);
|
|
1853
2099
|
}
|
|
1854
2100
|
|
|
1855
|
-
|
|
2101
|
+
// Each descriptor still owns one gutter cell, but an adjustable column owns
|
|
2102
|
+
// the edge that faces the flexible TITLE/SUMMARY reservoir. Fixed columns after
|
|
2103
|
+
// the grow cell therefore use the preceding descriptor's trailing gutter;
|
|
2104
|
+
// fixed columns before it use their own. Keeping this mapping pure lets the
|
|
2105
|
+
// pointer phase share the exact same geometry as the visible grips.
|
|
2106
|
+
function headerGutterKeyAt(cells, index, growIndex) {
|
|
2107
|
+
if (!Array.isArray(cells) || !Number.isSafeInteger(index) || index < 0 || index >= cells.length) {
|
|
2108
|
+
return null;
|
|
2109
|
+
}
|
|
2110
|
+
if (growIndex < 0) return null;
|
|
2111
|
+
if (index < growIndex) {
|
|
2112
|
+
return isAdjustableWidthColumn(cells[index]) ? cells[index].key : null;
|
|
2113
|
+
}
|
|
2114
|
+
return isAdjustableWidthColumn(cells[index + 1]) ? cells[index + 1].key : null;
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
function headerGutterKey(cells, index) {
|
|
2118
|
+
const growIndex = Array.isArray(cells)
|
|
2119
|
+
? cells.findIndex((column) => column.props?.grow)
|
|
2120
|
+
: -1;
|
|
2121
|
+
return headerGutterKeyAt(cells, index, growIndex);
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
function HeaderCells({ cells, selectedWidthKey = null, headerRef = null }) {
|
|
2125
|
+
const growIndex = cells.findIndex((column) => column.props?.grow);
|
|
1856
2126
|
return e(
|
|
1857
2127
|
Box,
|
|
1858
2128
|
{
|
|
2129
|
+
ref: headerRef,
|
|
1859
2130
|
flexDirection: "row",
|
|
1860
2131
|
borderStyle: "single",
|
|
1861
2132
|
borderTop: false,
|
|
@@ -1863,7 +2134,198 @@ function HeaderCells({ cells }) {
|
|
|
1863
2134
|
borderRight: false,
|
|
1864
2135
|
borderColor: BORDER_COLOR,
|
|
1865
2136
|
},
|
|
1866
|
-
...cells.
|
|
2137
|
+
...cells.flatMap((c, index) => {
|
|
2138
|
+
const gripKey = headerGutterKeyAt(cells, index, growIndex);
|
|
2139
|
+
const selected = gripKey !== null && gripKey === selectedWidthKey;
|
|
2140
|
+
return [
|
|
2141
|
+
e(
|
|
2142
|
+
Column,
|
|
2143
|
+
{ key: `${c.key}:cell`, ...c.props, bold: true, dim: true, marginRight: 0 },
|
|
2144
|
+
c.label,
|
|
2145
|
+
),
|
|
2146
|
+
e(
|
|
2147
|
+
Text,
|
|
2148
|
+
{
|
|
2149
|
+
key: `${c.key}:gutter`,
|
|
2150
|
+
color: selected ? TITLE_COLOR : BORDER_COLOR,
|
|
2151
|
+
bold: selected,
|
|
2152
|
+
dimColor: gripKey !== null && !selected,
|
|
2153
|
+
"aria-hidden": true,
|
|
2154
|
+
},
|
|
2155
|
+
gripKey === null ? " " : "│",
|
|
2156
|
+
),
|
|
2157
|
+
];
|
|
2158
|
+
}),
|
|
2159
|
+
);
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
const MemoHeaderCells = React.memo(HeaderCells);
|
|
2163
|
+
|
|
2164
|
+
function parseSgrMouse(input) {
|
|
2165
|
+
if (typeof input !== "string") return null;
|
|
2166
|
+
const match = /^\[<(\d+);(\d+);(\d+)([Mm])$/.exec(input);
|
|
2167
|
+
if (!match) return null;
|
|
2168
|
+
|
|
2169
|
+
const code = Number(match[1]);
|
|
2170
|
+
const encodedX = Number(match[2]);
|
|
2171
|
+
const encodedY = Number(match[3]);
|
|
2172
|
+
if (
|
|
2173
|
+
!Number.isSafeInteger(code) ||
|
|
2174
|
+
!Number.isSafeInteger(encodedX) ||
|
|
2175
|
+
!Number.isSafeInteger(encodedY) ||
|
|
2176
|
+
encodedX < 1 ||
|
|
2177
|
+
encodedY < 1 ||
|
|
2178
|
+
(code !== 0 && code !== 32)
|
|
2179
|
+
) {
|
|
2180
|
+
return null;
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
return {
|
|
2184
|
+
x: encodedX - 1,
|
|
2185
|
+
y: encodedY - 1,
|
|
2186
|
+
action: match[4] === "m" ? "release" : code === 32 ? "drag" : "press",
|
|
2187
|
+
};
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
function dividerHandles({ header, metrics }) {
|
|
2191
|
+
if (
|
|
2192
|
+
!Array.isArray(header) ||
|
|
2193
|
+
!metrics ||
|
|
2194
|
+
!Number.isSafeInteger(metrics.x) ||
|
|
2195
|
+
!Number.isSafeInteger(metrics.y) ||
|
|
2196
|
+
!Number.isSafeInteger(metrics.width) ||
|
|
2197
|
+
!Number.isSafeInteger(metrics.height) ||
|
|
2198
|
+
metrics.x < 0 ||
|
|
2199
|
+
metrics.y < 0 ||
|
|
2200
|
+
metrics.width < 1 ||
|
|
2201
|
+
metrics.height < 1
|
|
2202
|
+
) {
|
|
2203
|
+
return [];
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
const growIndexes = header
|
|
2207
|
+
.map((column, index) => column.props?.grow ? index : -1)
|
|
2208
|
+
.filter((index) => index >= 0);
|
|
2209
|
+
if (growIndexes.length !== 1) return [];
|
|
2210
|
+
const growIndex = growIndexes[0];
|
|
2211
|
+
const fixedWidth = header.reduce((sum, column, index) => {
|
|
2212
|
+
if (index === growIndex) return sum;
|
|
2213
|
+
return Number.isSafeInteger(column.props?.width) && column.props.width >= 0
|
|
2214
|
+
? sum + column.props.width
|
|
2215
|
+
: Number.NaN;
|
|
2216
|
+
}, 0);
|
|
2217
|
+
const growWidth = metrics.width - header.length - fixedWidth;
|
|
2218
|
+
const yEnd = metrics.y + metrics.height;
|
|
2219
|
+
if (!Number.isSafeInteger(growWidth) || growWidth < 0 || !Number.isSafeInteger(yEnd)) return [];
|
|
2220
|
+
|
|
2221
|
+
const handles = [];
|
|
2222
|
+
let x = metrics.x;
|
|
2223
|
+
for (let index = 0; index < header.length; index += 1) {
|
|
2224
|
+
const contentWidth = index === growIndex ? growWidth : header[index].props.width;
|
|
2225
|
+
const gutterX = x + contentWidth;
|
|
2226
|
+
const key = headerGutterKeyAt(header, index, growIndex);
|
|
2227
|
+
if (key !== null) {
|
|
2228
|
+
const ownerIndex = index < growIndex ? index : index + 1;
|
|
2229
|
+
const owner = header[ownerIndex];
|
|
2230
|
+
if (!isAdjustableWidthColumn(owner) || owner.key !== key) return [];
|
|
2231
|
+
handles.push({
|
|
2232
|
+
key,
|
|
2233
|
+
x: gutterX,
|
|
2234
|
+
yStart: metrics.y,
|
|
2235
|
+
yEnd,
|
|
2236
|
+
width: owner.props.width,
|
|
2237
|
+
direction: ownerIndex < growIndex ? 1 : -1,
|
|
2238
|
+
});
|
|
2239
|
+
}
|
|
2240
|
+
x = gutterX + 1;
|
|
2241
|
+
}
|
|
2242
|
+
return handles;
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
function hitDivider(handles, point, tolerance = 1) {
|
|
2246
|
+
if (
|
|
2247
|
+
!Array.isArray(handles) ||
|
|
2248
|
+
!point ||
|
|
2249
|
+
!Number.isSafeInteger(point.x) ||
|
|
2250
|
+
!Number.isSafeInteger(point.y) ||
|
|
2251
|
+
!Number.isSafeInteger(tolerance) ||
|
|
2252
|
+
tolerance < 0
|
|
2253
|
+
) {
|
|
2254
|
+
return null;
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
let nearest = null;
|
|
2258
|
+
let nearestDistance = Number.POSITIVE_INFINITY;
|
|
2259
|
+
for (const handle of handles) {
|
|
2260
|
+
if (
|
|
2261
|
+
!Number.isSafeInteger(handle?.x) ||
|
|
2262
|
+
!Number.isSafeInteger(handle.yStart) ||
|
|
2263
|
+
!Number.isSafeInteger(handle.yEnd) ||
|
|
2264
|
+
point.y < handle.yStart ||
|
|
2265
|
+
point.y >= handle.yEnd
|
|
2266
|
+
) {
|
|
2267
|
+
continue;
|
|
2268
|
+
}
|
|
2269
|
+
const distance = Math.abs(point.x - handle.x);
|
|
2270
|
+
if (
|
|
2271
|
+
distance <= tolerance &&
|
|
2272
|
+
(distance < nearestDistance ||
|
|
2273
|
+
(distance === nearestDistance && (nearest === null || handle.x < nearest.x)))
|
|
2274
|
+
) {
|
|
2275
|
+
nearest = handle;
|
|
2276
|
+
nearestDistance = distance;
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
return nearest;
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
function beginDividerDrag({ event, handles, tabKey, tolerance = 1 }) {
|
|
2283
|
+
if (event?.action !== "press" || typeof tabKey !== "string" || tabKey.length === 0) {
|
|
2284
|
+
return null;
|
|
2285
|
+
}
|
|
2286
|
+
const handle = hitDivider(handles, event, tolerance);
|
|
2287
|
+
if (!handle) return null;
|
|
2288
|
+
return {
|
|
2289
|
+
tabKey,
|
|
2290
|
+
key: handle.key,
|
|
2291
|
+
startX: event.x,
|
|
2292
|
+
startWidth: handle.width,
|
|
2293
|
+
direction: handle.direction,
|
|
2294
|
+
};
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
function draggedWidth({
|
|
2298
|
+
drag,
|
|
2299
|
+
event,
|
|
2300
|
+
tabKey,
|
|
2301
|
+
fullHeaderVisible,
|
|
2302
|
+
layoutValid = true,
|
|
2303
|
+
}) {
|
|
2304
|
+
if (
|
|
2305
|
+
!drag ||
|
|
2306
|
+
event?.action !== "drag" ||
|
|
2307
|
+
drag.tabKey !== tabKey ||
|
|
2308
|
+
fullHeaderVisible !== true ||
|
|
2309
|
+
layoutValid !== true ||
|
|
2310
|
+
!Number.isSafeInteger(drag.startX) ||
|
|
2311
|
+
!Number.isSafeInteger(drag.startWidth) ||
|
|
2312
|
+
(drag.direction !== 1 && drag.direction !== -1) ||
|
|
2313
|
+
!Number.isSafeInteger(event.x)
|
|
2314
|
+
) {
|
|
2315
|
+
return null;
|
|
2316
|
+
}
|
|
2317
|
+
const nextWidth = drag.startWidth + drag.direction * (event.x - drag.startX);
|
|
2318
|
+
return Number.isSafeInteger(nextWidth) ? { key: drag.key, nextWidth } : null;
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
function sameElementMetrics(left, right) {
|
|
2322
|
+
return (
|
|
2323
|
+
left != null &&
|
|
2324
|
+
right != null &&
|
|
2325
|
+
left.x === right.x &&
|
|
2326
|
+
left.y === right.y &&
|
|
2327
|
+
left.width === right.width &&
|
|
2328
|
+
left.height === right.height
|
|
1867
2329
|
);
|
|
1868
2330
|
}
|
|
1869
2331
|
|
|
@@ -1986,23 +2448,23 @@ function runStatusIcon(run, spin) {
|
|
|
1986
2448
|
}
|
|
1987
2449
|
|
|
1988
2450
|
const ACTIONS_HEADER = [
|
|
1989
|
-
{ label: "", props: { width: 3 } },
|
|
1990
|
-
{ label: "TITLE", props: { grow: true } },
|
|
1991
|
-
{ label: "WORKFLOW", props: { width: 10 } },
|
|
1992
|
-
{ label: "BRANCH", props: { width: 14 } },
|
|
1993
|
-
{ label: "TIME", props: { width: 7 } },
|
|
1994
|
-
{ label: "UPDATED", props: { width: 8 } },
|
|
2451
|
+
{ key: "status", label: "", props: { width: 3 }, adjustable: false },
|
|
2452
|
+
{ key: "title", label: "TITLE", props: { grow: true }, adjustable: false },
|
|
2453
|
+
{ key: "workflow", label: "WORKFLOW", props: { width: 10 }, adjustable: true, minWidth: 5 },
|
|
2454
|
+
{ key: "branch", label: "BRANCH", props: { width: 14 }, adjustable: true, minWidth: 6 },
|
|
2455
|
+
{ key: "time", label: "TIME", props: { width: 7 }, adjustable: true, minWidth: 5 },
|
|
2456
|
+
{ key: "updated", label: "UPDATED", props: { width: 8 }, adjustable: true, minWidth: 6 },
|
|
1995
2457
|
];
|
|
1996
2458
|
|
|
1997
2459
|
// Dropped in order of least value first: BRANCH and WORKFLOW are inferable
|
|
1998
2460
|
// from the title far more often than the status icon or the age are.
|
|
1999
2461
|
const ACTIONS_HEADER_COMPACT = [
|
|
2000
|
-
{ label: "", props: { width: 3 } },
|
|
2001
|
-
{ label: "TITLE", props: { grow: true } },
|
|
2002
|
-
{ label: "UPDATED", props: { width: 8 } },
|
|
2462
|
+
{ key: "status", label: "", props: { width: 3 } },
|
|
2463
|
+
{ key: "title", label: "TITLE", props: { grow: true } },
|
|
2464
|
+
{ key: "updated", label: "UPDATED", props: { width: 8 } },
|
|
2003
2465
|
];
|
|
2004
2466
|
|
|
2005
|
-
function ActionsRow({ item, now, spin, compact, cursor }) {
|
|
2467
|
+
function ActionsRow({ item, now, spin, compact, cursor, columns }) {
|
|
2006
2468
|
const { icon, color, label } = runStatusIcon(item, spin);
|
|
2007
2469
|
const started = new Date(item.startedAt);
|
|
2008
2470
|
const finished = item.status === "completed" ? new Date(item.updatedAt) : now;
|
|
@@ -2010,52 +2472,52 @@ function ActionsRow({ item, now, spin, compact, cursor }) {
|
|
|
2010
2472
|
return e(
|
|
2011
2473
|
Box,
|
|
2012
2474
|
{ flexDirection: "row" },
|
|
2013
|
-
e(Column, {
|
|
2014
|
-
e(Column,
|
|
2015
|
-
e(Column, {
|
|
2475
|
+
e(Column, { ...columnProps(columns, "status"), color, label }, `${cursor ? ">" : " "}${icon}`),
|
|
2476
|
+
e(Column, columnProps(columns, "title"), item.displayTitle),
|
|
2477
|
+
e(Column, { ...columnProps(columns, "updated"), dim: true }, formatAge(new Date(item.updatedAt), now)),
|
|
2016
2478
|
);
|
|
2017
2479
|
}
|
|
2018
2480
|
return e(
|
|
2019
2481
|
Box,
|
|
2020
2482
|
{ flexDirection: "row" },
|
|
2021
|
-
e(Column, {
|
|
2022
|
-
e(Column,
|
|
2483
|
+
e(Column, { ...columnProps(columns, "status"), color, label }, `${cursor ? ">" : " "}${icon}`),
|
|
2484
|
+
e(Column, columnProps(columns, "title"), item.displayTitle),
|
|
2023
2485
|
// The run number is the actionable half and used to be the first thing
|
|
2024
2486
|
// truncation ate, since it sat at the tail of a 10-column cell.
|
|
2025
|
-
e(Column, {
|
|
2026
|
-
e(Column, {
|
|
2027
|
-
e(Column,
|
|
2028
|
-
e(Column, {
|
|
2487
|
+
e(Column, { ...columnProps(columns, "workflow"), color: IDENTIFIER }, `#${item.number} ${item.workflowName}`),
|
|
2488
|
+
e(Column, { ...columnProps(columns, "branch"), color: REF, wrap: "truncate-middle" }, item.headBranch),
|
|
2489
|
+
e(Column, columnProps(columns, "time"), formatDuration(finished - started)),
|
|
2490
|
+
e(Column, { ...columnProps(columns, "updated"), dim: true }, formatAge(new Date(item.updatedAt), now)),
|
|
2029
2491
|
);
|
|
2030
2492
|
}
|
|
2031
2493
|
|
|
2032
2494
|
// ---------- Issues tab ----------
|
|
2033
2495
|
|
|
2034
2496
|
const ISSUES_HEADER = [
|
|
2035
|
-
{ label: "", props: { width: 3 } },
|
|
2036
|
-
{ label: "TITLE", props: { grow: true } },
|
|
2037
|
-
{ label: "AUTHOR", props: { width: 12 } },
|
|
2038
|
-
{ label: "LABEL", props: { width: 14 } },
|
|
2039
|
-
{ label: "UPDATED", props: { width: 8 } },
|
|
2497
|
+
{ key: "status", label: "", props: { width: 3 }, adjustable: false },
|
|
2498
|
+
{ key: "title", label: "TITLE", props: { grow: true }, adjustable: false },
|
|
2499
|
+
{ key: "author", label: "AUTHOR", props: { width: 12 }, adjustable: true, minWidth: 6 },
|
|
2500
|
+
{ key: "label", label: "LABEL", props: { width: 14 }, adjustable: true, minWidth: 6 },
|
|
2501
|
+
{ key: "updated", label: "UPDATED", props: { width: 8 }, adjustable: true, minWidth: 6 },
|
|
2040
2502
|
];
|
|
2041
2503
|
|
|
2042
2504
|
const ISSUES_HEADER_COMPACT = [
|
|
2043
|
-
{ label: "", props: { width: 3 } },
|
|
2044
|
-
{ label: "TITLE", props: { grow: true } },
|
|
2045
|
-
{ label: "UPDATED", props: { width: 8 } },
|
|
2505
|
+
{ key: "status", label: "", props: { width: 3 } },
|
|
2506
|
+
{ key: "title", label: "TITLE", props: { grow: true } },
|
|
2507
|
+
{ key: "updated", label: "UPDATED", props: { width: 8 } },
|
|
2046
2508
|
];
|
|
2047
2509
|
|
|
2048
|
-
function IssueRow({ item, now, compact, cursor }) {
|
|
2510
|
+
function IssueRow({ item, now, compact, cursor, columns }) {
|
|
2049
2511
|
const cells = [
|
|
2050
|
-
e(Column, { key: "
|
|
2051
|
-
e(Column, { key: "
|
|
2512
|
+
e(Column, { key: "status", ...columnProps(columns, "status"), color: OK, label: "open issue" }, `${cursor ? ">" : " "}${OCT.issueOpened}`),
|
|
2513
|
+
e(Column, { key: "title", ...columnProps(columns, "title") }, `#${item.number} ${item.title}`),
|
|
2052
2514
|
];
|
|
2053
2515
|
if (!compact) {
|
|
2054
|
-
cells.push(e(Column, { key: "
|
|
2055
|
-
cells.push(e(Column, { key: "
|
|
2516
|
+
cells.push(e(Column, { key: "author", ...columnProps(columns, "author"), color: IDENTIFIER }, item.author));
|
|
2517
|
+
cells.push(e(Column, { key: "label", ...columnProps(columns, "label"), color: REF }, item.label));
|
|
2056
2518
|
}
|
|
2057
2519
|
cells.push(
|
|
2058
|
-
e(Column, { key: "
|
|
2520
|
+
e(Column, { key: "updated", ...columnProps(columns, "updated"), dim: true }, formatAge(new Date(item.updatedAt), now)),
|
|
2059
2521
|
);
|
|
2060
2522
|
return e(Box, { flexDirection: "row" }, ...cells);
|
|
2061
2523
|
}
|
|
@@ -2063,18 +2525,18 @@ function IssueRow({ item, now, compact, cursor }) {
|
|
|
2063
2525
|
// ---------- Pull requests tab ----------
|
|
2064
2526
|
|
|
2065
2527
|
const PRS_HEADER = [
|
|
2066
|
-
{ label: "", props: { width: 3 } },
|
|
2067
|
-
{ label: "TITLE", props: { grow: true } },
|
|
2068
|
-
{ label: "AUTHOR", props: { width: 12 } },
|
|
2069
|
-
{ label: "BRANCH", props: { width: 14 } },
|
|
2070
|
-
{ label: "REVIEW", props: { width: 10 } },
|
|
2071
|
-
{ label: "UPDATED", props: { width: 8 } },
|
|
2528
|
+
{ key: "status", label: "", props: { width: 3 }, adjustable: false },
|
|
2529
|
+
{ key: "title", label: "TITLE", props: { grow: true }, adjustable: false },
|
|
2530
|
+
{ key: "author", label: "AUTHOR", props: { width: 12 }, adjustable: true, minWidth: 6 },
|
|
2531
|
+
{ key: "branch", label: "BRANCH", props: { width: 14 }, adjustable: true, minWidth: 6 },
|
|
2532
|
+
{ key: "review", label: "REVIEW", props: { width: 10 }, adjustable: true, minWidth: 7 },
|
|
2533
|
+
{ key: "updated", label: "UPDATED", props: { width: 8 }, adjustable: true, minWidth: 6 },
|
|
2072
2534
|
];
|
|
2073
2535
|
|
|
2074
2536
|
const PRS_HEADER_COMPACT = [
|
|
2075
|
-
{ label: "", props: { width: 3 } },
|
|
2076
|
-
{ label: "TITLE", props: { grow: true } },
|
|
2077
|
-
{ label: "REVIEW", props: { width: 10 } },
|
|
2537
|
+
{ key: "status", label: "", props: { width: 3 } },
|
|
2538
|
+
{ key: "title", label: "TITLE", props: { grow: true } },
|
|
2539
|
+
{ key: "review", label: "REVIEW", props: { width: 10 } },
|
|
2078
2540
|
];
|
|
2079
2541
|
|
|
2080
2542
|
const REVIEW_LABEL = {
|
|
@@ -2084,25 +2546,25 @@ const REVIEW_LABEL = {
|
|
|
2084
2546
|
};
|
|
2085
2547
|
const REVIEW_NONE = { label: "", color: INERT };
|
|
2086
2548
|
|
|
2087
|
-
function PRRow({ item, now, compact, cursor }) {
|
|
2549
|
+
function PRRow({ item, now, compact, cursor, columns }) {
|
|
2088
2550
|
const prIcon = item.isDraft
|
|
2089
2551
|
? { icon: OCT.pullRequestDraft, color: INERT, label: "draft pull request" }
|
|
2090
2552
|
: { icon: OCT.pullRequest, color: OK, label: "open pull request" };
|
|
2091
2553
|
const review = pick(REVIEW_LABEL, item.reviewDecision, REVIEW_NONE);
|
|
2092
2554
|
const cells = [
|
|
2093
|
-
e(Column, { key: "
|
|
2094
|
-
e(Column, { key: "
|
|
2555
|
+
e(Column, { key: "status", ...columnProps(columns, "status"), color: prIcon.color, label: prIcon.label }, `${cursor ? ">" : " "}${prIcon.icon}`),
|
|
2556
|
+
e(Column, { key: "title", ...columnProps(columns, "title") }, `#${item.number} ${item.title}`),
|
|
2095
2557
|
];
|
|
2096
2558
|
if (!compact) {
|
|
2097
|
-
cells.push(e(Column, { key: "
|
|
2559
|
+
cells.push(e(Column, { key: "author", ...columnProps(columns, "author"), color: IDENTIFIER }, item.author));
|
|
2098
2560
|
cells.push(
|
|
2099
|
-
e(Column, { key: "
|
|
2561
|
+
e(Column, { key: "branch", ...columnProps(columns, "branch"), color: REF, wrap: "truncate-middle" }, item.headRefName),
|
|
2100
2562
|
);
|
|
2101
2563
|
}
|
|
2102
|
-
cells.push(e(Column, { key: "
|
|
2564
|
+
cells.push(e(Column, { key: "review", ...columnProps(columns, "review"), color: review.color }, review.label));
|
|
2103
2565
|
if (!compact) {
|
|
2104
2566
|
cells.push(
|
|
2105
|
-
e(Column, { key: "
|
|
2567
|
+
e(Column, { key: "updated", ...columnProps(columns, "updated"), dim: true }, formatAge(new Date(item.updatedAt), now)),
|
|
2106
2568
|
);
|
|
2107
2569
|
}
|
|
2108
2570
|
return e(Box, { flexDirection: "row" }, ...cells);
|
|
@@ -2116,19 +2578,19 @@ function PRRow({ item, now, compact, cursor }) {
|
|
|
2116
2578
|
// text column states it outright; the width comes out of PACKAGE / FILE, whose
|
|
2117
2579
|
// contents were already being truncated.
|
|
2118
2580
|
const SECURITY_HEADER = [
|
|
2119
|
-
{ label: "", props: { width: 3 } },
|
|
2120
|
-
{ label: "SEV", props: { width: 4 } },
|
|
2121
|
-
{ label: "PACKAGE / FILE", props: { width: 16 } },
|
|
2122
|
-
{ label: "SUMMARY", props: { grow: true } },
|
|
2123
|
-
{ label: "AGE", props: { width: 8 } },
|
|
2581
|
+
{ key: "status", label: "", props: { width: 3 }, adjustable: false },
|
|
2582
|
+
{ key: "severity", label: "SEV", props: { width: 4 }, adjustable: false },
|
|
2583
|
+
{ key: "package", label: "PACKAGE / FILE", props: { width: 16 }, adjustable: true, minWidth: 6 },
|
|
2584
|
+
{ key: "summary", label: "SUMMARY", props: { grow: true }, adjustable: false },
|
|
2585
|
+
{ key: "age", label: "AGE", props: { width: 8 }, adjustable: true, minWidth: 6 },
|
|
2124
2586
|
];
|
|
2125
2587
|
|
|
2126
2588
|
// SEV is the last thing to drop on this tab: it is the whole point of the pane
|
|
2127
2589
|
// and the only non-colour severity channel.
|
|
2128
2590
|
const SECURITY_HEADER_COMPACT = [
|
|
2129
|
-
{ label: "", props: { width: 3 } },
|
|
2130
|
-
{ label: "SEV", props: { width: 4 } },
|
|
2131
|
-
{ label: "SUMMARY", props: { grow: true } },
|
|
2591
|
+
{ key: "status", label: "", props: { width: 3 } },
|
|
2592
|
+
{ key: "severity", label: "SEV", props: { width: 4 } },
|
|
2593
|
+
{ key: "summary", label: "SUMMARY", props: { grow: true } },
|
|
2132
2594
|
];
|
|
2133
2595
|
|
|
2134
2596
|
const SEVERITY_STYLE = {
|
|
@@ -2141,19 +2603,19 @@ const SEVERITY_STYLE = {
|
|
|
2141
2603
|
};
|
|
2142
2604
|
const SEVERITY_UNKNOWN = SEVERITY_STYLE.unknown;
|
|
2143
2605
|
|
|
2144
|
-
function SecurityRow({ item, now, compact, cursor }) {
|
|
2606
|
+
function SecurityRow({ item, now, compact, cursor, columns }) {
|
|
2145
2607
|
const sev = pick(SEVERITY_STYLE, item.severity, SEVERITY_UNKNOWN);
|
|
2146
2608
|
const cells = [
|
|
2147
|
-
e(Column, { key: "
|
|
2148
|
-
e(Column, { key: "
|
|
2609
|
+
e(Column, { key: "status", ...columnProps(columns, "status"), color: sev.color, label: `${sev.short} severity` }, `${cursor ? ">" : " "}${OCT.shield}`),
|
|
2610
|
+
e(Column, { key: "severity", ...columnProps(columns, "severity"), color: sev.color }, sev.short),
|
|
2149
2611
|
];
|
|
2150
2612
|
if (!compact) {
|
|
2151
|
-
cells.push(e(Column, { key: "
|
|
2613
|
+
cells.push(e(Column, { key: "package", ...columnProps(columns, "package"), color: IDENTIFIER }, item.detail || item.kind));
|
|
2152
2614
|
}
|
|
2153
|
-
cells.push(e(Column, { key: "
|
|
2615
|
+
cells.push(e(Column, { key: "summary", ...columnProps(columns, "summary") }, item.title));
|
|
2154
2616
|
if (!compact) {
|
|
2155
2617
|
cells.push(
|
|
2156
|
-
e(Column, { key: "
|
|
2618
|
+
e(Column, { key: "age", ...columnProps(columns, "age"), dim: true }, formatAge(new Date(item.createdAt), now)),
|
|
2157
2619
|
);
|
|
2158
2620
|
}
|
|
2159
2621
|
return e(Box, { flexDirection: "row" }, ...cells);
|
|
@@ -2214,6 +2676,181 @@ const TABS = [
|
|
|
2214
2676
|
},
|
|
2215
2677
|
];
|
|
2216
2678
|
|
|
2679
|
+
function tabForKey(key) {
|
|
2680
|
+
return TABS.find((tab) => tab.key === key);
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
const EMPTY_WIDTH_OVERRIDES = Object.freeze({});
|
|
2684
|
+
|
|
2685
|
+
function columnProps(columns, key) {
|
|
2686
|
+
const column = columns.find((candidate) => candidate.key === key);
|
|
2687
|
+
if (!column) throw new Error(`Unknown column: ${key}`);
|
|
2688
|
+
return column.props;
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
function isAdjustableWidthColumn(column) {
|
|
2692
|
+
return (
|
|
2693
|
+
column?.adjustable &&
|
|
2694
|
+
Number.isSafeInteger(column.props.width) &&
|
|
2695
|
+
Number.isSafeInteger(column.minWidth)
|
|
2696
|
+
);
|
|
2697
|
+
}
|
|
2698
|
+
|
|
2699
|
+
function adjustableWidthKeys(tab) {
|
|
2700
|
+
if (!tab || !Array.isArray(tab.header)) return [];
|
|
2701
|
+
return tab.header.filter(isAdjustableWidthColumn).map((column) => column.key);
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
function selectWidthKey(tab, rememberedKey = null) {
|
|
2705
|
+
const keys = adjustableWidthKeys(tab);
|
|
2706
|
+
return keys.includes(rememberedKey) ? rememberedKey : (keys[0] ?? null);
|
|
2707
|
+
}
|
|
2708
|
+
|
|
2709
|
+
function cycleWidthKey(tab, selectedKey, direction) {
|
|
2710
|
+
if (!Number.isSafeInteger(direction) || direction === 0) return selectWidthKey(tab, selectedKey);
|
|
2711
|
+
const keys = adjustableWidthKeys(tab);
|
|
2712
|
+
if (keys.length === 0) return null;
|
|
2713
|
+
const current = keys.indexOf(selectedKey);
|
|
2714
|
+
if (current < 0) return direction > 0 ? keys[0] : keys[keys.length - 1];
|
|
2715
|
+
return keys[(current + Math.sign(direction) + keys.length) % keys.length];
|
|
2716
|
+
}
|
|
2717
|
+
|
|
2718
|
+
function resolveHeader(base, overrides = EMPTY_WIDTH_OVERRIDES) {
|
|
2719
|
+
let changed = false;
|
|
2720
|
+
const resolved = base.map((column) => {
|
|
2721
|
+
if (
|
|
2722
|
+
!isAdjustableWidthColumn(column) ||
|
|
2723
|
+
overrides == null ||
|
|
2724
|
+
!Object.hasOwn(overrides, column.key) ||
|
|
2725
|
+
!Number.isSafeInteger(overrides[column.key])
|
|
2726
|
+
) {
|
|
2727
|
+
return column;
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
const width = Math.max(column.minWidth, overrides[column.key]);
|
|
2731
|
+
if (width === column.props.width) return column;
|
|
2732
|
+
changed = true;
|
|
2733
|
+
return { ...column, props: { ...column.props, width } };
|
|
2734
|
+
});
|
|
2735
|
+
return changed ? resolved : base;
|
|
2736
|
+
}
|
|
2737
|
+
|
|
2738
|
+
function fitHeaderToFrame(preferred, defaults, frameCols) {
|
|
2739
|
+
const preferredFloor = minimumWidthFor(preferred);
|
|
2740
|
+
if (preferredFloor <= frameCols) return preferred;
|
|
2741
|
+
if (minimumWidthFor(defaults) > frameCols) return null;
|
|
2742
|
+
|
|
2743
|
+
let remaining = preferredFloor - frameCols;
|
|
2744
|
+
return preferred.map((column) => {
|
|
2745
|
+
if (remaining <= 0 || !Number.isSafeInteger(column.props.width)) return column;
|
|
2746
|
+
const defaultColumn = defaults.find((candidate) => candidate.key === column.key);
|
|
2747
|
+
const defaultWidth = defaultColumn?.props.width;
|
|
2748
|
+
if (!Number.isSafeInteger(defaultWidth) || column.props.width <= defaultWidth) return column;
|
|
2749
|
+
|
|
2750
|
+
const shrinkBy = Math.min(remaining, column.props.width - defaultWidth);
|
|
2751
|
+
remaining -= shrinkBy;
|
|
2752
|
+
return { ...column, props: { ...column.props, width: column.props.width - shrinkBy } };
|
|
2753
|
+
});
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
function effectiveHeaderFor(tab, tabOverrides, frameCols) {
|
|
2757
|
+
return fitHeaderToFrame(
|
|
2758
|
+
resolveHeader(tab.header, tabOverrides),
|
|
2759
|
+
tab.header,
|
|
2760
|
+
frameCols,
|
|
2761
|
+
);
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2764
|
+
function adjustWidth({ header, key, delta, frameCols }) {
|
|
2765
|
+
if (!Number.isSafeInteger(delta) || delta === 0 || !Number.isSafeInteger(frameCols)) return header;
|
|
2766
|
+
const index = header.findIndex((column) => column.key === key);
|
|
2767
|
+
if (index < 0) return header;
|
|
2768
|
+
|
|
2769
|
+
const column = header[index];
|
|
2770
|
+
if (!isAdjustableWidthColumn(column)) return header;
|
|
2771
|
+
|
|
2772
|
+
const available = Math.max(0, frameCols - minimumWidthFor(header));
|
|
2773
|
+
const maximum = column.props.width + available;
|
|
2774
|
+
const width = Math.min(maximum, Math.max(column.minWidth, column.props.width + delta));
|
|
2775
|
+
if (width === column.props.width) return header;
|
|
2776
|
+
|
|
2777
|
+
const adjusted = [...header];
|
|
2778
|
+
adjusted[index] = { ...column, props: { ...column.props, width } };
|
|
2779
|
+
return adjusted;
|
|
2780
|
+
}
|
|
2781
|
+
|
|
2782
|
+
function removeWidthOverride(overrides, tabKey, key) {
|
|
2783
|
+
if (!isRecord(overrides)) return overrides;
|
|
2784
|
+
const tabOverrides = overrides[tabKey];
|
|
2785
|
+
if (!isRecord(tabOverrides) || !Object.hasOwn(tabOverrides, key)) return overrides;
|
|
2786
|
+
|
|
2787
|
+
const nextTab = { ...tabOverrides };
|
|
2788
|
+
delete nextTab[key];
|
|
2789
|
+
if (Object.keys(nextTab).length > 0) return { ...overrides, [tabKey]: nextTab };
|
|
2790
|
+
|
|
2791
|
+
const next = { ...overrides };
|
|
2792
|
+
delete next[tabKey];
|
|
2793
|
+
return next;
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2796
|
+
// One immutable preference reducer for keyboard deltas and the pointer phase's
|
|
2797
|
+
// absolute drag snapshots. The optional geometry arguments are the live fitted
|
|
2798
|
+
// header and frame budget; omitting them gives unit callers semantic-minimum
|
|
2799
|
+
// clamping without inventing a terminal width.
|
|
2800
|
+
function updateWidthPreference({
|
|
2801
|
+
overrides,
|
|
2802
|
+
tab,
|
|
2803
|
+
key,
|
|
2804
|
+
nextWidth,
|
|
2805
|
+
effectiveHeader,
|
|
2806
|
+
frameCols,
|
|
2807
|
+
}) {
|
|
2808
|
+
if (!isRecord(overrides) || !tab || !Array.isArray(tab.header) || !Number.isSafeInteger(nextWidth)) {
|
|
2809
|
+
return overrides;
|
|
2810
|
+
}
|
|
2811
|
+
const defaultColumn = tab.header.find((column) => column.key === key);
|
|
2812
|
+
if (!isAdjustableWidthColumn(defaultColumn)) return overrides;
|
|
2813
|
+
|
|
2814
|
+
const tabOverrides = isRecord(overrides[tab.key]) ? overrides[tab.key] : EMPTY_WIDTH_OVERRIDES;
|
|
2815
|
+
const currentHeader = Array.isArray(effectiveHeader)
|
|
2816
|
+
? effectiveHeader
|
|
2817
|
+
: resolveHeader(tab.header, tabOverrides);
|
|
2818
|
+
const currentColumn = currentHeader.find((column) => column.key === key);
|
|
2819
|
+
if (!isAdjustableWidthColumn(currentColumn)) return overrides;
|
|
2820
|
+
|
|
2821
|
+
const delta = nextWidth - currentColumn.props.width;
|
|
2822
|
+
const liveFrameCols = Number.isSafeInteger(frameCols)
|
|
2823
|
+
? frameCols
|
|
2824
|
+
: minimumWidthFor(currentHeader) + Math.max(0, delta);
|
|
2825
|
+
const adjusted = adjustWidth({ header: currentHeader, key, delta, frameCols: liveFrameCols });
|
|
2826
|
+
if (adjusted === currentHeader) return overrides;
|
|
2827
|
+
const width = adjusted.find((column) => column.key === key).props.width;
|
|
2828
|
+
const hasOverride = Object.hasOwn(tabOverrides, key);
|
|
2829
|
+
|
|
2830
|
+
if (width === defaultColumn.props.width) {
|
|
2831
|
+
return removeWidthOverride(overrides, tab.key, key);
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
if (hasOverride && tabOverrides[key] === width) return overrides;
|
|
2835
|
+
return { ...overrides, [tab.key]: { ...tabOverrides, [key]: width } };
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
function resetWidthPreference(overrides, tabKey, key) {
|
|
2839
|
+
if (!isRecord(overrides)) return overrides;
|
|
2840
|
+
const tab = tabForKey(tabKey);
|
|
2841
|
+
const column = tab?.header.find((candidate) => candidate.key === key);
|
|
2842
|
+
if (!isAdjustableWidthColumn(column)) return overrides;
|
|
2843
|
+
return removeWidthOverride(overrides, tabKey, key);
|
|
2844
|
+
}
|
|
2845
|
+
|
|
2846
|
+
function resetTabWidthPreferences(overrides, tabKey) {
|
|
2847
|
+
if (!isRecord(overrides) || !Object.hasOwn(overrides, tabKey)) return overrides;
|
|
2848
|
+
if (!tabForKey(tabKey)) return overrides;
|
|
2849
|
+
const next = { ...overrides };
|
|
2850
|
+
delete next[tabKey];
|
|
2851
|
+
return next;
|
|
2852
|
+
}
|
|
2853
|
+
|
|
2217
2854
|
// The narrowest width each tab's table can render without its fixed columns
|
|
2218
2855
|
// overflowing the frame. Derived from the header descriptors rather than
|
|
2219
2856
|
// hard-coded, so adding or resizing a column cannot silently invalidate it.
|
|
@@ -2226,6 +2863,448 @@ function minimumWidthFor(header) {
|
|
|
2226
2863
|
// + 2 border verticals, + 2 paddingX, + at least 4 columns for TITLE.
|
|
2227
2864
|
return fixed + 8;
|
|
2228
2865
|
}
|
|
2866
|
+
|
|
2867
|
+
// ---------- Width preferences ----------
|
|
2868
|
+
|
|
2869
|
+
const WIDTH_PREFERENCES_VERSION = 1;
|
|
2870
|
+
let widthPreferenceTempSequence = 0;
|
|
2871
|
+
|
|
2872
|
+
function widthPreferencesPath({
|
|
2873
|
+
env = process.env,
|
|
2874
|
+
platform = process.platform,
|
|
2875
|
+
home = homedir(),
|
|
2876
|
+
} = {}) {
|
|
2877
|
+
const xdgRoot = env?.XDG_CONFIG_HOME;
|
|
2878
|
+
const root =
|
|
2879
|
+
typeof xdgRoot === "string" && xdgRoot.length > 0 && isAbsolute(xdgRoot)
|
|
2880
|
+
? xdgRoot
|
|
2881
|
+
: platform === "darwin"
|
|
2882
|
+
? join(home, "Library", "Application Support")
|
|
2883
|
+
: join(home, ".config");
|
|
2884
|
+
return join(root, "gh-glance", "preferences.json");
|
|
2885
|
+
}
|
|
2886
|
+
|
|
2887
|
+
function isRecord(value) {
|
|
2888
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
function normalizeWidthOverrides(overrides, tabs = TABS, { omitDefaults = false } = {}) {
|
|
2892
|
+
if (!isRecord(overrides)) return {};
|
|
2893
|
+
|
|
2894
|
+
const normalized = {};
|
|
2895
|
+
for (const tab of tabs) {
|
|
2896
|
+
if (!Object.hasOwn(overrides, tab.key) || !isRecord(overrides[tab.key])) continue;
|
|
2897
|
+
|
|
2898
|
+
const source = overrides[tab.key];
|
|
2899
|
+
const tabOverrides = {};
|
|
2900
|
+
for (const column of tab.header) {
|
|
2901
|
+
if (!isAdjustableWidthColumn(column) || !Object.hasOwn(source, column.key)) continue;
|
|
2902
|
+
const width = source[column.key];
|
|
2903
|
+
if (!Number.isSafeInteger(width) || width < column.minWidth) continue;
|
|
2904
|
+
if (omitDefaults && width === column.props.width) continue;
|
|
2905
|
+
tabOverrides[column.key] = width;
|
|
2906
|
+
}
|
|
2907
|
+
if (Object.keys(tabOverrides).length > 0) normalized[tab.key] = tabOverrides;
|
|
2908
|
+
}
|
|
2909
|
+
return normalized;
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2912
|
+
function decodeWidthPreferences(raw, tabs = TABS) {
|
|
2913
|
+
let document;
|
|
2914
|
+
try {
|
|
2915
|
+
document = JSON.parse(raw);
|
|
2916
|
+
} catch (error) {
|
|
2917
|
+
return { preferences: {}, error };
|
|
2918
|
+
}
|
|
2919
|
+
|
|
2920
|
+
if (
|
|
2921
|
+
!isRecord(document) ||
|
|
2922
|
+
!Object.hasOwn(document, "version") ||
|
|
2923
|
+
document.version !== WIDTH_PREFERENCES_VERSION ||
|
|
2924
|
+
!Object.hasOwn(document, "tabs") ||
|
|
2925
|
+
!isRecord(document.tabs)
|
|
2926
|
+
) {
|
|
2927
|
+
return { preferences: {}, error: new Error("Unsupported width preferences document") };
|
|
2928
|
+
}
|
|
2929
|
+
|
|
2930
|
+
return { preferences: normalizeWidthOverrides(document.tabs, tabs), error: null };
|
|
2931
|
+
}
|
|
2932
|
+
|
|
2933
|
+
function parseWidthPreferences(raw, tabs = TABS) {
|
|
2934
|
+
return decodeWidthPreferences(raw, tabs).preferences;
|
|
2935
|
+
}
|
|
2936
|
+
|
|
2937
|
+
function serializeWidthPreferences(overrides, tabs = TABS) {
|
|
2938
|
+
const document = {
|
|
2939
|
+
version: WIDTH_PREFERENCES_VERSION,
|
|
2940
|
+
tabs: normalizeWidthOverrides(overrides, tabs, { omitDefaults: true }),
|
|
2941
|
+
};
|
|
2942
|
+
return `${JSON.stringify(document, null, 2)}\n`;
|
|
2943
|
+
}
|
|
2944
|
+
|
|
2945
|
+
function loadWidthPreferences(path, tabs = TABS) {
|
|
2946
|
+
let raw;
|
|
2947
|
+
try {
|
|
2948
|
+
raw = readFileSync(path, "utf8");
|
|
2949
|
+
} catch (error) {
|
|
2950
|
+
return error?.code === "ENOENT"
|
|
2951
|
+
? { preferences: {}, error: null }
|
|
2952
|
+
: { preferences: {}, error };
|
|
2953
|
+
}
|
|
2954
|
+
return decodeWidthPreferences(raw, tabs);
|
|
2955
|
+
}
|
|
2956
|
+
|
|
2957
|
+
function saveWidthPreferences(path, overrides, tabs = TABS) {
|
|
2958
|
+
let tempPath = null;
|
|
2959
|
+
try {
|
|
2960
|
+
const payload = serializeWidthPreferences(overrides, tabs);
|
|
2961
|
+
const parent = dirname(path);
|
|
2962
|
+
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) {
|
|
2974
|
+
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.
|
|
2979
|
+
}
|
|
2980
|
+
}
|
|
2981
|
+
return { ok: false, error };
|
|
2982
|
+
}
|
|
2983
|
+
}
|
|
2984
|
+
|
|
2985
|
+
function createCoalescedWriter({ write, delay = 200, onResult = () => {} }) {
|
|
2986
|
+
let latest;
|
|
2987
|
+
let generation = 0;
|
|
2988
|
+
let persistedGeneration = 0;
|
|
2989
|
+
let timer = null;
|
|
2990
|
+
let flushPromise = null;
|
|
2991
|
+
|
|
2992
|
+
function report(result) {
|
|
2993
|
+
try {
|
|
2994
|
+
onResult(result);
|
|
2995
|
+
} catch {
|
|
2996
|
+
// Persistence reporting is advisory and must not reach Ink's render path.
|
|
2997
|
+
}
|
|
2998
|
+
return result;
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
function failed(error) {
|
|
3002
|
+
return { ok: false, error };
|
|
3003
|
+
}
|
|
3004
|
+
|
|
3005
|
+
async function writeGeneration(value) {
|
|
3006
|
+
let pending;
|
|
3007
|
+
try {
|
|
3008
|
+
// Keep this call before the first await. The production writer is
|
|
3009
|
+
// synchronous, so an unmount flush completes the filesystem replacement
|
|
3010
|
+
// before React hands terminal teardown back to its caller.
|
|
3011
|
+
pending = write(value);
|
|
3012
|
+
} catch (error) {
|
|
3013
|
+
return failed(error);
|
|
3014
|
+
}
|
|
3015
|
+
|
|
3016
|
+
try {
|
|
3017
|
+
const result = await pending;
|
|
3018
|
+
return result?.ok === false ? result : (result ?? { ok: true });
|
|
3019
|
+
} catch (error) {
|
|
3020
|
+
return failed(error);
|
|
3021
|
+
}
|
|
3022
|
+
}
|
|
3023
|
+
|
|
3024
|
+
async function drain() {
|
|
3025
|
+
let result = { ok: true, written: false };
|
|
3026
|
+
while (persistedGeneration < generation) {
|
|
3027
|
+
const attemptedGeneration = generation;
|
|
3028
|
+
const value = latest;
|
|
3029
|
+
result = await writeGeneration(value);
|
|
3030
|
+
|
|
3031
|
+
const isLatest = attemptedGeneration === generation;
|
|
3032
|
+
if (result.ok !== false) {
|
|
3033
|
+
persistedGeneration = Math.max(persistedGeneration, attemptedGeneration);
|
|
3034
|
+
}
|
|
3035
|
+
|
|
3036
|
+
// The loop serializes a superseding write behind this completion, so its
|
|
3037
|
+
// result owns the warning state. A stale completion is deliberately not
|
|
3038
|
+
// reported, while async replacements can neither race on disk nor report
|
|
3039
|
+
// out of order.
|
|
3040
|
+
if (!isLatest) continue;
|
|
3041
|
+
report(result);
|
|
3042
|
+
// Leave the latest generation dirty after failure. A later flush/dispose
|
|
3043
|
+
// retries it; concurrent callers still share this completed attempt.
|
|
3044
|
+
if (result.ok === false) return result;
|
|
3045
|
+
}
|
|
3046
|
+
return result;
|
|
3047
|
+
}
|
|
3048
|
+
|
|
3049
|
+
function flush() {
|
|
3050
|
+
if (timer !== null) {
|
|
3051
|
+
clearTimeout(timer);
|
|
3052
|
+
timer = null;
|
|
3053
|
+
}
|
|
3054
|
+
if (flushPromise !== null) return flushPromise;
|
|
3055
|
+
if (persistedGeneration >= generation) {
|
|
3056
|
+
return Promise.resolve({ ok: true, written: false });
|
|
3057
|
+
}
|
|
3058
|
+
|
|
3059
|
+
const pending = drain().finally(() => {
|
|
3060
|
+
if (flushPromise === pending) flushPromise = null;
|
|
3061
|
+
});
|
|
3062
|
+
flushPromise = pending;
|
|
3063
|
+
return pending;
|
|
3064
|
+
}
|
|
3065
|
+
|
|
3066
|
+
function schedule(value) {
|
|
3067
|
+
latest = value;
|
|
3068
|
+
generation += 1;
|
|
3069
|
+
if (timer === null) {
|
|
3070
|
+
timer = setTimeout(() => {
|
|
3071
|
+
timer = null;
|
|
3072
|
+
void flush();
|
|
3073
|
+
}, delay);
|
|
3074
|
+
timer.unref?.();
|
|
3075
|
+
} else {
|
|
3076
|
+
timer.refresh();
|
|
3077
|
+
}
|
|
3078
|
+
}
|
|
3079
|
+
|
|
3080
|
+
return {
|
|
3081
|
+
schedule,
|
|
3082
|
+
flush,
|
|
3083
|
+
dispose: flush,
|
|
3084
|
+
};
|
|
3085
|
+
}
|
|
3086
|
+
|
|
3087
|
+
const createWidthPreferenceWriter = createCoalescedWriter;
|
|
3088
|
+
|
|
3089
|
+
// ---------- Last-known-good dashboard cache ----------
|
|
3090
|
+
|
|
3091
|
+
const DASHBOARD_CACHE_VERSION = 1;
|
|
3092
|
+
const MAX_DASHBOARD_CACHE_TARGETS = 5;
|
|
3093
|
+
const MAX_DASHBOARD_CACHE_ROWS_PER_TAB = 60;
|
|
3094
|
+
let dashboardCacheTempSequence = 0;
|
|
3095
|
+
|
|
3096
|
+
function dashboardCachePath(options = {}) {
|
|
3097
|
+
return join(dirname(widthPreferencesPath(options)), "dashboard-cache.json");
|
|
3098
|
+
}
|
|
3099
|
+
|
|
3100
|
+
// Explicit targets are stable across working directories. In inferred mode the
|
|
3101
|
+
// directory is the only repository identity available without making another
|
|
3102
|
+
// GitHub call -- exactly the call that cannot succeed when this cache is needed.
|
|
3103
|
+
// JSON encoding avoids delimiter ambiguity in host, repo, and path strings.
|
|
3104
|
+
function dashboardCacheTarget({ repo = null, ghRepo = null, host = null, cwd = process.cwd() } = {}) {
|
|
3105
|
+
const target = repo || ghRepo;
|
|
3106
|
+
return target
|
|
3107
|
+
? JSON.stringify({ kind: "repo", host: String(host ?? ""), repo: String(target) })
|
|
3108
|
+
: JSON.stringify({ kind: "cwd", host: String(host ?? ""), cwd: String(cwd) });
|
|
3109
|
+
}
|
|
3110
|
+
|
|
3111
|
+
function cacheTimestamp(value) {
|
|
3112
|
+
return Number.isFinite(value) && value >= 0 ? value : null;
|
|
3113
|
+
}
|
|
3114
|
+
|
|
3115
|
+
function normalizeCachedItem(tabKey, item) {
|
|
3116
|
+
if (!isRecord(item)) return null;
|
|
3117
|
+
if (tabKey === "actions") {
|
|
3118
|
+
if (!Number.isSafeInteger(item.databaseId)) return null;
|
|
3119
|
+
return {
|
|
3120
|
+
databaseId: item.databaseId,
|
|
3121
|
+
displayTitle: safe(item.displayTitle),
|
|
3122
|
+
workflowName: safe(item.workflowName),
|
|
3123
|
+
number: Number.isSafeInteger(item.number) ? item.number : null,
|
|
3124
|
+
headBranch: safe(item.headBranch),
|
|
3125
|
+
status: safe(item.status),
|
|
3126
|
+
conclusion: item.conclusion == null ? null : safe(item.conclusion),
|
|
3127
|
+
startedAt: safe(item.startedAt),
|
|
3128
|
+
updatedAt: safe(item.updatedAt),
|
|
3129
|
+
};
|
|
3130
|
+
}
|
|
3131
|
+
if (tabKey === "issues") {
|
|
3132
|
+
if (!Number.isSafeInteger(item.number)) return null;
|
|
3133
|
+
return {
|
|
3134
|
+
number: item.number,
|
|
3135
|
+
title: safe(item.title),
|
|
3136
|
+
author: safe(item.author),
|
|
3137
|
+
label: safe(item.label),
|
|
3138
|
+
updatedAt: safe(item.updatedAt),
|
|
3139
|
+
};
|
|
3140
|
+
}
|
|
3141
|
+
if (tabKey === "prs") {
|
|
3142
|
+
if (!Number.isSafeInteger(item.number)) return null;
|
|
3143
|
+
return {
|
|
3144
|
+
number: item.number,
|
|
3145
|
+
title: safe(item.title),
|
|
3146
|
+
author: safe(item.author),
|
|
3147
|
+
headRefName: safe(item.headRefName),
|
|
3148
|
+
isDraft: Boolean(item.isDraft),
|
|
3149
|
+
reviewDecision: safe(item.reviewDecision),
|
|
3150
|
+
updatedAt: safe(item.updatedAt),
|
|
3151
|
+
};
|
|
3152
|
+
}
|
|
3153
|
+
if (tabKey === "security") {
|
|
3154
|
+
const id = safe(item.id);
|
|
3155
|
+
if (!id) return null;
|
|
3156
|
+
return {
|
|
3157
|
+
id,
|
|
3158
|
+
kind: safe(item.kind),
|
|
3159
|
+
severity: safe(item.severity),
|
|
3160
|
+
title: safe(item.title),
|
|
3161
|
+
detail: safe(item.detail),
|
|
3162
|
+
createdAt: safe(item.createdAt),
|
|
3163
|
+
};
|
|
3164
|
+
}
|
|
3165
|
+
return null;
|
|
3166
|
+
}
|
|
3167
|
+
|
|
3168
|
+
function normalizeDashboardCacheEntry(entry) {
|
|
3169
|
+
if (!isRecord(entry) || !isRecord(entry.tabs)) return null;
|
|
3170
|
+
const tabs = {};
|
|
3171
|
+
for (const tabKey of TAB_KEYS) {
|
|
3172
|
+
const tab = entry.tabs[tabKey];
|
|
3173
|
+
if (!isRecord(tab) || !Array.isArray(tab.data)) continue;
|
|
3174
|
+
const lastOk = cacheTimestamp(tab.lastOk);
|
|
3175
|
+
if (
|
|
3176
|
+
lastOk === null ||
|
|
3177
|
+
!isRecord(tab.meta) ||
|
|
3178
|
+
cacheTimestamp(tab.meta.at) === null ||
|
|
3179
|
+
typeof tab.meta.truncated !== "boolean"
|
|
3180
|
+
) {
|
|
3181
|
+
continue;
|
|
3182
|
+
}
|
|
3183
|
+
const normalizedData = tab.data.map((item) => normalizeCachedItem(tabKey, item)).filter(Boolean);
|
|
3184
|
+
// A malformed row means this tab was not written by the current schema.
|
|
3185
|
+
// Reject the tab rather than turning a corrupt non-empty payload into a
|
|
3186
|
+
// confident empty state.
|
|
3187
|
+
if (normalizedData.length !== tab.data.length) continue;
|
|
3188
|
+
const data = normalizedData.slice(0, MAX_DASHBOARD_CACHE_ROWS_PER_TAB);
|
|
3189
|
+
const meta = {
|
|
3190
|
+
at: tab.meta.at,
|
|
3191
|
+
truncated: tab.meta.truncated || normalizedData.length > MAX_DASHBOARD_CACHE_ROWS_PER_TAB,
|
|
3192
|
+
};
|
|
3193
|
+
tabs[tabKey] = { data, meta, lastOk };
|
|
3194
|
+
}
|
|
3195
|
+
if (Object.keys(tabs).length === 0) return null;
|
|
3196
|
+
const latestTab = Math.max(...Object.values(tabs).map((tab) => tab.lastOk));
|
|
3197
|
+
return {
|
|
3198
|
+
tabs,
|
|
3199
|
+
securityNotes: Array.isArray(entry.securityNotes)
|
|
3200
|
+
? entry.securityNotes
|
|
3201
|
+
.filter((note) => typeof note === "string")
|
|
3202
|
+
.slice(0, ALERT_SOURCES.length)
|
|
3203
|
+
.map((note) => safe(note))
|
|
3204
|
+
: [],
|
|
3205
|
+
securityBlind: typeof entry.securityBlind === "boolean" ? entry.securityBlind : false,
|
|
3206
|
+
updatedAt: cacheTimestamp(entry.updatedAt) ?? latestTab,
|
|
3207
|
+
};
|
|
3208
|
+
}
|
|
3209
|
+
|
|
3210
|
+
function normalizeDashboardCache(cache) {
|
|
3211
|
+
if (!isRecord(cache)) return {};
|
|
3212
|
+
return limitDashboardCache(
|
|
3213
|
+
Object.fromEntries(
|
|
3214
|
+
Object.entries(cache)
|
|
3215
|
+
.map(([target, entry]) => [target, normalizeDashboardCacheEntry(entry)])
|
|
3216
|
+
.filter(([target, entry]) => target.length > 0 && entry !== null),
|
|
3217
|
+
),
|
|
3218
|
+
);
|
|
3219
|
+
}
|
|
3220
|
+
|
|
3221
|
+
function limitDashboardCache(cache) {
|
|
3222
|
+
return Object.fromEntries(
|
|
3223
|
+
Object.entries(cache)
|
|
3224
|
+
.sort((left, right) => right[1].updatedAt - left[1].updatedAt)
|
|
3225
|
+
.slice(0, MAX_DASHBOARD_CACHE_TARGETS),
|
|
3226
|
+
);
|
|
3227
|
+
}
|
|
3228
|
+
|
|
3229
|
+
function mergeDashboardCacheEntry(cache, target, entry) {
|
|
3230
|
+
const normalized = normalizeDashboardCacheEntry(entry);
|
|
3231
|
+
return normalized === null ? cache : limitDashboardCache({ ...cache, [target]: normalized });
|
|
3232
|
+
}
|
|
3233
|
+
|
|
3234
|
+
function decodeDashboardCache(raw) {
|
|
3235
|
+
let document;
|
|
3236
|
+
try {
|
|
3237
|
+
document = JSON.parse(raw);
|
|
3238
|
+
} catch (error) {
|
|
3239
|
+
return { cache: {}, error };
|
|
3240
|
+
}
|
|
3241
|
+
if (
|
|
3242
|
+
!isRecord(document) ||
|
|
3243
|
+
document.version !== DASHBOARD_CACHE_VERSION ||
|
|
3244
|
+
!isRecord(document.targets)
|
|
3245
|
+
) {
|
|
3246
|
+
return { cache: {}, error: new Error("Unsupported dashboard cache document") };
|
|
3247
|
+
}
|
|
3248
|
+
return { cache: normalizeDashboardCache(document.targets), error: null };
|
|
3249
|
+
}
|
|
3250
|
+
|
|
3251
|
+
function serializeDashboardCache(cache, { normalized = false } = {}) {
|
|
3252
|
+
return `${JSON.stringify(
|
|
3253
|
+
{ version: DASHBOARD_CACHE_VERSION, targets: normalized ? cache : normalizeDashboardCache(cache) },
|
|
3254
|
+
null,
|
|
3255
|
+
2,
|
|
3256
|
+
)}\n`;
|
|
3257
|
+
}
|
|
3258
|
+
|
|
3259
|
+
function loadDashboardCache(path, target) {
|
|
3260
|
+
let raw;
|
|
3261
|
+
try {
|
|
3262
|
+
raw = readFileSync(path, "utf8");
|
|
3263
|
+
} catch (error) {
|
|
3264
|
+
return error?.code === "ENOENT"
|
|
3265
|
+
? { cache: {}, entry: null, error: null }
|
|
3266
|
+
: { cache: {}, entry: null, error };
|
|
3267
|
+
}
|
|
3268
|
+
const decoded = decodeDashboardCache(raw);
|
|
3269
|
+
return {
|
|
3270
|
+
cache: decoded.cache,
|
|
3271
|
+
entry: pick(decoded.cache, target, null),
|
|
3272
|
+
error: decoded.error,
|
|
3273
|
+
};
|
|
3274
|
+
}
|
|
3275
|
+
|
|
3276
|
+
function saveDashboardCache(path, cache, { normalized = false } = {}) {
|
|
3277
|
+
let tempPath = null;
|
|
3278
|
+
try {
|
|
3279
|
+
const parent = dirname(path);
|
|
3280
|
+
mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
3281
|
+
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) {
|
|
3293
|
+
try {
|
|
3294
|
+
unlinkSync(tempPath);
|
|
3295
|
+
} catch {
|
|
3296
|
+
// The write may have failed before creating this exact temporary file.
|
|
3297
|
+
}
|
|
3298
|
+
}
|
|
3299
|
+
return { ok: false, error };
|
|
3300
|
+
}
|
|
3301
|
+
}
|
|
3302
|
+
|
|
3303
|
+
function nextSecurityRaw(previousRaw, raw, blind) {
|
|
3304
|
+
if (blind === true) return null;
|
|
3305
|
+
return previousRaw === raw ? previousRaw : raw;
|
|
3306
|
+
}
|
|
3307
|
+
|
|
2229
3308
|
// Below this the full column set cannot render without its fixed columns
|
|
2230
3309
|
// overflowing the frame -- rows hard-wrap and ink switches to clearing and
|
|
2231
3310
|
// repainting the whole screen every frame. Worse, in the band just above the
|
|
@@ -2314,6 +3393,7 @@ const KEY_HINTS = [
|
|
|
2314
3393
|
{ label: "Move", keys: "↑↓" },
|
|
2315
3394
|
{ label: "Open", keys: "Ent" },
|
|
2316
3395
|
{ label: "Refresh", keys: "r" },
|
|
3396
|
+
{ label: "Width", keys: "w" },
|
|
2317
3397
|
{ label: "Quit", keys: "q" },
|
|
2318
3398
|
];
|
|
2319
3399
|
|
|
@@ -2340,6 +3420,26 @@ const REMOTE_SETUP_NONINTERACTIVE_LINES = [
|
|
|
2340
3420
|
// finishes. Wide enough for the spinner, a space and "Fetching".
|
|
2341
3421
|
const FETCHING_WIDTH = 12;
|
|
2342
3422
|
|
|
3423
|
+
function widthStatusText({ label, width, cols, saveError = false }) {
|
|
3424
|
+
const budget = Number.isSafeInteger(cols) ? Math.max(0, cols) : 0;
|
|
3425
|
+
const safeLabel = String(label ?? "").replace(/[^\x20-\x7e]/g, "?");
|
|
3426
|
+
const safeWidth = Number.isSafeInteger(width) ? String(width) : "?";
|
|
3427
|
+
const variants = saveError
|
|
3428
|
+
? [
|
|
3429
|
+
`Width: ${safeLabel} ${safeWidth} Widths not saved`,
|
|
3430
|
+
`${safeLabel} ${safeWidth} Widths not saved`,
|
|
3431
|
+
"Widths not saved",
|
|
3432
|
+
]
|
|
3433
|
+
: [
|
|
3434
|
+
`Width: ${safeLabel} ${safeWidth} Tab select <- -> resize r reset Esc done`,
|
|
3435
|
+
`Width: ${safeLabel} ${safeWidth} <- -> resize r reset Esc done`,
|
|
3436
|
+
`${safeLabel} ${safeWidth} <- -> r reset Esc done`,
|
|
3437
|
+
`${safeLabel} ${safeWidth} <- -> r Esc`,
|
|
3438
|
+
];
|
|
3439
|
+
const fitting = variants.find((variant) => variant.length <= budget);
|
|
3440
|
+
return fitting ?? variants[variants.length - 1].slice(0, budget);
|
|
3441
|
+
}
|
|
3442
|
+
|
|
2343
3443
|
// Compact drops the labels and keeps the keys, separated by a space:
|
|
2344
3444
|
// "↑↓ Ent r q". No constant for its width -- nothing branches on it, and the
|
|
2345
3445
|
// keys are short enough that it fits anywhere the frame itself does.
|
|
@@ -2356,7 +3456,37 @@ const FETCHING_WIDTH = 12;
|
|
|
2356
3456
|
// finding at a glance, so they get the accent colour and the words describing
|
|
2357
3457
|
// them stay dim. The accent is the panel-title cyan rather than the amber used
|
|
2358
3458
|
// for in-progress status, so amber means exactly one thing across the product.
|
|
2359
|
-
function StatusBar({
|
|
3459
|
+
function StatusBar({
|
|
3460
|
+
fetching,
|
|
3461
|
+
spin,
|
|
3462
|
+
stale,
|
|
3463
|
+
throttle,
|
|
3464
|
+
interactive,
|
|
3465
|
+
cols,
|
|
3466
|
+
remoteSetup = false,
|
|
3467
|
+
widthMode = false,
|
|
3468
|
+
widthColumn = null,
|
|
3469
|
+
widthSaveError = null,
|
|
3470
|
+
}) {
|
|
3471
|
+
// Width mode owns the whole bar, so the throttle badge is deliberately not
|
|
3472
|
+
// shown here: this state is transient and explicitly entered, and the widened
|
|
3473
|
+
// interval is still reported the moment the user leaves it.
|
|
3474
|
+
if (widthMode && widthColumn) {
|
|
3475
|
+
return e(
|
|
3476
|
+
Box,
|
|
3477
|
+
{ flexDirection: "row" },
|
|
3478
|
+
e(
|
|
3479
|
+
Text,
|
|
3480
|
+
{ color: widthSaveError ? ATTENTION : undefined, wrap: "truncate-end" },
|
|
3481
|
+
widthStatusText({
|
|
3482
|
+
label: widthColumn.label,
|
|
3483
|
+
width: widthColumn.props.width,
|
|
3484
|
+
cols,
|
|
3485
|
+
saveError: Boolean(widthSaveError),
|
|
3486
|
+
}),
|
|
3487
|
+
),
|
|
3488
|
+
);
|
|
3489
|
+
}
|
|
2360
3490
|
// Without raw mode none of the key handlers run, so advertising them would be
|
|
2361
3491
|
// telling the user something untrue about what the app can do. Ctrl+C still
|
|
2362
3492
|
// works there, because the tty delivers a real SIGINT.
|
|
@@ -2393,6 +3523,12 @@ function StatusBar({ fetching, spin, stale, interactive, cols, remoteSetup = fal
|
|
|
2393
3523
|
stale
|
|
2394
3524
|
? e(Box, { marginRight: 1, flexShrink: 0 }, e(Text, { color: ATTENTION }, stale))
|
|
2395
3525
|
: null,
|
|
3526
|
+
// Same free-slot treatment as `stale` above. Dim rather than ATTENTION: a
|
|
3527
|
+
// widened interval is the throttle working, not a failure, and colouring it
|
|
3528
|
+
// like a problem would send the user looking for one.
|
|
3529
|
+
throttle
|
|
3530
|
+
? e(Box, { marginRight: 1, flexShrink: 0 }, e(Text, { dimColor: true }, throttle))
|
|
3531
|
+
: null,
|
|
2396
3532
|
...hints
|
|
2397
3533
|
.flatMap((hint, i) => [
|
|
2398
3534
|
i > 0 &&
|
|
@@ -2462,21 +3598,75 @@ function useTerminalSize(stdout) {
|
|
|
2462
3598
|
function App({ onCreateRemote = () => {} } = {}) {
|
|
2463
3599
|
const { stdout } = useStdout();
|
|
2464
3600
|
const { isRawModeSupported } = useStdin();
|
|
2465
|
-
const { exit } = useApp();
|
|
3601
|
+
const { exit, suspendTerminal } = useApp();
|
|
2466
3602
|
const [activeIndex, setActiveIndex] = useState(runtime.initialTabIndex);
|
|
3603
|
+
const [preferencePath] = useState(() => widthPreferencesPath());
|
|
3604
|
+
const [widthOverrides, setWidthOverrides] = useState(
|
|
3605
|
+
() => loadWidthPreferences(preferencePath).preferences,
|
|
3606
|
+
);
|
|
3607
|
+
const widthOverridesRef = useRef(widthOverrides);
|
|
3608
|
+
widthOverridesRef.current = widthOverrides;
|
|
3609
|
+
const [widthSaveError, setWidthSaveError] = useState(null);
|
|
3610
|
+
const widthPreferencesMountedRef = useRef(true);
|
|
3611
|
+
const [widthPreferenceWriter] = useState(() =>
|
|
3612
|
+
createWidthPreferenceWriter({
|
|
3613
|
+
write: (overrides) => saveWidthPreferences(preferencePath, overrides),
|
|
3614
|
+
onResult: (result) => {
|
|
3615
|
+
if (!widthPreferencesMountedRef.current) return;
|
|
3616
|
+
setWidthSaveError(
|
|
3617
|
+
result?.ok === false
|
|
3618
|
+
? (result.error ?? new Error("Width preferences could not be saved"))
|
|
3619
|
+
: null,
|
|
3620
|
+
);
|
|
3621
|
+
},
|
|
3622
|
+
}),
|
|
3623
|
+
);
|
|
3624
|
+
useEffect(() => {
|
|
3625
|
+
widthPreferencesMountedRef.current = true;
|
|
3626
|
+
return () => {
|
|
3627
|
+
// saveWidthPreferences() itself runs synchronously inside dispose(), but
|
|
3628
|
+
// writer result reporting settles through a promise. Mark unmounted first
|
|
3629
|
+
// so that later callback cannot enqueue state on a departing App.
|
|
3630
|
+
widthPreferencesMountedRef.current = false;
|
|
3631
|
+
void widthPreferenceWriter.dispose();
|
|
3632
|
+
};
|
|
3633
|
+
}, [widthPreferenceWriter]);
|
|
3634
|
+
const [cachePath] = useState(() => dashboardCachePath());
|
|
3635
|
+
const [cacheTarget] = useState(() =>
|
|
3636
|
+
dashboardCacheTarget({
|
|
3637
|
+
repo: runtime.repo,
|
|
3638
|
+
ghRepo: process.env.GH_REPO,
|
|
3639
|
+
host: runtime.host ?? process.env.GH_HOST,
|
|
3640
|
+
cwd: process.cwd(),
|
|
3641
|
+
}),
|
|
3642
|
+
);
|
|
3643
|
+
const [loadedCache] = useState(() => loadDashboardCache(cachePath, cacheTarget));
|
|
3644
|
+
const dashboardCacheRef = useRef(loadedCache.cache);
|
|
3645
|
+
const dashboardCacheTargetRef = useRef(cacheTarget);
|
|
3646
|
+
const [dashboardCacheWriter] = useState(() =>
|
|
3647
|
+
createCoalescedWriter({
|
|
3648
|
+
write: (cache) => saveDashboardCache(cachePath, cache, { normalized: true }),
|
|
3649
|
+
}),
|
|
3650
|
+
);
|
|
3651
|
+
useEffect(() => () => {
|
|
3652
|
+
// Production writes start synchronously, so a pending latest snapshot is
|
|
3653
|
+
// not lost on quit. The cache is bounded; ignoring the advisory result keeps
|
|
3654
|
+
// a filesystem error from replacing terminal teardown.
|
|
3655
|
+
void dashboardCacheWriter.dispose();
|
|
3656
|
+
}, [dashboardCacheWriter]);
|
|
3657
|
+
const cachedEntry = loadedCache.entry;
|
|
2467
3658
|
// `null` means "never resolved" -- distinct from `[]`, which means "resolved
|
|
2468
3659
|
// and genuinely empty". The tab bar and the body render those differently.
|
|
2469
|
-
const [data, setData] = useState(
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
const [securityNotes, setSecurityNotes] = useState([]);
|
|
3660
|
+
const [data, setData] = useState(() =>
|
|
3661
|
+
Object.fromEntries(TABS.map((candidate) => [candidate.key, cachedEntry?.tabs[candidate.key]?.data ?? null])),
|
|
3662
|
+
);
|
|
3663
|
+
const [meta, setMeta] = useState(() =>
|
|
3664
|
+
Object.fromEntries(TABS.map((candidate) => [candidate.key, cachedEntry?.tabs[candidate.key]?.meta ?? null])),
|
|
3665
|
+
);
|
|
3666
|
+
const [securityNotes, setSecurityNotes] = useState(() => cachedEntry?.securityNotes ?? []);
|
|
2477
3667
|
// Whether the Security tab is currently unable to see its endpoints, as
|
|
2478
3668
|
// opposed to seeing that they are switched off. Drives the count marker.
|
|
2479
|
-
const [securityBlind, setSecurityBlind] = useState(false);
|
|
3669
|
+
const [securityBlind, setSecurityBlind] = useState(() => cachedEntry?.securityBlind ?? false);
|
|
2480
3670
|
// The `?` overlay. Renders only on a keypress, so consecutive idle frames are
|
|
2481
3671
|
// still byte-identical and the redraw suppression is untouched.
|
|
2482
3672
|
const [showHelp, setShowHelp] = useState(false);
|
|
@@ -2487,6 +3677,9 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
2487
3677
|
const [errors, setErrors] = useState({ actions: null, issues: null, prs: null, security: null });
|
|
2488
3678
|
const [failureContext, setFailureContext] = useState(null);
|
|
2489
3679
|
const [loading, setLoading] = useState({ actions: true, issues: true, prs: true, security: true });
|
|
3680
|
+
// The widened poll interval in ms while the adaptive throttle is holding one,
|
|
3681
|
+
// or null at the configured refresh. Only ever set from inside the poll effect.
|
|
3682
|
+
const [throttleMs, setThrottleMs] = useState(null);
|
|
2490
3683
|
const [now, setNow] = useState(new Date());
|
|
2491
3684
|
const { rows, cols, useShortLabels } = useTerminalSize(stdout);
|
|
2492
3685
|
const [frame, setFrame] = useState(0);
|
|
@@ -2495,6 +3688,8 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
2495
3688
|
// renders byte-identical frames and ink still writes nothing.
|
|
2496
3689
|
const [selected, setSelected] = useState({});
|
|
2497
3690
|
const [offset, setOffset] = useState({});
|
|
3691
|
+
const [widthMode, setWidthMode] = useState(false);
|
|
3692
|
+
const [selectedWidthKeyByTab, setSelectedWidthKeyByTab] = useState({});
|
|
2498
3693
|
|
|
2499
3694
|
const tab = TABS[activeIndex];
|
|
2500
3695
|
const tabError = errors[tab.key];
|
|
@@ -2559,12 +3754,18 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
2559
3754
|
|
|
2560
3755
|
const inFlightRef = useRef({});
|
|
2561
3756
|
const rawRef = useRef({});
|
|
2562
|
-
const cleanRef = useRef({});
|
|
2563
3757
|
// Last *successful* poll per tab, wall-clock. Wall-clock on purpose: a laptop
|
|
2564
3758
|
// sleeping is exactly the gap this is meant to report, and a monotonic clock
|
|
2565
3759
|
// does not advance across suspend. Never written on the failure path, or a
|
|
2566
3760
|
// persistently failing tab would report itself fresh forever.
|
|
2567
|
-
const lastOkRef = useRef(
|
|
3761
|
+
const lastOkRef = useRef(
|
|
3762
|
+
Object.fromEntries(
|
|
3763
|
+
TABS.flatMap((candidate) => {
|
|
3764
|
+
const lastOk = cachedEntry?.tabs[candidate.key]?.lastOk;
|
|
3765
|
+
return lastOk == null ? [] : [[candidate.key, lastOk]];
|
|
3766
|
+
}),
|
|
3767
|
+
),
|
|
3768
|
+
);
|
|
2568
3769
|
const fetchTabRef = useRef(null);
|
|
2569
3770
|
const contextCoordinatorRef = useRef(null);
|
|
2570
3771
|
|
|
@@ -2590,6 +3791,185 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
2590
3791
|
showHelpRef.current = showHelp;
|
|
2591
3792
|
const remoteSetupRef = useRef(false);
|
|
2592
3793
|
remoteSetupRef.current = remoteSetup;
|
|
3794
|
+
const headerRef = useRef(null);
|
|
3795
|
+
const dragRef = useRef(null);
|
|
3796
|
+
const resizeRef = useRef({
|
|
3797
|
+
active: false,
|
|
3798
|
+
tabKey: "actions",
|
|
3799
|
+
selectedKey: null,
|
|
3800
|
+
effectiveHeader: null,
|
|
3801
|
+
frameCols: 0,
|
|
3802
|
+
compact: true,
|
|
3803
|
+
fullHeaderVisible: false,
|
|
3804
|
+
});
|
|
3805
|
+
|
|
3806
|
+
useEffect(() => {
|
|
3807
|
+
if (!interactive) return;
|
|
3808
|
+
enableMouseReporting();
|
|
3809
|
+
return () => {
|
|
3810
|
+
dragRef.current = null;
|
|
3811
|
+
disableMouseReporting();
|
|
3812
|
+
};
|
|
3813
|
+
}, [interactive]);
|
|
3814
|
+
|
|
3815
|
+
function applyWidthOverrides(next) {
|
|
3816
|
+
const current = widthOverridesRef.current;
|
|
3817
|
+
if (next === current) return false;
|
|
3818
|
+
widthOverridesRef.current = next;
|
|
3819
|
+
setWidthOverrides(next);
|
|
3820
|
+
// Schedule in the input turn, not a post-render effect. Reset, mode exit and
|
|
3821
|
+
// quit all flush immediately, and must not race ahead of React committing the
|
|
3822
|
+
// state that contains the latest width.
|
|
3823
|
+
widthPreferenceWriter.schedule(next);
|
|
3824
|
+
|
|
3825
|
+
const geometry = resizeRef.current;
|
|
3826
|
+
const activeTab = tabForKey(geometry.tabKey);
|
|
3827
|
+
if (activeTab) {
|
|
3828
|
+
const nextTabOverrides = pick(next, activeTab.key, EMPTY_WIDTH_OVERRIDES) ??
|
|
3829
|
+
EMPTY_WIDTH_OVERRIDES;
|
|
3830
|
+
const effective = effectiveHeaderFor(activeTab, nextTabOverrides, geometry.frameCols);
|
|
3831
|
+
resizeRef.current = {
|
|
3832
|
+
...geometry,
|
|
3833
|
+
effectiveHeader: effective,
|
|
3834
|
+
compact: effective == null,
|
|
3835
|
+
fullHeaderVisible: geometry.fullHeaderVisible && effective != null,
|
|
3836
|
+
};
|
|
3837
|
+
}
|
|
3838
|
+
return true;
|
|
3839
|
+
}
|
|
3840
|
+
|
|
3841
|
+
function flushWidthPreferences() {
|
|
3842
|
+
void widthPreferenceWriter.flush();
|
|
3843
|
+
}
|
|
3844
|
+
|
|
3845
|
+
function leaveWidthMode() {
|
|
3846
|
+
dragRef.current = null;
|
|
3847
|
+
resizeRef.current = { ...resizeRef.current, active: false };
|
|
3848
|
+
setWidthMode(false);
|
|
3849
|
+
flushWidthPreferences();
|
|
3850
|
+
}
|
|
3851
|
+
|
|
3852
|
+
function rememberWidthSelection(key) {
|
|
3853
|
+
if (key === null) return;
|
|
3854
|
+
const tabKey = resizeRef.current.tabKey;
|
|
3855
|
+
resizeRef.current = { ...resizeRef.current, selectedKey: key };
|
|
3856
|
+
setSelectedWidthKeyByTab((current) =>
|
|
3857
|
+
current[tabKey] === key ? current : { ...current, [tabKey]: key },
|
|
3858
|
+
);
|
|
3859
|
+
}
|
|
3860
|
+
|
|
3861
|
+
function enterWidthMode(requestedKey = null) {
|
|
3862
|
+
const geometry = resizeRef.current;
|
|
3863
|
+
if (!geometry.fullHeaderVisible || geometry.compact) return false;
|
|
3864
|
+
const activeTab = tabForKey(geometry.tabKey);
|
|
3865
|
+
const selectedKey = selectWidthKey(activeTab, requestedKey ?? geometry.selectedKey);
|
|
3866
|
+
if (selectedKey === null) return false;
|
|
3867
|
+
rememberWidthSelection(selectedKey);
|
|
3868
|
+
resizeRef.current = { ...resizeRef.current, active: true, selectedKey };
|
|
3869
|
+
setWidthMode(true);
|
|
3870
|
+
return true;
|
|
3871
|
+
}
|
|
3872
|
+
|
|
3873
|
+
function resizeSelectedWidth(delta) {
|
|
3874
|
+
const geometry = resizeRef.current;
|
|
3875
|
+
if (!geometry.active || !geometry.fullHeaderVisible || geometry.compact) return;
|
|
3876
|
+
const activeTab = tabForKey(geometry.tabKey);
|
|
3877
|
+
const column = geometry.effectiveHeader?.find(
|
|
3878
|
+
(candidate) => candidate.key === geometry.selectedKey,
|
|
3879
|
+
);
|
|
3880
|
+
if (!activeTab || !isAdjustableWidthColumn(column)) return;
|
|
3881
|
+
const next = updateWidthPreference({
|
|
3882
|
+
overrides: widthOverridesRef.current,
|
|
3883
|
+
tab: activeTab,
|
|
3884
|
+
key: geometry.selectedKey,
|
|
3885
|
+
nextWidth: column.props.width + delta,
|
|
3886
|
+
effectiveHeader: geometry.effectiveHeader,
|
|
3887
|
+
frameCols: geometry.frameCols,
|
|
3888
|
+
});
|
|
3889
|
+
applyWidthOverrides(next);
|
|
3890
|
+
}
|
|
3891
|
+
|
|
3892
|
+
function resetSelectedWidth() {
|
|
3893
|
+
dragRef.current = null;
|
|
3894
|
+
const { tabKey, selectedKey } = resizeRef.current;
|
|
3895
|
+
applyWidthOverrides(resetWidthPreference(widthOverridesRef.current, tabKey, selectedKey));
|
|
3896
|
+
flushWidthPreferences();
|
|
3897
|
+
}
|
|
3898
|
+
|
|
3899
|
+
function resetActiveTabWidths() {
|
|
3900
|
+
dragRef.current = null;
|
|
3901
|
+
const { tabKey } = resizeRef.current;
|
|
3902
|
+
applyWidthOverrides(resetTabWidthPreferences(widthOverridesRef.current, tabKey));
|
|
3903
|
+
flushWidthPreferences();
|
|
3904
|
+
}
|
|
3905
|
+
|
|
3906
|
+
const cancelWidthDrag = useCallback(() => {
|
|
3907
|
+
if (dragRef.current === null) return false;
|
|
3908
|
+
dragRef.current = null;
|
|
3909
|
+
void widthPreferenceWriter.flush();
|
|
3910
|
+
return true;
|
|
3911
|
+
}, [widthPreferenceWriter]);
|
|
3912
|
+
|
|
3913
|
+
const measuredHeader = useCallback(() => {
|
|
3914
|
+
return headerRef.current ? measureElement(headerRef.current) : null;
|
|
3915
|
+
}, []);
|
|
3916
|
+
|
|
3917
|
+
function handleSgrMouse(event) {
|
|
3918
|
+
if (event.action === "release") {
|
|
3919
|
+
dragRef.current = null;
|
|
3920
|
+
flushWidthPreferences();
|
|
3921
|
+
return;
|
|
3922
|
+
}
|
|
3923
|
+
|
|
3924
|
+
const geometry = resizeRef.current;
|
|
3925
|
+
if (event.action === "press") {
|
|
3926
|
+
if (!interactive || !geometry.fullHeaderVisible || geometry.compact) return;
|
|
3927
|
+
const metrics = measuredHeader();
|
|
3928
|
+
if (!metrics) return;
|
|
3929
|
+
const drag = beginDividerDrag({
|
|
3930
|
+
event,
|
|
3931
|
+
handles: dividerHandles({ header: geometry.effectiveHeader, metrics }),
|
|
3932
|
+
tabKey: geometry.tabKey,
|
|
3933
|
+
});
|
|
3934
|
+
if (!drag || !enterWidthMode(drag.key)) return;
|
|
3935
|
+
dragRef.current = { ...drag, metrics };
|
|
3936
|
+
return;
|
|
3937
|
+
}
|
|
3938
|
+
|
|
3939
|
+
const drag = dragRef.current;
|
|
3940
|
+
if (!drag) return;
|
|
3941
|
+
const metrics = measuredHeader();
|
|
3942
|
+
const layoutValid = sameElementMetrics(drag.metrics, metrics);
|
|
3943
|
+
const proposal = draggedWidth({
|
|
3944
|
+
drag,
|
|
3945
|
+
event,
|
|
3946
|
+
tabKey: geometry.tabKey,
|
|
3947
|
+
fullHeaderVisible: geometry.fullHeaderVisible,
|
|
3948
|
+
layoutValid,
|
|
3949
|
+
});
|
|
3950
|
+
if (!proposal) {
|
|
3951
|
+
if (!layoutValid || drag.tabKey !== geometry.tabKey || !geometry.fullHeaderVisible) {
|
|
3952
|
+
cancelWidthDrag();
|
|
3953
|
+
}
|
|
3954
|
+
return;
|
|
3955
|
+
}
|
|
3956
|
+
|
|
3957
|
+
const activeTab = tabForKey(geometry.tabKey);
|
|
3958
|
+
if (!activeTab) {
|
|
3959
|
+
cancelWidthDrag();
|
|
3960
|
+
return;
|
|
3961
|
+
}
|
|
3962
|
+
applyWidthOverrides(
|
|
3963
|
+
updateWidthPreference({
|
|
3964
|
+
overrides: widthOverridesRef.current,
|
|
3965
|
+
tab: activeTab,
|
|
3966
|
+
key: proposal.key,
|
|
3967
|
+
nextWidth: proposal.nextWidth,
|
|
3968
|
+
effectiveHeader: geometry.effectiveHeader,
|
|
3969
|
+
frameCols: geometry.frameCols,
|
|
3970
|
+
}),
|
|
3971
|
+
);
|
|
3972
|
+
}
|
|
2593
3973
|
|
|
2594
3974
|
function moveSelection(delta) {
|
|
2595
3975
|
const { items, key: currentKey, bodyRows: rows_, tabKey, offset: offsetRaw } = navRef.current;
|
|
@@ -2665,7 +4045,38 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
2665
4045
|
|
|
2666
4046
|
useInput(
|
|
2667
4047
|
(input, key) => {
|
|
2668
|
-
|
|
4048
|
+
// Ink delivers a complete unrecognised CSI token with its leading Escape
|
|
4049
|
+
// removed. Consume the whole SGR namespace here, including unsupported
|
|
4050
|
+
// buttons/modifiers, so no report can fall through to keyboard bindings.
|
|
4051
|
+
if (input.startsWith("[<")) {
|
|
4052
|
+
const mouse = parseSgrMouse(input);
|
|
4053
|
+
if (mouse) handleSgrMouse(mouse);
|
|
4054
|
+
return;
|
|
4055
|
+
}
|
|
4056
|
+
if (input === "q" || (input === "c" && key.ctrl)) {
|
|
4057
|
+
flushWidthPreferences();
|
|
4058
|
+
exit();
|
|
4059
|
+
} else if (resizeRef.current.active) {
|
|
4060
|
+
if (input === "w" || key.return || key.escape) {
|
|
4061
|
+
leaveWidthMode();
|
|
4062
|
+
} else if (key.tab) {
|
|
4063
|
+
const activeTab = tabForKey(resizeRef.current.tabKey);
|
|
4064
|
+
rememberWidthSelection(
|
|
4065
|
+
cycleWidthKey(activeTab, resizeRef.current.selectedKey, key.shift ? -1 : 1),
|
|
4066
|
+
);
|
|
4067
|
+
} else if (key.leftArrow) {
|
|
4068
|
+
resizeSelectedWidth(key.shift ? -5 : -1);
|
|
4069
|
+
} else if (key.rightArrow) {
|
|
4070
|
+
resizeSelectedWidth(key.shift ? 5 : 1);
|
|
4071
|
+
} else if (input === "r") {
|
|
4072
|
+
resetSelectedWidth();
|
|
4073
|
+
} else if (input === "R") {
|
|
4074
|
+
resetActiveTabWidths();
|
|
4075
|
+
}
|
|
4076
|
+
// Width mode owns every other key. In particular, digits, arrows, Tab,
|
|
4077
|
+
// Enter and r must never fall through to their ordinary meanings.
|
|
4078
|
+
} else if (key.escape) {
|
|
4079
|
+
flushWidthPreferences();
|
|
2669
4080
|
exit();
|
|
2670
4081
|
} else if (showHelpRef.current) {
|
|
2671
4082
|
// Any key dismisses -- except quit, handled above, which must never be
|
|
@@ -2675,6 +4086,8 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
2675
4086
|
setShowHelp(false);
|
|
2676
4087
|
} else if (input === "?") {
|
|
2677
4088
|
setShowHelp(true);
|
|
4089
|
+
} else if (input === "w") {
|
|
4090
|
+
enterWidthMode();
|
|
2678
4091
|
} else if (key.downArrow || input === "j") {
|
|
2679
4092
|
moveSelection(1);
|
|
2680
4093
|
} else if (key.upArrow || input === "k") {
|
|
@@ -2684,7 +4097,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
2684
4097
|
} else if (key.pageUp) {
|
|
2685
4098
|
moveSelection(-pageStep);
|
|
2686
4099
|
} else if (key.return) {
|
|
2687
|
-
if (remoteSetupRef.current) onCreateRemote();
|
|
4100
|
+
if (remoteSetupRef.current) onCreateRemote(suspendTerminal);
|
|
2688
4101
|
else openSelected();
|
|
2689
4102
|
} else if (input === "r") {
|
|
2690
4103
|
// Goes through the same per-tab in-flight guard as the poll loop, so
|
|
@@ -2719,10 +4132,17 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
2719
4132
|
// -- the behaviour the per-tab guard and the fetch-time limit read were
|
|
2720
4133
|
// written to prevent. Do not "fix" this by adding them.
|
|
2721
4134
|
//
|
|
2722
|
-
// exhaustive-deps is satisfied as written -- every captured value is
|
|
2723
|
-
//
|
|
2724
|
-
//
|
|
2725
|
-
//
|
|
4135
|
+
// exhaustive-deps is satisfied as written -- every captured value is a ref,
|
|
4136
|
+
// a setState function, or the stable cache writer created once above. If a
|
|
4137
|
+
// changing dependency ever becomes necessary, that is the signal that the
|
|
4138
|
+
// mount-only contract has been broken.
|
|
4139
|
+
//
|
|
4140
|
+
// The adaptive throttle re-arms the timer, which is not a contradiction of the
|
|
4141
|
+
// above: `rearm` swaps the interval from *inside* this effect, so the effect
|
|
4142
|
+
// still runs exactly once and no closure, ref or AbortController is ever
|
|
4143
|
+
// recreated. What is created once is the effect; what changes is only the
|
|
4144
|
+
// delay `tick` is scheduled at. Do not move the re-arm into a dependency
|
|
4145
|
+
// array -- that is precisely the teardown this comment exists to prevent.
|
|
2726
4146
|
useEffect(() => {
|
|
2727
4147
|
let cancelled = false;
|
|
2728
4148
|
let ticks = 0;
|
|
@@ -2746,6 +4166,32 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
2746
4166
|
});
|
|
2747
4167
|
contextCoordinatorRef.current = coordinator;
|
|
2748
4168
|
|
|
4169
|
+
function cacheSuccessfulTab(key, tabData, tabMeta, lastOk, security = {}) {
|
|
4170
|
+
const target = dashboardCacheTargetRef.current;
|
|
4171
|
+
const currentCache = dashboardCacheRef.current;
|
|
4172
|
+
const currentEntry = pick(currentCache, target, null) ?? {
|
|
4173
|
+
tabs: {},
|
|
4174
|
+
securityNotes: [],
|
|
4175
|
+
securityBlind: false,
|
|
4176
|
+
updatedAt: lastOk,
|
|
4177
|
+
};
|
|
4178
|
+
const nextEntry = {
|
|
4179
|
+
...currentEntry,
|
|
4180
|
+
tabs: {
|
|
4181
|
+
...currentEntry.tabs,
|
|
4182
|
+
[key]: { data: tabData, meta: tabMeta, lastOk },
|
|
4183
|
+
},
|
|
4184
|
+
securityNotes:
|
|
4185
|
+
key === "security" ? (security.notes ?? []) : currentEntry.securityNotes,
|
|
4186
|
+
securityBlind:
|
|
4187
|
+
key === "security" ? Boolean(security.blind) : currentEntry.securityBlind,
|
|
4188
|
+
updatedAt: lastOk,
|
|
4189
|
+
};
|
|
4190
|
+
const nextCache = mergeDashboardCacheEntry(currentCache, target, nextEntry);
|
|
4191
|
+
dashboardCacheRef.current = nextCache;
|
|
4192
|
+
dashboardCacheWriter.schedule(nextCache);
|
|
4193
|
+
}
|
|
4194
|
+
|
|
2749
4195
|
// Each tab commits its own result the moment it lands instead of waiting on
|
|
2750
4196
|
// a Promise.allSettled barrier. Actions is by far the slowest fetch, so
|
|
2751
4197
|
// barrelling everything together meant the three fast tabs sat invisible
|
|
@@ -2759,6 +4205,10 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
2759
4205
|
setLoading((l) => (l[key] ? l : { ...l, [key]: true }));
|
|
2760
4206
|
return run()
|
|
2761
4207
|
.then((result) => {
|
|
4208
|
+
// Billed before the `cancelled` early return: the requests were made
|
|
4209
|
+
// and counted by GitHub whether or not this process still wants the
|
|
4210
|
+
// answer.
|
|
4211
|
+
restSpentTotal += result?.restSpent ?? 0;
|
|
2762
4212
|
if (cancelled) return;
|
|
2763
4213
|
const { raw, parse, limit } = result;
|
|
2764
4214
|
// Identical payload: skip the parse *and* the state update. Returning
|
|
@@ -2780,36 +4230,58 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
2780
4230
|
// completely healthy. A ref rather than state because writing state here
|
|
2781
4231
|
// would allocate a new object every tick and permanently defeat the
|
|
2782
4232
|
// React bail-out this early return exists to preserve.
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
4233
|
+
const completedAt = Date.now();
|
|
4234
|
+
if (rawRef.current[key] === raw) {
|
|
4235
|
+
lastOkRef.current[key] = completedAt;
|
|
4236
|
+
// Clear on the first success or a single failure latches the ladder.
|
|
4237
|
+
clearBackoff(`tab:${key}`);
|
|
2787
4238
|
setErrors((x) => (x[key] === null ? x : { ...x, [key]: null }));
|
|
2788
4239
|
return;
|
|
2789
4240
|
}
|
|
2790
4241
|
const value = parse();
|
|
2791
|
-
|
|
2792
|
-
|
|
4242
|
+
// Security fetches resolve each source independently so their notes
|
|
4243
|
+
// remain visible. A blind result is still a failed observation: it
|
|
4244
|
+
// must not replace known alerts with a false empty state or advance
|
|
4245
|
+
// freshness. Do not retain its raw value either, so the next source
|
|
4246
|
+
// 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);
|
|
4249
|
+
setErrors((x) => (x[key] === null ? x : { ...x, [key]: null }));
|
|
4250
|
+
setSecurityNotes(value.notes ?? []);
|
|
4251
|
+
setSecurityBlind(true);
|
|
4252
|
+
return;
|
|
4253
|
+
}
|
|
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
|
+
};
|
|
4262
|
+
lastOkRef.current[key] = completedAt;
|
|
4263
|
+
clearBackoff(`tab:${key}`);
|
|
4264
|
+
rawRef.current[key] =
|
|
4265
|
+
key === "security" ? nextSecurityRaw(rawRef.current[key], raw, false) : raw;
|
|
2793
4266
|
setErrors((x) => (x[key] === null ? x : { ...x, [key]: null }));
|
|
2794
|
-
setData((d) => ({ ...d, [key]:
|
|
2795
|
-
setMeta((m) => ({
|
|
2796
|
-
...m,
|
|
2797
|
-
[key]: {
|
|
2798
|
-
at: Date.now(),
|
|
2799
|
-
// Compare against the limit this payload was actually fetched
|
|
2800
|
-
// with. Comparing against the live pane-height ref made the "n+"
|
|
2801
|
-
// marker lie for one cycle after every resize.
|
|
2802
|
-
truncated: value.truncated ?? (value.alerts ?? value).length >= limit,
|
|
2803
|
-
},
|
|
2804
|
-
}));
|
|
4267
|
+
setData((d) => ({ ...d, [key]: tabData }));
|
|
4268
|
+
setMeta((m) => ({ ...m, [key]: tabMeta }));
|
|
2805
4269
|
if (value.notes) setSecurityNotes(value.notes);
|
|
2806
4270
|
if (key === "security") {
|
|
2807
4271
|
setSecurityBlind((b) => (b === Boolean(value.blind) ? b : Boolean(value.blind)));
|
|
2808
4272
|
}
|
|
4273
|
+
cacheSuccessfulTab(key, tabData, tabMeta, completedAt, {
|
|
4274
|
+
notes: value.notes,
|
|
4275
|
+
blind: value.blind,
|
|
4276
|
+
});
|
|
2809
4277
|
})
|
|
2810
4278
|
.catch((err) => {
|
|
4279
|
+
// A rejected fetch carries no result, so its own spend is unknowable
|
|
4280
|
+
// here; bill the tab's table cost instead. A request that failed after
|
|
4281
|
+
// reaching GitHub still counted, and over-billing by an aborted call
|
|
4282
|
+
// errs toward throttling harder, which is the safe direction.
|
|
4283
|
+
restSpentTotal += REST_PER_FETCH[key] ?? 0;
|
|
2811
4284
|
if (cancelled || err?.name === "AbortError") return;
|
|
2812
|
-
cleanRef.current[key] = false;
|
|
2813
4285
|
// Preserve both the verdict and the bounded raw error in state. The
|
|
2814
4286
|
// renderer translates recognized verdicts at draw time, which lets a
|
|
2815
4287
|
// later repository/account context refine the one-line remedy without
|
|
@@ -2838,7 +4310,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
2838
4310
|
// exactly the moment the pane looks broken and they reach for it.
|
|
2839
4311
|
function fetchTab(key, { force = false } = {}) {
|
|
2840
4312
|
const signal = controller.signal;
|
|
2841
|
-
const descriptor =
|
|
4313
|
+
const descriptor = tabForKey(key);
|
|
2842
4314
|
if (!force && backoffActive(`tab:${key}`, performance.now())) return Promise.resolve();
|
|
2843
4315
|
if (force) clearBackoff(`tab:${key}`);
|
|
2844
4316
|
// Every tab carries its own fetcher on the registry, so adding a tab is a
|
|
@@ -2854,6 +4326,10 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
2854
4326
|
// so stop spawning subprocesses once any endpoint proves there is no
|
|
2855
4327
|
// remote. The interval may still wake, but this branch is otherwise idle.
|
|
2856
4328
|
if (remoteSetupRef.current) return;
|
|
4329
|
+
// Kicked off, never awaited: the budget probe must not delay the fetches
|
|
4330
|
+
// this tick is for. `probeInFlight` keeps a slow probe from stampeding.
|
|
4331
|
+
const nowMs = Date.now();
|
|
4332
|
+
if (!probeInFlight && nowMs - lastProbeAt >= BUDGET_PROBE_MS) void probeBudget(nowMs);
|
|
2857
4333
|
// Only the tab you're looking at needs to keep up with REFRESH_MS. The
|
|
2858
4334
|
// other three exist to keep the tab-bar counts honest, which tolerates
|
|
2859
4335
|
// being a minute behind -- so they refresh every BACKGROUND_EVERY ticks
|
|
@@ -2874,11 +4350,64 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
2874
4350
|
);
|
|
2875
4351
|
}
|
|
2876
4352
|
|
|
4353
|
+
// The adaptive throttle. `appliedMs` is what the timer is currently set to;
|
|
4354
|
+
// runtime.refreshMs stays the floor it may widen from and never tighten past.
|
|
4355
|
+
let appliedMs = runtime.refreshMs;
|
|
4356
|
+
let intervalId = null;
|
|
4357
|
+
// 0 rather than `now`, so the first tick probes immediately: a pane started
|
|
4358
|
+
// into an already-drained budget must back off at once rather than spend a
|
|
4359
|
+
// minute at full rate first.
|
|
4360
|
+
let lastProbeAt = 0;
|
|
4361
|
+
let probeInFlight = false;
|
|
4362
|
+
// The open probe window, and the last share it was able to measure. Both are
|
|
4363
|
+
// carried by nextProbeWindow/inferShare rather than reset every probe -- see
|
|
4364
|
+
// those functions for why a throttled pane must not re-measure from scratch.
|
|
4365
|
+
let probeWindow = null;
|
|
4366
|
+
let lastShare = 1;
|
|
4367
|
+
|
|
4368
|
+
function rearm(ms) {
|
|
4369
|
+
appliedMs = ms;
|
|
4370
|
+
if (intervalId) clearInterval(intervalId);
|
|
4371
|
+
intervalId = setInterval(tick, ms);
|
|
4372
|
+
}
|
|
4373
|
+
|
|
4374
|
+
async function probeBudget(nowMs) {
|
|
4375
|
+
probeInFlight = true;
|
|
4376
|
+
lastProbeAt = nowMs; // set before awaiting, so ticks cannot stampede
|
|
4377
|
+
try {
|
|
4378
|
+
// `controller` is this effect's own AbortController, so a probe still in
|
|
4379
|
+
// 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,
|
|
4393
|
+
floorMs: runtime.refreshMs,
|
|
4394
|
+
nowMs: Date.now(),
|
|
4395
|
+
restPerTick: restPerTick(TABS[activeIndexRef.current].key),
|
|
4396
|
+
});
|
|
4397
|
+
if (adaptiveChangeWorthApplying(appliedMs, target)) {
|
|
4398
|
+
rearm(target);
|
|
4399
|
+
setThrottleMs(target > runtime.refreshMs ? target : null);
|
|
4400
|
+
}
|
|
4401
|
+
} finally {
|
|
4402
|
+
probeInFlight = false;
|
|
4403
|
+
}
|
|
4404
|
+
}
|
|
4405
|
+
|
|
2877
4406
|
tick();
|
|
2878
|
-
|
|
4407
|
+
rearm(runtime.refreshMs);
|
|
2879
4408
|
return () => {
|
|
2880
4409
|
cancelled = true;
|
|
2881
|
-
clearInterval(
|
|
4410
|
+
clearInterval(intervalId);
|
|
2882
4411
|
if (contextCoordinatorRef.current === coordinator) contextCoordinatorRef.current = null;
|
|
2883
4412
|
// `cancelled` stops state updates from a promise that already resolved;
|
|
2884
4413
|
// the signal stops the subprocess itself, so quitting doesn't orphan up to
|
|
@@ -2886,7 +4415,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
2886
4415
|
// needed.
|
|
2887
4416
|
controller.abort();
|
|
2888
4417
|
};
|
|
2889
|
-
}, []);
|
|
4418
|
+
}, [dashboardCacheWriter]);
|
|
2890
4419
|
|
|
2891
4420
|
// Background tabs can be up to BACKGROUND_EVERY ticks stale, so the tab you
|
|
2892
4421
|
// switch to refreshes straight away rather than showing old data until its
|
|
@@ -3062,8 +4591,14 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
3062
4591
|
// the cost -- different tabs showing different column counts at the same width
|
|
3063
4592
|
// -- is not something you can actually see. MIN_TABLE_WIDTH stays as the
|
|
3064
4593
|
// exported worst case, which is what the tests pin.
|
|
3065
|
-
const
|
|
3066
|
-
|
|
4594
|
+
const tabOverrides = pick(widthOverridesRef.current, tab.key, EMPTY_WIDTH_OVERRIDES) ??
|
|
4595
|
+
EMPTY_WIDTH_OVERRIDES;
|
|
4596
|
+
const effectiveHeader = useMemo(
|
|
4597
|
+
() => effectiveHeaderFor(tab, tabOverrides, frameCols),
|
|
4598
|
+
[tab, tabOverrides, frameCols],
|
|
4599
|
+
);
|
|
4600
|
+
const compact = effectiveHeader == null;
|
|
4601
|
+
const header = compact ? tab.compactHeader : effectiveHeader;
|
|
3067
4602
|
|
|
3068
4603
|
// Below the compact set's own floor even the fixed columns overflow, which
|
|
3069
4604
|
// hard-wraps every row and drives ink into clearing and repainting the whole
|
|
@@ -3072,10 +4607,64 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
3072
4607
|
// sits after usableSize(), which substitutes DEFAULT_COLS for a 0 reported
|
|
3073
4608
|
// mid-resize, so a transient zero cannot be mistaken for a genuinely tiny pane.
|
|
3074
4609
|
const tooNarrow = frameCols < MIN_COMPACT_WIDTH;
|
|
4610
|
+
const fullHeaderVisible = !compact && !showHelp && !tooNarrow && !remoteSetup;
|
|
4611
|
+
const selectedWidthKey = selectedWidthKeyByTab[tab.key] ?? null;
|
|
4612
|
+
const selectedWidthColumn = effectiveHeader?.find(
|
|
4613
|
+
(column) => column.key === selectedWidthKey,
|
|
4614
|
+
) ?? null;
|
|
4615
|
+
resizeRef.current = {
|
|
4616
|
+
active: widthMode && fullHeaderVisible && selectedWidthColumn !== null,
|
|
4617
|
+
tabKey: tab.key,
|
|
4618
|
+
selectedKey: selectedWidthKey,
|
|
4619
|
+
effectiveHeader,
|
|
4620
|
+
frameCols,
|
|
4621
|
+
compact,
|
|
4622
|
+
fullHeaderVisible,
|
|
4623
|
+
};
|
|
4624
|
+
|
|
4625
|
+
// A resize, help/setup transition, or replacement layout can remove the full
|
|
4626
|
+
// header without a keypress. Stop owning input immediately through resizeRef,
|
|
4627
|
+
// then settle the visible mode state and durable write in the effect.
|
|
4628
|
+
useEffect(() => {
|
|
4629
|
+
let flushed = false;
|
|
4630
|
+
const drag = dragRef.current;
|
|
4631
|
+
if (drag) {
|
|
4632
|
+
const metrics = measuredHeader();
|
|
4633
|
+
if (
|
|
4634
|
+
drag.tabKey !== tab.key ||
|
|
4635
|
+
!fullHeaderVisible ||
|
|
4636
|
+
!sameElementMetrics(drag.metrics, metrics)
|
|
4637
|
+
) {
|
|
4638
|
+
flushed = cancelWidthDrag();
|
|
4639
|
+
}
|
|
4640
|
+
}
|
|
4641
|
+
if (!widthMode || fullHeaderVisible) return;
|
|
4642
|
+
setWidthMode(false);
|
|
4643
|
+
if (!flushed) void widthPreferenceWriter.flush();
|
|
4644
|
+
}, [
|
|
4645
|
+
widthMode,
|
|
4646
|
+
fullHeaderVisible,
|
|
4647
|
+
tab.key,
|
|
4648
|
+
frameCols,
|
|
4649
|
+
extraLines,
|
|
4650
|
+
cancelWidthDrag,
|
|
4651
|
+
measuredHeader,
|
|
4652
|
+
widthPreferenceWriter,
|
|
4653
|
+
]);
|
|
4654
|
+
|
|
4655
|
+
// Keep one physical row below Ink's output, the vertical counterpart to the
|
|
4656
|
+
// spare column in `frameCols`. Incremental rendering assumes its cursor still
|
|
4657
|
+
// sits at the bottom of the previous frame; if dynamic content occupies the
|
|
4658
|
+
// terminal's last row, a terminal scroll can invalidate that assumption and
|
|
4659
|
+
// leave old status lines behind. A non-fullscreen frame gets a trailing
|
|
4660
|
+
// newline from Ink, so the cursor parks on the unused guard row while the
|
|
4661
|
+
// status bar remains one row above the scroll edge.
|
|
4662
|
+
const liveRows = Math.min(rows, usableSize(stdout?.rows, rows));
|
|
4663
|
+
const frameRows = Math.max(1, liveRows - 1);
|
|
3075
4664
|
|
|
3076
4665
|
return e(
|
|
3077
4666
|
Box,
|
|
3078
|
-
{ flexDirection: "column", width: frameCols, height:
|
|
4667
|
+
{ flexDirection: "column", width: frameCols, height: frameRows },
|
|
3079
4668
|
e(TabBar, {
|
|
3080
4669
|
activeIndex,
|
|
3081
4670
|
counts,
|
|
@@ -3148,7 +4737,14 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
3148
4737
|
line,
|
|
3149
4738
|
),
|
|
3150
4739
|
),
|
|
3151
|
-
!showHelp &&
|
|
4740
|
+
!showHelp &&
|
|
4741
|
+
!tooNarrow &&
|
|
4742
|
+
!remoteSetup &&
|
|
4743
|
+
e(MemoHeaderCells, {
|
|
4744
|
+
cells: header,
|
|
4745
|
+
selectedWidthKey: resizeRef.current.active ? selectedWidthKey : null,
|
|
4746
|
+
headerRef,
|
|
4747
|
+
}),
|
|
3152
4748
|
...(tooNarrow || showHelp || remoteSetup ? [] : visibleItems).map((item) => {
|
|
3153
4749
|
const key = itemKey(item);
|
|
3154
4750
|
return e(
|
|
@@ -3158,6 +4754,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
3158
4754
|
item,
|
|
3159
4755
|
now,
|
|
3160
4756
|
compact,
|
|
4757
|
+
columns: header,
|
|
3161
4758
|
cursor: key === selectedKey,
|
|
3162
4759
|
// Only an *executing* Actions row reads this. It used to go to every
|
|
3163
4760
|
// visible row on the tab, so a prop that changes several times a
|
|
@@ -3208,9 +4805,13 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
3208
4805
|
fetching: Object.values(loading).some(Boolean),
|
|
3209
4806
|
spin: showSpinner ? spin : null,
|
|
3210
4807
|
stale: staleLabel,
|
|
4808
|
+
throttle: throttleMs ? `throttled ${Math.round(throttleMs / 1000)}s` : null,
|
|
3211
4809
|
interactive,
|
|
3212
4810
|
cols: frameCols,
|
|
3213
4811
|
remoteSetup,
|
|
4812
|
+
widthMode: resizeRef.current.active,
|
|
4813
|
+
widthColumn: selectedWidthColumn,
|
|
4814
|
+
widthSaveError,
|
|
3214
4815
|
}),
|
|
3215
4816
|
);
|
|
3216
4817
|
}
|
|
@@ -3224,16 +4825,58 @@ function enterAlternateScreen() {
|
|
|
3224
4825
|
process.stdout.write("\x1b[?1049h\x1b[2J\x1b[H");
|
|
3225
4826
|
}
|
|
3226
4827
|
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
4828
|
+
function createTerminalLifecycle(write) {
|
|
4829
|
+
if (typeof write !== "function") throw new TypeError("write must be a function");
|
|
4830
|
+
let mouseReportingEnabled = false;
|
|
4831
|
+
let screenRestored = false;
|
|
4832
|
+
|
|
4833
|
+
function enableMouseReporting() {
|
|
4834
|
+
if (mouseReportingEnabled || screenRestored) return false;
|
|
4835
|
+
write("\x1b[?1002h\x1b[?1006h");
|
|
4836
|
+
mouseReportingEnabled = true;
|
|
4837
|
+
return true;
|
|
4838
|
+
}
|
|
4839
|
+
|
|
4840
|
+
function disableMouseReporting() {
|
|
4841
|
+
if (!mouseReportingEnabled) return false;
|
|
4842
|
+
write("\x1b[?1002l\x1b[?1006l");
|
|
4843
|
+
mouseReportingEnabled = false;
|
|
4844
|
+
return true;
|
|
4845
|
+
}
|
|
4846
|
+
|
|
4847
|
+
function restoreScreen() {
|
|
4848
|
+
if (screenRestored) return false;
|
|
4849
|
+
disableMouseReporting();
|
|
4850
|
+
screenRestored = true;
|
|
4851
|
+
// Ink restores the cursor only through its own unmount path, which an
|
|
4852
|
+
// explicit process.exit() skips.
|
|
4853
|
+
write("\x1b[?25h\x1b[?1049l");
|
|
4854
|
+
return true;
|
|
4855
|
+
}
|
|
4856
|
+
|
|
4857
|
+
return {
|
|
4858
|
+
enableMouseReporting,
|
|
4859
|
+
disableMouseReporting,
|
|
4860
|
+
restoreScreen,
|
|
4861
|
+
isMouseReportingEnabled: () => mouseReportingEnabled,
|
|
4862
|
+
};
|
|
4863
|
+
}
|
|
4864
|
+
|
|
4865
|
+
const terminalLifecycle = createTerminalLifecycle((output) => process.stdout.write(output));
|
|
4866
|
+
|
|
4867
|
+
function enableMouseReporting() {
|
|
4868
|
+
return terminalLifecycle.enableMouseReporting();
|
|
4869
|
+
}
|
|
4870
|
+
|
|
4871
|
+
function disableMouseReporting() {
|
|
4872
|
+
return terminalLifecycle.disableMouseReporting();
|
|
4873
|
+
}
|
|
4874
|
+
|
|
4875
|
+
// Idempotent because both the lifecycle controller and this process-level
|
|
4876
|
+
// wrapper share one restored state. Mouse modes are always disabled before the
|
|
4877
|
+
// alternate buffer is released, including when this is only the exit backstop.
|
|
3233
4878
|
function restoreScreen() {
|
|
3234
|
-
|
|
3235
|
-
screenRestored = true;
|
|
3236
|
-
process.stdout.write("\x1b[?25h\x1b[?1049l");
|
|
4879
|
+
return terminalLifecycle.restoreScreen();
|
|
3237
4880
|
}
|
|
3238
4881
|
|
|
3239
4882
|
// A crash used to be indistinguishable from a clean quit. Ink catches render
|
|
@@ -3242,18 +4885,18 @@ function restoreScreen() {
|
|
|
3242
4885
|
// simply vanished and any wrapper saw success. Restore the primary buffer
|
|
3243
4886
|
// *first*, then write, or the fix reproduces the problem it is fixing.
|
|
3244
4887
|
//
|
|
3245
|
-
//
|
|
3246
|
-
//
|
|
3247
|
-
//
|
|
3248
|
-
//
|
|
3249
|
-
//
|
|
3250
|
-
// handler exists to print with an unrelated one.
|
|
4888
|
+
// Unmount before restore so Ink's final repaint stays in the alternate buffer.
|
|
4889
|
+
// A crash before app assignment, or another failure during unmount, can skip the
|
|
4890
|
+
// poll effect's cleanup, so abortLiveRequests() remains an explicit backstop.
|
|
4891
|
+
// It runs *after* restoreScreen() and is wrapped because an exception there must
|
|
4892
|
+
// not replace the stack trace this handler exists to print.
|
|
3251
4893
|
//
|
|
3252
4894
|
// Both messages go through redact(): a stack can carry a URL with inline
|
|
3253
4895
|
// credentials, and this output is what a user pastes into a bug report -- the
|
|
3254
4896
|
// same reasoning --doctor already applies to its own report.
|
|
3255
|
-
function installCrashHandlers() {
|
|
4897
|
+
function installCrashHandlers(unmountApp) {
|
|
3256
4898
|
const fail = (label) => (err) => {
|
|
4899
|
+
unmountApp();
|
|
3257
4900
|
restoreScreen();
|
|
3258
4901
|
try {
|
|
3259
4902
|
abortLiveRequests();
|
|
@@ -3269,8 +4912,16 @@ function installCrashHandlers() {
|
|
|
3269
4912
|
}
|
|
3270
4913
|
|
|
3271
4914
|
if (IS_MAIN) {
|
|
4915
|
+
let app;
|
|
4916
|
+
const unmountApp = () => {
|
|
4917
|
+
try {
|
|
4918
|
+
app?.unmount();
|
|
4919
|
+
} catch {
|
|
4920
|
+
// Teardown is best effort. Every caller still restores the terminal.
|
|
4921
|
+
}
|
|
4922
|
+
};
|
|
3272
4923
|
disarmDevBuildLeak();
|
|
3273
|
-
installCrashHandlers();
|
|
4924
|
+
installCrashHandlers(unmountApp);
|
|
3274
4925
|
enterAlternateScreen();
|
|
3275
4926
|
process.on("exit", restoreScreen);
|
|
3276
4927
|
|
|
@@ -3284,40 +4935,71 @@ if (IS_MAIN) {
|
|
|
3284
4935
|
// root box is the terminal height. Windows is already documented as untested
|
|
3285
4936
|
// (README Limitations), and the pty harness covers the two platforms that are
|
|
3286
4937
|
// supported, so the flag is verified where it is claimed to work.
|
|
3287
|
-
let app;
|
|
3288
4938
|
let remoteSetupStarted = false;
|
|
3289
|
-
const createRemote = () => {
|
|
4939
|
+
const createRemote = (suspendTerminal) => {
|
|
3290
4940
|
if (remoteSetupStarted) return;
|
|
3291
4941
|
remoteSetupStarted = true;
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
4942
|
+
|
|
4943
|
+
// Release Ink's raw-mode/parser state before the child inherits stdin.
|
|
4944
|
+
// Unmounting alone can leave an active readable dispatch racing the first
|
|
4945
|
+
// bytes typed for gh, so one immediate boundary lets that dispatch return;
|
|
4946
|
+
// the explicit stream cleanup below then prevents any future parent reads.
|
|
4947
|
+
void suspendTerminal()
|
|
4948
|
+
.then(async () => {
|
|
4949
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
4950
|
+
unmountApp();
|
|
4951
|
+
// This is a permanent handoff, so the parent must not retain any stream
|
|
4952
|
+
// consumer that can race the child for terminal bytes. Pausing/removing
|
|
4953
|
+
// Node listeners does not close fd 0; the spawned process still inherits
|
|
4954
|
+
// the same canonical TTY directly from the operating system.
|
|
4955
|
+
process.stdin.pause();
|
|
4956
|
+
process.stdin.removeAllListeners("readable");
|
|
4957
|
+
process.stdin.removeAllListeners("data");
|
|
4958
|
+
restoreScreen();
|
|
4959
|
+
abortLiveRequests();
|
|
4960
|
+
|
|
4961
|
+
await new Promise((resolve) => {
|
|
4962
|
+
// Plain `gh repo create` is the interactive form. Supplying --source
|
|
4963
|
+
// here would switch gh to non-interactive mode and require gh-glance
|
|
4964
|
+
// to choose a visibility on the user's behalf, which this consent
|
|
4965
|
+
// boundary must never do. The prompt tells the user which interactive
|
|
4966
|
+
// path matches this folder.
|
|
4967
|
+
const child = spawn("gh", ["repo", "create"], {
|
|
4968
|
+
stdio: "inherit",
|
|
4969
|
+
env: process.env,
|
|
4970
|
+
});
|
|
4971
|
+
setupChild = child;
|
|
4972
|
+
child.once("error", (err) => {
|
|
4973
|
+
if (setupChild === child) setupChild = null;
|
|
4974
|
+
console.error(
|
|
4975
|
+
`gh-glance: could not start repository setup: ${redact(shortErr(err))}`,
|
|
4976
|
+
);
|
|
4977
|
+
process.exitCode = 1;
|
|
4978
|
+
resolve();
|
|
4979
|
+
});
|
|
4980
|
+
child.once("exit", (code, signal) => {
|
|
4981
|
+
if (setupChild === child) setupChild = null;
|
|
4982
|
+
process.exitCode = signal ? 1 : (code ?? 1);
|
|
4983
|
+
if (code === 0) {
|
|
4984
|
+
console.log(
|
|
4985
|
+
"gh repo create finished. Run gh-glance again when this folder has a remote.",
|
|
4986
|
+
);
|
|
4987
|
+
}
|
|
4988
|
+
resolve();
|
|
4989
|
+
});
|
|
4990
|
+
});
|
|
4991
|
+
})
|
|
4992
|
+
.catch((err) => {
|
|
4993
|
+
unmountApp();
|
|
4994
|
+
restoreScreen();
|
|
4995
|
+
try {
|
|
4996
|
+
abortLiveRequests();
|
|
4997
|
+
} catch {
|
|
4998
|
+
// Preserve the handoff error below.
|
|
4999
|
+
}
|
|
5000
|
+
console.error(`gh-glance: could not hand off the terminal: ${redact(shortErr(err))}`);
|
|
5001
|
+
process.exitCode = 1;
|
|
5002
|
+
});
|
|
3321
5003
|
};
|
|
3322
5004
|
app = render(e(App, { onCreateRemote: createRemote }), { incrementalRendering: true });
|
|
3323
5005
|
|
|
@@ -3347,12 +5029,7 @@ if (IS_MAIN) {
|
|
|
3347
5029
|
if (signalExitStarted) return;
|
|
3348
5030
|
signalExitStarted = true;
|
|
3349
5031
|
const finish = () => {
|
|
3350
|
-
|
|
3351
|
-
app.unmount();
|
|
3352
|
-
} catch {
|
|
3353
|
-
// Teardown is best effort. restoreScreen below, and the `exit` listener
|
|
3354
|
-
// above, both still run, so the terminal is handed back either way.
|
|
3355
|
-
}
|
|
5032
|
+
unmountApp();
|
|
3356
5033
|
restoreScreen();
|
|
3357
5034
|
process.exit(code);
|
|
3358
5035
|
};
|
|
@@ -3387,6 +5064,22 @@ export {
|
|
|
3387
5064
|
MIN_REFRESH_SECONDS,
|
|
3388
5065
|
MAX_REFRESH_SECONDS,
|
|
3389
5066
|
TAB_KEYS,
|
|
5067
|
+
ALERT_SOURCES,
|
|
5068
|
+
REST_PER_FETCH,
|
|
5069
|
+
GRAPHQL_PER_FETCH,
|
|
5070
|
+
projectedHourlyCost,
|
|
5071
|
+
REFRESH_MS,
|
|
5072
|
+
BACKGROUND_EVERY,
|
|
5073
|
+
adaptiveRefreshMs,
|
|
5074
|
+
adaptiveChangeWorthApplying,
|
|
5075
|
+
sampleIsUsable,
|
|
5076
|
+
inferShare,
|
|
5077
|
+
nextProbeWindow,
|
|
5078
|
+
restPerTick,
|
|
5079
|
+
MAX_ADAPTIVE_REFRESH_MS,
|
|
5080
|
+
BUDGET_SAFETY,
|
|
5081
|
+
MIN_SAMPLE_CALLS,
|
|
5082
|
+
ADAPTIVE_HYSTERESIS,
|
|
3390
5083
|
safe,
|
|
3391
5084
|
shortErr,
|
|
3392
5085
|
isUnavailable,
|
|
@@ -3411,17 +5104,51 @@ export {
|
|
|
3411
5104
|
usableSize,
|
|
3412
5105
|
severityRank,
|
|
3413
5106
|
pick,
|
|
5107
|
+
columnProps,
|
|
5108
|
+
adjustableWidthKeys,
|
|
5109
|
+
selectWidthKey,
|
|
5110
|
+
cycleWidthKey,
|
|
5111
|
+
resolveHeader,
|
|
5112
|
+
fitHeaderToFrame,
|
|
5113
|
+
adjustWidth,
|
|
5114
|
+
updateWidthPreference,
|
|
5115
|
+
resetWidthPreference,
|
|
5116
|
+
resetTabWidthPreferences,
|
|
3414
5117
|
minimumWidthFor,
|
|
5118
|
+
WIDTH_PREFERENCES_VERSION,
|
|
5119
|
+
widthPreferencesPath,
|
|
5120
|
+
parseWidthPreferences,
|
|
5121
|
+
serializeWidthPreferences,
|
|
5122
|
+
loadWidthPreferences,
|
|
5123
|
+
saveWidthPreferences,
|
|
5124
|
+
createWidthPreferenceWriter,
|
|
5125
|
+
DASHBOARD_CACHE_VERSION,
|
|
5126
|
+
dashboardCachePath,
|
|
5127
|
+
dashboardCacheTarget,
|
|
5128
|
+
serializeDashboardCache,
|
|
5129
|
+
loadDashboardCache,
|
|
5130
|
+
saveDashboardCache,
|
|
5131
|
+
nextSecurityRaw,
|
|
3415
5132
|
runStatusIcon,
|
|
3416
5133
|
RUN_STATUS_ICON,
|
|
3417
5134
|
SEVERITY_STYLE,
|
|
3418
5135
|
REVIEW_LABEL,
|
|
3419
5136
|
MIN_TABLE_WIDTH,
|
|
3420
5137
|
MIN_COMPACT_WIDTH,
|
|
5138
|
+
TABS,
|
|
3421
5139
|
OCT_NERD,
|
|
3422
5140
|
OCT_UNICODE,
|
|
3423
5141
|
KEY_TABLE,
|
|
3424
5142
|
KEY_HINTS,
|
|
5143
|
+
widthStatusText,
|
|
5144
|
+
headerGutterKey,
|
|
5145
|
+
HeaderCells,
|
|
5146
|
+
parseSgrMouse,
|
|
5147
|
+
dividerHandles,
|
|
5148
|
+
hitDivider,
|
|
5149
|
+
beginDividerDrag,
|
|
5150
|
+
draggedWidth,
|
|
5151
|
+
createTerminalLifecycle,
|
|
3425
5152
|
REMOTE_SETUP_HINTS,
|
|
3426
5153
|
REMOTE_SETUP_LINES,
|
|
3427
5154
|
REMOTE_SETUP_NONINTERACTIVE_LINES,
|