gh-glance 0.6.0 → 0.7.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 +67 -0
- package/README.md +106 -26
- package/index.mjs +1579 -150
- package/package.json +1 -1
package/index.mjs
CHANGED
|
@@ -23,7 +23,16 @@
|
|
|
23
23
|
process.env.NODE_ENV ??= "production";
|
|
24
24
|
|
|
25
25
|
import { execFile, spawn } from "node:child_process";
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
mkdirSync,
|
|
28
|
+
readFileSync,
|
|
29
|
+
realpathSync,
|
|
30
|
+
renameSync,
|
|
31
|
+
unlinkSync,
|
|
32
|
+
writeFileSync,
|
|
33
|
+
} from "node:fs";
|
|
34
|
+
import { homedir } from "node:os";
|
|
35
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
27
36
|
import { fileURLToPath } from "node:url";
|
|
28
37
|
import { promisify } from "node:util";
|
|
29
38
|
|
|
@@ -108,6 +117,8 @@ const ALERT_QUERY = `?state=open&per_page=${ALERT_PER_PAGE}&sort=created&directi
|
|
|
108
117
|
// single pane. Only the tab you are looking at needs REFRESH_MS latency; the
|
|
109
118
|
// other three exist to keep counts honest, which tolerates a minute.
|
|
110
119
|
const BACKGROUND_EVERY = 12;
|
|
120
|
+
// What one fetch of each tab costs lives in REST_PER_FETCH / GRAPHQL_PER_FETCH,
|
|
121
|
+
// declared with ALERT_SOURCES below because the security figure derives from it.
|
|
111
122
|
|
|
112
123
|
// A stalled `gh` used to wedge a tab permanently: the promise never settled, so
|
|
113
124
|
// the in-flight guard was never cleared and that tab stopped fetching for the
|
|
@@ -333,11 +344,18 @@ function errText(err) {
|
|
|
333
344
|
// "you can't see this here" -- everything else (auth expiry, rate limiting,
|
|
334
345
|
// DNS, a 502) is a real failure that used to be reported as a confident,
|
|
335
346
|
// plausible, and wrong claim that the feature was switched off.
|
|
347
|
+
// The GraphQL variant is matched as two fixed substrings rather than one
|
|
348
|
+
// `.*`-joined regex: CodeQL (js/polynomial-redos) flags a wildcard spanning
|
|
349
|
+
// attacker-influenced text -- a repository name gh echoes back verbatim --
|
|
350
|
+
// as worst-case superlinear. Two `includes` calls are index-scan cheap
|
|
351
|
+
// regardless of what sits between the markers and carry no backtracking risk.
|
|
336
352
|
function isUnavailable(err) {
|
|
337
353
|
const text = errText(err);
|
|
354
|
+
const lower = text.toLowerCase();
|
|
338
355
|
return (
|
|
339
356
|
/HTTP (403|404)/.test(text) ||
|
|
340
|
-
|
|
357
|
+
(lower.includes("could not resolve to a repository with the name") &&
|
|
358
|
+
lower.includes("(repository)"))
|
|
341
359
|
);
|
|
342
360
|
}
|
|
343
361
|
|
|
@@ -786,6 +804,10 @@ async function fetchActions(limit, signal) {
|
|
|
786
804
|
return {
|
|
787
805
|
raw,
|
|
788
806
|
limit,
|
|
807
|
+
// What this fetch actually cost, read from the one cost table rather than
|
|
808
|
+
// re-derived from the argv shape -- a second copy of the cost model is the
|
|
809
|
+
// drift this file warns about repeatedly.
|
|
810
|
+
restSpent: REST_PER_FETCH.actions,
|
|
789
811
|
parse: () =>
|
|
790
812
|
JSON.parse(raw).map((r) => ({
|
|
791
813
|
databaseId: r.databaseId,
|
|
@@ -831,6 +853,9 @@ async function fetchIssues(signal) {
|
|
|
831
853
|
return {
|
|
832
854
|
raw,
|
|
833
855
|
limit: LIST_LIMIT,
|
|
856
|
+
// Stated rather than omitted so the zero is visibly deliberate: SORT_RECENT's
|
|
857
|
+
// --search routes this through GraphQL, which is a separate budget.
|
|
858
|
+
restSpent: REST_PER_FETCH.issues,
|
|
834
859
|
parse: () =>
|
|
835
860
|
JSON.parse(raw).map((i) => ({
|
|
836
861
|
number: i.number,
|
|
@@ -862,6 +887,8 @@ async function fetchPRs(signal) {
|
|
|
862
887
|
return {
|
|
863
888
|
raw,
|
|
864
889
|
limit: LIST_LIMIT,
|
|
890
|
+
// Zero for the same reason as issues: --search makes this a GraphQL call.
|
|
891
|
+
restSpent: REST_PER_FETCH.prs,
|
|
865
892
|
parse: () =>
|
|
866
893
|
JSON.parse(raw).map((p) => ({
|
|
867
894
|
number: p.number,
|
|
@@ -939,6 +966,148 @@ const ALERT_SOURCES = [
|
|
|
939
966
|
},
|
|
940
967
|
];
|
|
941
968
|
|
|
969
|
+
// What one fetch of each tab costs, per budget. The single source of truth:
|
|
970
|
+
// `projectedHourlyCost` derives the hourly figure from it and the spend meter
|
|
971
|
+
// bills against it, so a corrected number cannot reach one and miss the other.
|
|
972
|
+
//
|
|
973
|
+
// actions is 2, not 1: measured 2026-08-10 with `GH_DEBUG=api`, `gh run list`
|
|
974
|
+
// issues GET /actions/runs *and* GET /actions/workflows. The 1 that stood here
|
|
975
|
+
// understated the default tab -- the tab every pane starts on, since
|
|
976
|
+
// initialTabIndex defaults to 0 -- by half.
|
|
977
|
+
//
|
|
978
|
+
// issues and prs are 0 REST because SORT_RECENT's --search routes both through
|
|
979
|
+
// GraphQL entirely (2 POSTs each, confirmed by the same measurement).
|
|
980
|
+
const REST_PER_FETCH = { actions: 2, issues: 0, prs: 0, security: ALERT_SOURCES.length };
|
|
981
|
+
const GRAPHQL_PER_FETCH = { actions: 0, issues: 2, prs: 2, security: 0 };
|
|
982
|
+
|
|
983
|
+
// Ceiling on adaptive widening. An adaptive interval needs one: a minute is
|
|
984
|
+
// where the pane stops being live in any useful sense, and past it the honest
|
|
985
|
+
// answer is fewer panes, not a slower one.
|
|
986
|
+
const MAX_ADAPTIVE_REFRESH_MS = 60_000;
|
|
987
|
+
|
|
988
|
+
// Target this fraction of what the token can afford, not all of it. The margin
|
|
989
|
+
// is for the user's own `gh` and `git` commands, which are the whole reason the
|
|
990
|
+
// pane yields at all -- see RATE_LIMIT_RETRY_MS.
|
|
991
|
+
const BUDGET_SAFETY = 0.8;
|
|
992
|
+
|
|
993
|
+
// How often to re-read the budget. `gh api rate_limit` does not count against
|
|
994
|
+
// the limit (verified, delta 0 -- see rateBudget) but it is still a subprocess,
|
|
995
|
+
// so once a minute rather than once a tick: the quantity it measures moves on
|
|
996
|
+
// the scale of minutes.
|
|
997
|
+
const BUDGET_PROBE_MS = 60_000;
|
|
998
|
+
|
|
999
|
+
// Below this many of our own REST calls in a probe window, the share inference
|
|
1000
|
+
// is noise -- one call against a global delta of one reads as "we are the only
|
|
1001
|
+
// consumer" whether or not that is true. Under the threshold the loop declines
|
|
1002
|
+
// to infer a new share rather than guessing.
|
|
1003
|
+
const MIN_SAMPLE_CALLS = 5;
|
|
1004
|
+
|
|
1005
|
+
// Only move the applied interval when the target differs by more than this
|
|
1006
|
+
// factor either way, so a budget wobbling around a boundary cannot make the
|
|
1007
|
+
// poll rate flap. Same reasoning as TAB_LABEL_HYSTERESIS.
|
|
1008
|
+
const ADAPTIVE_HYSTERESIS = 1.25;
|
|
1009
|
+
|
|
1010
|
+
// Whether a probe window holds enough of our own calls for the ratio below to
|
|
1011
|
+
// mean anything. Named rather than inlined because the control law and the loop
|
|
1012
|
+
// that feeds it must agree on the answer: the law uses it to decide whether to
|
|
1013
|
+
// infer, and the loop uses it to decide whether the window may be closed.
|
|
1014
|
+
function sampleIsUsable(sample) {
|
|
1015
|
+
return Boolean(sample) && sample.myRestCalls >= MIN_SAMPLE_CALLS && sample.globalUsed > 0;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// How many equivalent consumers are on this token, from one probe window.
|
|
1019
|
+
//
|
|
1020
|
+
// `lastShare` is what the previous measurable window said, and it is the answer
|
|
1021
|
+
// whenever this window is not measurable. Falling back to 1 instead would be the
|
|
1022
|
+
// controller starving its own input: a pane that has widened to 40s makes about
|
|
1023
|
+
// three REST calls per probe window, below MIN_SAMPLE_CALLS, so it would read
|
|
1024
|
+
// "no other consumers", snap back to the floor, spend fast enough to measure
|
|
1025
|
+
// again, and re-throttle -- a flap on a two-minute cycle at exactly the instance
|
|
1026
|
+
// counts the widening exists for. Holding the last measurement keeps the pane
|
|
1027
|
+
// where it is until a window is long enough to say otherwise.
|
|
1028
|
+
function inferShare(sample, lastShare = 1) {
|
|
1029
|
+
return sampleIsUsable(sample)
|
|
1030
|
+
? Math.max(1, sample.globalUsed / sample.myRestCalls)
|
|
1031
|
+
: Math.max(1, lastShare);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// One probe window's bookkeeping. Pure, so the flap it prevents is testable
|
|
1035
|
+
// without a timer: `prev` is `{ used, spent }` from the last window that could
|
|
1036
|
+
// be measured, or null before the first probe.
|
|
1037
|
+
//
|
|
1038
|
+
// The baseline only advances when the window was usable, so an unmeasurable
|
|
1039
|
+
// window is not thrown away -- it grows until it holds MIN_SAMPLE_CALLS of our
|
|
1040
|
+
// own calls, with both halves of the ratio still spanning the same stretch of
|
|
1041
|
+
// time. The cost is latency, not accuracy: a pane at the 60s cap needs two or
|
|
1042
|
+
// three probes rather than one to notice that the other panes have exited.
|
|
1043
|
+
function nextProbeWindow(prev, budget, spentTotal) {
|
|
1044
|
+
// A window reset makes `used` fall. The span before the reset cannot be
|
|
1045
|
+
// compared against the counter after it, so the window restarts here and this
|
|
1046
|
+
// cycle infers nothing -- the caller keeps the share it last measured, which
|
|
1047
|
+
// is the one direction that cannot spike the poll rate at the top of an hour.
|
|
1048
|
+
if (prev === null || budget.used < prev.used) {
|
|
1049
|
+
return { sample: null, next: { used: budget.used, spent: spentTotal } };
|
|
1050
|
+
}
|
|
1051
|
+
const sample = {
|
|
1052
|
+
globalUsed: budget.used - prev.used,
|
|
1053
|
+
myRestCalls: spentTotal - prev.spent,
|
|
1054
|
+
};
|
|
1055
|
+
return { sample, next: sampleIsUsable(sample) ? { used: budget.used, spent: spentTotal } : prev };
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// How wide the active-tab interval has to be for this instance to fit its share
|
|
1059
|
+
// of the token's remaining budget.
|
|
1060
|
+
//
|
|
1061
|
+
// The share is *inferred*, not configured: this instance knows exactly how many
|
|
1062
|
+
// REST calls it has spent since the last probe, and `rate_limit` reports how
|
|
1063
|
+
// many the token spent in total over the same window. The ratio is how many
|
|
1064
|
+
// equivalent consumers are on this token -- other panes, a `gh pr checks
|
|
1065
|
+
// --watch`, an agent shelling out to `gh`. That is why no IPC and no on-disk
|
|
1066
|
+
// registry is needed: the token's own counter is the shared channel, and it
|
|
1067
|
+
// already aggregates everything.
|
|
1068
|
+
//
|
|
1069
|
+
// Deliberately one-directional: the returned value is never below `floorMs`, so
|
|
1070
|
+
// --refresh and GH_GLANCE_REFRESH stay a floor the loop widens from and cannot
|
|
1071
|
+
// tighten past. A single instance on a fresh window computes a required interval
|
|
1072
|
+
// below the floor and therefore stays at exactly the configured refresh, which
|
|
1073
|
+
// is what keeps this from changing ordinary single-pane behaviour.
|
|
1074
|
+
//
|
|
1075
|
+
// MAX_ADAPTIVE_REFRESH_MS is wrapped in Math.max(floorMs, ...) at every exit, so
|
|
1076
|
+
// a user who configures a refresh wider than the cap is never silently sped up.
|
|
1077
|
+
//
|
|
1078
|
+
// `lastShare` defaults to 1, so a caller with no history behaves exactly as this
|
|
1079
|
+
// did before it existed: one instance, no adaptation.
|
|
1080
|
+
function adaptiveRefreshMs({ budget, sample, restPerTick, floorMs, nowMs, lastShare = 1 }) {
|
|
1081
|
+
if (!budget || !Number.isFinite(budget.remaining) || restPerTick <= 0) return floorMs;
|
|
1082
|
+
if (budget.remaining <= 0) return Math.max(floorMs, MAX_ADAPTIVE_REFRESH_MS);
|
|
1083
|
+
|
|
1084
|
+
const secondsToReset = Math.max(1, (budget.resetMs - nowMs) / 1000);
|
|
1085
|
+
const affordable = (budget.remaining / secondsToReset) * BUDGET_SAFETY;
|
|
1086
|
+
if (affordable <= 0) return Math.max(floorMs, MAX_ADAPTIVE_REFRESH_MS);
|
|
1087
|
+
|
|
1088
|
+
const mine = affordable / inferShare(sample, lastShare);
|
|
1089
|
+
const requiredMs = (restPerTick / mine) * 1000;
|
|
1090
|
+
return Math.min(Math.max(floorMs, requiredMs), Math.max(floorMs, MAX_ADAPTIVE_REFRESH_MS));
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// Whether a newly computed target is different enough to act on.
|
|
1094
|
+
function adaptiveChangeWorthApplying(appliedMs, targetMs) {
|
|
1095
|
+
if (appliedMs <= 0) return true;
|
|
1096
|
+
const ratio = targetMs / appliedMs;
|
|
1097
|
+
return ratio >= ADAPTIVE_HYSTERESIS || ratio <= 1 / ADAPTIVE_HYSTERESIS;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// REST cost of one tick in this configuration: the active tab every tick, the
|
|
1101
|
+
// other three amortised over BACKGROUND_EVERY. Same table and same shape as
|
|
1102
|
+
// projectedHourlyCost, so the controller and the report cannot disagree.
|
|
1103
|
+
function restPerTick(activeKey) {
|
|
1104
|
+
let cost = REST_PER_FETCH[activeKey] ?? 0;
|
|
1105
|
+
for (const key of TAB_KEYS) {
|
|
1106
|
+
if (key !== activeKey) cost += (REST_PER_FETCH[key] ?? 0) / BACKGROUND_EVERY;
|
|
1107
|
+
}
|
|
1108
|
+
return cost;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
942
1111
|
function alertArgs(source) {
|
|
943
1112
|
return ["api", apiPath(source.path), ...apiHostArgs(), "--jq", source.jq];
|
|
944
1113
|
}
|
|
@@ -948,6 +1117,18 @@ function alertArgs(source) {
|
|
|
948
1117
|
// mid-session is picked up within the hour.
|
|
949
1118
|
const alertBackoff = new Map();
|
|
950
1119
|
|
|
1120
|
+
// REST calls this process has spent, ever. In-memory and per-process, in the
|
|
1121
|
+
// same spirit as alertBackoff above: lost on exit, and nothing depends on it
|
|
1122
|
+
// surviving. Only ever compared against itself between two budget probes, so it
|
|
1123
|
+
// needs no windowing and cannot overflow in any realistic session.
|
|
1124
|
+
//
|
|
1125
|
+
// Deliberately incomplete: openItem, preflight and resolveFailureContext also
|
|
1126
|
+
// spend, and are not counted, because they are occasional rather than periodic.
|
|
1127
|
+
// Under-reporting our own spend makes the inferred share *larger* and so
|
|
1128
|
+
// throttles slightly harder than strictly necessary -- the safe direction to err
|
|
1129
|
+
// in, which is why the imprecision is tolerated rather than chased.
|
|
1130
|
+
let restSpentTotal = 0;
|
|
1131
|
+
|
|
951
1132
|
function backoffActive(key, now) {
|
|
952
1133
|
const state = alertBackoff.get(key);
|
|
953
1134
|
return Boolean(state) && now < state.until;
|
|
@@ -983,13 +1164,20 @@ async function fetchAlertSource(source, signal, now) {
|
|
|
983
1164
|
// The verdict is replayed alongside the note. Replaying only the note left
|
|
984
1165
|
// the tab unable to tell "Dependabot is switched off here" from "we cannot
|
|
985
1166
|
// see Dependabot" for the whole length of a backoff window.
|
|
986
|
-
|
|
1167
|
+
// Nothing was spawned, so nothing was billed -- which is the whole reason
|
|
1168
|
+
// the meter is fed by the fetchers rather than by counting ticks.
|
|
1169
|
+
return {
|
|
1170
|
+
raw: `backoff:${note}`,
|
|
1171
|
+
spent: 0,
|
|
1172
|
+
parse: () => ({ alerts: [], note, verdict, truncated: false }),
|
|
1173
|
+
};
|
|
987
1174
|
}
|
|
988
1175
|
try {
|
|
989
1176
|
const raw = await runGh(alertArgs(source), { signal });
|
|
990
1177
|
clearBackoff(source.key);
|
|
991
1178
|
return {
|
|
992
1179
|
raw,
|
|
1180
|
+
spent: 1,
|
|
993
1181
|
parse: () => {
|
|
994
1182
|
const rows = JSON.parse(raw).filter((a) => a.state === "open");
|
|
995
1183
|
return {
|
|
@@ -1014,6 +1202,9 @@ async function fetchAlertSource(source, signal, now) {
|
|
|
1014
1202
|
}
|
|
1015
1203
|
return {
|
|
1016
1204
|
raw: `unavailable:${note}`,
|
|
1205
|
+
// A failed call still bills: the request reached GitHub and was counted
|
|
1206
|
+
// whether it returned data, `[]`, or a 403.
|
|
1207
|
+
spent: 1,
|
|
1017
1208
|
parse: () => ({ alerts: [], note, verdict, truncated: false }),
|
|
1018
1209
|
};
|
|
1019
1210
|
}
|
|
@@ -1047,6 +1238,10 @@ async function fetchSecurity(signal) {
|
|
|
1047
1238
|
// colliding on the same string.
|
|
1048
1239
|
raw: parts.map((p) => p.raw).join("\0"),
|
|
1049
1240
|
limit: ALERT_PER_PAGE,
|
|
1241
|
+
// Summed from what each source reported rather than assumed to be
|
|
1242
|
+
// ALERT_SOURCES.length: a source held off by backoff spawned nothing and
|
|
1243
|
+
// must not be billed for it.
|
|
1244
|
+
restSpent: parts.reduce((n, p) => n + p.spent, 0),
|
|
1050
1245
|
parse: () => {
|
|
1051
1246
|
const parsed = parts.map((p) => p.parse());
|
|
1052
1247
|
const alerts = parsed.flatMap((p) => p.alerts);
|
|
@@ -1161,6 +1356,7 @@ const DOCTOR_ENV_PLAIN = [
|
|
|
1161
1356
|
"NO_PROXY",
|
|
1162
1357
|
"GH_GLANCE_ICONS",
|
|
1163
1358
|
"GH_GLANCE_NO_ANIMATION",
|
|
1359
|
+
"GH_GLANCE_REFRESH",
|
|
1164
1360
|
"NO_COLOR",
|
|
1165
1361
|
"NODE_ENV",
|
|
1166
1362
|
];
|
|
@@ -1286,8 +1482,8 @@ function targetSource() {
|
|
|
1286
1482
|
|
|
1287
1483
|
// How much of the hourly API budget is left, and roughly how fast this
|
|
1288
1484
|
// configuration spends it. The steady-state cost is not small and was invisible:
|
|
1289
|
-
// at the default 5s refresh with the Security tab open it is around 2,
|
|
1290
|
-
// requests an hour -- about
|
|
1485
|
+
// at the default 5s refresh with the Security tab open it is around 2,280 REST
|
|
1486
|
+
// requests an hour -- about 46% of a personal token's 5,000 -- and `--refresh 2`
|
|
1291
1487
|
// projects past the limit outright, so it exhausts inside the hour, every hour.
|
|
1292
1488
|
// The budget is shared with everything else the token does, so the first symptom
|
|
1293
1489
|
// is usually "GitHub is broken" somewhere else entirely.
|
|
@@ -1298,7 +1494,10 @@ function targetSource() {
|
|
|
1298
1494
|
// exactly why this reports the server's own numbers rather than asserting 5,000.
|
|
1299
1495
|
async function rateBudget() {
|
|
1300
1496
|
try {
|
|
1301
|
-
|
|
1497
|
+
// Host-routed like every other `gh api` call: a budget is per token *per
|
|
1498
|
+
// server*, so on a GHES tenant the github.com numbers are not merely stale,
|
|
1499
|
+
// they belong to a different limit entirely.
|
|
1500
|
+
const raw = await runGh(["api", "rate_limit", ...apiHostArgs()]);
|
|
1302
1501
|
const { resources } = JSON.parse(raw);
|
|
1303
1502
|
// formatAge() is for timestamps in the past and returns "-" for a future one,
|
|
1304
1503
|
// so the reset is rendered as a forward interval instead.
|
|
@@ -1314,24 +1513,52 @@ async function rateBudget() {
|
|
|
1314
1513
|
}
|
|
1315
1514
|
}
|
|
1316
1515
|
|
|
1516
|
+
// The same probe as rateBudget, but returning `core` as data rather than a
|
|
1517
|
+
// sentence, because the adaptive loop has to do arithmetic on it. rateBudget
|
|
1518
|
+
// keeps its own shape: it formats `graphql` too, which the controller does not
|
|
1519
|
+
// need, and it is on the --doctor path where a string is the product.
|
|
1520
|
+
//
|
|
1521
|
+
// Safe to run on a timer for the same reason it is safe on the diagnostic path:
|
|
1522
|
+
// `gh api rate_limit` is documented as not counting against the limit, and it
|
|
1523
|
+
// measures as free (verified: delta 0).
|
|
1524
|
+
async function readCoreBudget(signal) {
|
|
1525
|
+
try {
|
|
1526
|
+
// apiHostArgs() for the same reason the alert endpoints carry it: --repo
|
|
1527
|
+
// host/owner/name sets runtime.host without setting GH_HOST, so without the
|
|
1528
|
+
// flag this reads github.com's budget while the pane spends against the
|
|
1529
|
+
// tenant -- and then throttles, or fails to, against an unrelated number.
|
|
1530
|
+
const raw = await runGh(["api", "rate_limit", ...apiHostArgs()], { signal });
|
|
1531
|
+
const core = JSON.parse(raw)?.resources?.core;
|
|
1532
|
+
if (!core || !Number.isFinite(core.remaining)) return null;
|
|
1533
|
+
return {
|
|
1534
|
+
remaining: core.remaining,
|
|
1535
|
+
limit: core.limit,
|
|
1536
|
+
used: core.used,
|
|
1537
|
+
resetMs: core.reset * 1000,
|
|
1538
|
+
};
|
|
1539
|
+
} catch {
|
|
1540
|
+
// A probe failure must never be louder than the pane's own data: the loop
|
|
1541
|
+
// simply does not adapt this cycle and stays at whatever it last applied.
|
|
1542
|
+
return null;
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1317
1546
|
// Requests per hour this configuration will spend once it settles, derived from
|
|
1318
1547
|
// the same constants the poll loop uses rather than from a number written down
|
|
1319
1548
|
// once and left to rot. The active tab refreshes every tick; the other three
|
|
1320
|
-
// every BACKGROUND_EVERY ticks.
|
|
1321
|
-
//
|
|
1322
|
-
//
|
|
1549
|
+
// every BACKGROUND_EVERY ticks. The per-fetch prices come from REST_PER_FETCH
|
|
1550
|
+
// and GRAPHQL_PER_FETCH, which are also what the spend meter bills against --
|
|
1551
|
+
// two copies of them would diverge the first time one was fixed.
|
|
1323
1552
|
function projectedHourlyCost(activeKey) {
|
|
1324
1553
|
const perHour = 3_600_000 / runtime.refreshMs;
|
|
1325
|
-
const restCalls = { actions: 1, issues: 0, prs: 0, security: ALERT_SOURCES.length };
|
|
1326
|
-
const graphqlCalls = { actions: 0, issues: 2, prs: 2, security: 0 };
|
|
1327
1554
|
let rest = 0;
|
|
1328
1555
|
let graphql = 0;
|
|
1329
1556
|
// TAB_KEYS rather than TABS: this runs on the --doctor path, which deliberately
|
|
1330
1557
|
// returns before the render tree is reached, and TABS is declared down there.
|
|
1331
1558
|
for (const key of TAB_KEYS) {
|
|
1332
1559
|
const ticks = key === activeKey ? perHour : perHour / BACKGROUND_EVERY;
|
|
1333
|
-
rest += ticks *
|
|
1334
|
-
graphql += ticks *
|
|
1560
|
+
rest += ticks * REST_PER_FETCH[key];
|
|
1561
|
+
graphql += ticks * GRAPHQL_PER_FETCH[key];
|
|
1335
1562
|
}
|
|
1336
1563
|
return { rest: Math.round(rest), graphql: Math.round(graphql) };
|
|
1337
1564
|
}
|
|
@@ -1488,6 +1715,11 @@ function parseArgs(argv) {
|
|
|
1488
1715
|
doctor: false,
|
|
1489
1716
|
repo: null,
|
|
1490
1717
|
refresh: null,
|
|
1718
|
+
// Which surface supplied `refresh`, so validateArgs can name it in the
|
|
1719
|
+
// bounds messages. argv never sets it -- the entry block does, when it
|
|
1720
|
+
// falls back to GH_GLANCE_REFRESH -- but it is declared here so the shape
|
|
1721
|
+
// parseArgs returns stays stated in one place.
|
|
1722
|
+
refreshSource: null,
|
|
1491
1723
|
tab: null,
|
|
1492
1724
|
verbose: false,
|
|
1493
1725
|
};
|
|
@@ -1530,13 +1762,16 @@ function validateArgs(opts, tabKeys) {
|
|
|
1530
1762
|
|
|
1531
1763
|
let refreshMs = null;
|
|
1532
1764
|
if (opts.refresh !== null) {
|
|
1765
|
+
// Named so the message points at whichever surface supplied the value: the
|
|
1766
|
+
// interval can now come from GH_GLANCE_REFRESH as well as --refresh.
|
|
1767
|
+
const refreshLabel = opts.refreshSource ?? "--refresh";
|
|
1533
1768
|
const seconds = Number(opts.refresh);
|
|
1534
1769
|
if (!Number.isFinite(seconds) || !Number.isInteger(seconds)) {
|
|
1535
|
-
throw new Error(
|
|
1770
|
+
throw new Error(`${refreshLabel} must be a whole number of seconds, got: ${opts.refresh}`);
|
|
1536
1771
|
}
|
|
1537
1772
|
if (seconds < MIN_REFRESH_SECONDS || seconds > MAX_REFRESH_SECONDS) {
|
|
1538
1773
|
throw new Error(
|
|
1539
|
-
|
|
1774
|
+
`${refreshLabel} must be between ${MIN_REFRESH_SECONDS} and ${MAX_REFRESH_SECONDS} seconds, got: ${seconds}`,
|
|
1540
1775
|
);
|
|
1541
1776
|
}
|
|
1542
1777
|
refreshMs = seconds * 1000;
|
|
@@ -1574,8 +1809,9 @@ const KEY_TABLE = [
|
|
|
1574
1809
|
["PgUp / PgDn", "Move a page at a time"],
|
|
1575
1810
|
["Enter", "Open the selected item or accept a prompt"],
|
|
1576
1811
|
["r", "Refresh the current tab now"],
|
|
1812
|
+
["w", "Adjust table column widths"],
|
|
1577
1813
|
["?", "Show the keys (any key closes it)"],
|
|
1578
|
-
["q / Esc / Ctrl+C", "Quit"],
|
|
1814
|
+
["q / Esc / Ctrl+C", "Quit (Esc leaves width mode)"],
|
|
1579
1815
|
];
|
|
1580
1816
|
const KEY_COL = Math.max(...KEY_TABLE.map(([k]) => k.length)) + 3;
|
|
1581
1817
|
const keyTableLines = () => KEY_TABLE.map(([k, d]) => `${k.padEnd(KEY_COL)}${d}`);
|
|
@@ -1636,6 +1872,9 @@ Environment:
|
|
|
1636
1872
|
GH_HOST=host GitHub Enterprise or EMU host to send every call to.
|
|
1637
1873
|
A host-qualified GH_REPO does not route \`gh api\`;
|
|
1638
1874
|
this and --repo host/owner/name both do.
|
|
1875
|
+
GH_GLANCE_REFRESH=<seconds>
|
|
1876
|
+
Active-tab poll interval, ${MIN_REFRESH_SECONDS}-${MAX_REFRESH_SECONDS}
|
|
1877
|
+
(--refresh takes precedence)
|
|
1639
1878
|
GH_GLANCE_ICONS=unicode Plain-unicode status icons (no Nerd Font needed)
|
|
1640
1879
|
GH_GLANCE_NO_ANIMATION=1 Freeze the spinner (no motion)
|
|
1641
1880
|
NO_COLOR=1 Disable colour (status stays readable)
|
|
@@ -1653,7 +1892,20 @@ Environment:
|
|
|
1653
1892
|
if (IS_MAIN) {
|
|
1654
1893
|
let opts;
|
|
1655
1894
|
try {
|
|
1656
|
-
|
|
1895
|
+
const argvOpts = parseArgs(process.argv.slice(2));
|
|
1896
|
+
// The flag wins, which is the precedence GH_REPO already advertises -- but
|
|
1897
|
+
// not its mechanism: GH_REPO is read at each use site, because `gh` honours
|
|
1898
|
+
// it natively and a repo slug needs no validation. An interval does, so it
|
|
1899
|
+
// is resolved once, here, and substituted before validation -- an
|
|
1900
|
+
// out-of-range GH_GLANCE_REFRESH is then refused by the same two messages
|
|
1901
|
+
// an out-of-range --refresh gets, rather than by a second copy of the
|
|
1902
|
+
// bounds. It still reaches runtime through the one write site below rather
|
|
1903
|
+
// than being assigned beside it.
|
|
1904
|
+
if (argvOpts.refresh === null && process.env.GH_GLANCE_REFRESH) {
|
|
1905
|
+
argvOpts.refresh = process.env.GH_GLANCE_REFRESH;
|
|
1906
|
+
argvOpts.refreshSource = "GH_GLANCE_REFRESH";
|
|
1907
|
+
}
|
|
1908
|
+
opts = validateArgs(argvOpts, TAB_KEYS);
|
|
1657
1909
|
} catch (err) {
|
|
1658
1910
|
// Exit 2 for every argv problem, unchanged from when the only possible
|
|
1659
1911
|
// problem was an unrecognised flag. CI asserts this code.
|
|
@@ -1727,10 +1979,10 @@ if (IS_MAIN) {
|
|
|
1727
1979
|
}
|
|
1728
1980
|
|
|
1729
1981
|
const ReactModule = await import("react");
|
|
1730
|
-
const { render, Box, Text, useStdout, useInput, useStdin, useApp } = await import("ink");
|
|
1982
|
+
const { render, measureElement, Box, Text, useStdout, useInput, useStdin, useApp } = await import("ink");
|
|
1731
1983
|
|
|
1732
1984
|
const React = ReactModule.default;
|
|
1733
|
-
const { useState, useEffect, useRef } = ReactModule;
|
|
1985
|
+
const { useState, useEffect, useMemo, useRef, useCallback } = ReactModule;
|
|
1734
1986
|
const e = React.createElement;
|
|
1735
1987
|
|
|
1736
1988
|
// The NODE_ENV escape hatch above is genuinely useful -- React's warnings are
|
|
@@ -1833,10 +2085,10 @@ const ERROR_TEXT = "red";
|
|
|
1833
2085
|
// column is otherwise a private-use codepoint that announces as nothing --
|
|
1834
2086
|
// leaving every row with no status at all. It is derived from the same lookup
|
|
1835
2087
|
// that picks the glyph, so the two cannot drift apart.
|
|
1836
|
-
function Column({ width, grow, children, bold, color, dim, wrap, label }) {
|
|
2088
|
+
function Column({ width, grow, children, bold, color, dim, wrap, label, marginRight = 1 }) {
|
|
1837
2089
|
return e(
|
|
1838
2090
|
Box,
|
|
1839
|
-
{ width, flexGrow: grow ? 1 : 0, flexShrink: grow ? 1 : 0, marginRight
|
|
2091
|
+
{ width, flexGrow: grow ? 1 : 0, flexShrink: grow ? 1 : 0, marginRight },
|
|
1840
2092
|
e(
|
|
1841
2093
|
Text,
|
|
1842
2094
|
{ bold, color, dimColor: dim, wrap: wrap ?? "truncate-end", "aria-label": label },
|
|
@@ -1845,10 +2097,35 @@ function Column({ width, grow, children, bold, color, dim, wrap, label }) {
|
|
|
1845
2097
|
);
|
|
1846
2098
|
}
|
|
1847
2099
|
|
|
1848
|
-
|
|
2100
|
+
// Each descriptor still owns one gutter cell, but an adjustable column owns
|
|
2101
|
+
// the edge that faces the flexible TITLE/SUMMARY reservoir. Fixed columns after
|
|
2102
|
+
// the grow cell therefore use the preceding descriptor's trailing gutter;
|
|
2103
|
+
// fixed columns before it use their own. Keeping this mapping pure lets the
|
|
2104
|
+
// pointer phase share the exact same geometry as the visible grips.
|
|
2105
|
+
function headerGutterKeyAt(cells, index, growIndex) {
|
|
2106
|
+
if (!Array.isArray(cells) || !Number.isSafeInteger(index) || index < 0 || index >= cells.length) {
|
|
2107
|
+
return null;
|
|
2108
|
+
}
|
|
2109
|
+
if (growIndex < 0) return null;
|
|
2110
|
+
if (index < growIndex) {
|
|
2111
|
+
return isAdjustableWidthColumn(cells[index]) ? cells[index].key : null;
|
|
2112
|
+
}
|
|
2113
|
+
return isAdjustableWidthColumn(cells[index + 1]) ? cells[index + 1].key : null;
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
function headerGutterKey(cells, index) {
|
|
2117
|
+
const growIndex = Array.isArray(cells)
|
|
2118
|
+
? cells.findIndex((column) => column.props?.grow)
|
|
2119
|
+
: -1;
|
|
2120
|
+
return headerGutterKeyAt(cells, index, growIndex);
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
function HeaderCells({ cells, selectedWidthKey = null, headerRef = null }) {
|
|
2124
|
+
const growIndex = cells.findIndex((column) => column.props?.grow);
|
|
1849
2125
|
return e(
|
|
1850
2126
|
Box,
|
|
1851
2127
|
{
|
|
2128
|
+
ref: headerRef,
|
|
1852
2129
|
flexDirection: "row",
|
|
1853
2130
|
borderStyle: "single",
|
|
1854
2131
|
borderTop: false,
|
|
@@ -1856,7 +2133,198 @@ function HeaderCells({ cells }) {
|
|
|
1856
2133
|
borderRight: false,
|
|
1857
2134
|
borderColor: BORDER_COLOR,
|
|
1858
2135
|
},
|
|
1859
|
-
...cells.
|
|
2136
|
+
...cells.flatMap((c, index) => {
|
|
2137
|
+
const gripKey = headerGutterKeyAt(cells, index, growIndex);
|
|
2138
|
+
const selected = gripKey !== null && gripKey === selectedWidthKey;
|
|
2139
|
+
return [
|
|
2140
|
+
e(
|
|
2141
|
+
Column,
|
|
2142
|
+
{ key: `${c.key}:cell`, ...c.props, bold: true, dim: true, marginRight: 0 },
|
|
2143
|
+
c.label,
|
|
2144
|
+
),
|
|
2145
|
+
e(
|
|
2146
|
+
Text,
|
|
2147
|
+
{
|
|
2148
|
+
key: `${c.key}:gutter`,
|
|
2149
|
+
color: selected ? TITLE_COLOR : BORDER_COLOR,
|
|
2150
|
+
bold: selected,
|
|
2151
|
+
dimColor: gripKey !== null && !selected,
|
|
2152
|
+
"aria-hidden": true,
|
|
2153
|
+
},
|
|
2154
|
+
gripKey === null ? " " : "│",
|
|
2155
|
+
),
|
|
2156
|
+
];
|
|
2157
|
+
}),
|
|
2158
|
+
);
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
const MemoHeaderCells = React.memo(HeaderCells);
|
|
2162
|
+
|
|
2163
|
+
function parseSgrMouse(input) {
|
|
2164
|
+
if (typeof input !== "string") return null;
|
|
2165
|
+
const match = /^\[<(\d+);(\d+);(\d+)([Mm])$/.exec(input);
|
|
2166
|
+
if (!match) return null;
|
|
2167
|
+
|
|
2168
|
+
const code = Number(match[1]);
|
|
2169
|
+
const encodedX = Number(match[2]);
|
|
2170
|
+
const encodedY = Number(match[3]);
|
|
2171
|
+
if (
|
|
2172
|
+
!Number.isSafeInteger(code) ||
|
|
2173
|
+
!Number.isSafeInteger(encodedX) ||
|
|
2174
|
+
!Number.isSafeInteger(encodedY) ||
|
|
2175
|
+
encodedX < 1 ||
|
|
2176
|
+
encodedY < 1 ||
|
|
2177
|
+
(code !== 0 && code !== 32)
|
|
2178
|
+
) {
|
|
2179
|
+
return null;
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
return {
|
|
2183
|
+
x: encodedX - 1,
|
|
2184
|
+
y: encodedY - 1,
|
|
2185
|
+
action: match[4] === "m" ? "release" : code === 32 ? "drag" : "press",
|
|
2186
|
+
};
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
function dividerHandles({ header, metrics }) {
|
|
2190
|
+
if (
|
|
2191
|
+
!Array.isArray(header) ||
|
|
2192
|
+
!metrics ||
|
|
2193
|
+
!Number.isSafeInteger(metrics.x) ||
|
|
2194
|
+
!Number.isSafeInteger(metrics.y) ||
|
|
2195
|
+
!Number.isSafeInteger(metrics.width) ||
|
|
2196
|
+
!Number.isSafeInteger(metrics.height) ||
|
|
2197
|
+
metrics.x < 0 ||
|
|
2198
|
+
metrics.y < 0 ||
|
|
2199
|
+
metrics.width < 1 ||
|
|
2200
|
+
metrics.height < 1
|
|
2201
|
+
) {
|
|
2202
|
+
return [];
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
const growIndexes = header
|
|
2206
|
+
.map((column, index) => column.props?.grow ? index : -1)
|
|
2207
|
+
.filter((index) => index >= 0);
|
|
2208
|
+
if (growIndexes.length !== 1) return [];
|
|
2209
|
+
const growIndex = growIndexes[0];
|
|
2210
|
+
const fixedWidth = header.reduce((sum, column, index) => {
|
|
2211
|
+
if (index === growIndex) return sum;
|
|
2212
|
+
return Number.isSafeInteger(column.props?.width) && column.props.width >= 0
|
|
2213
|
+
? sum + column.props.width
|
|
2214
|
+
: Number.NaN;
|
|
2215
|
+
}, 0);
|
|
2216
|
+
const growWidth = metrics.width - header.length - fixedWidth;
|
|
2217
|
+
const yEnd = metrics.y + metrics.height;
|
|
2218
|
+
if (!Number.isSafeInteger(growWidth) || growWidth < 0 || !Number.isSafeInteger(yEnd)) return [];
|
|
2219
|
+
|
|
2220
|
+
const handles = [];
|
|
2221
|
+
let x = metrics.x;
|
|
2222
|
+
for (let index = 0; index < header.length; index += 1) {
|
|
2223
|
+
const contentWidth = index === growIndex ? growWidth : header[index].props.width;
|
|
2224
|
+
const gutterX = x + contentWidth;
|
|
2225
|
+
const key = headerGutterKeyAt(header, index, growIndex);
|
|
2226
|
+
if (key !== null) {
|
|
2227
|
+
const ownerIndex = index < growIndex ? index : index + 1;
|
|
2228
|
+
const owner = header[ownerIndex];
|
|
2229
|
+
if (!isAdjustableWidthColumn(owner) || owner.key !== key) return [];
|
|
2230
|
+
handles.push({
|
|
2231
|
+
key,
|
|
2232
|
+
x: gutterX,
|
|
2233
|
+
yStart: metrics.y,
|
|
2234
|
+
yEnd,
|
|
2235
|
+
width: owner.props.width,
|
|
2236
|
+
direction: ownerIndex < growIndex ? 1 : -1,
|
|
2237
|
+
});
|
|
2238
|
+
}
|
|
2239
|
+
x = gutterX + 1;
|
|
2240
|
+
}
|
|
2241
|
+
return handles;
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
function hitDivider(handles, point, tolerance = 1) {
|
|
2245
|
+
if (
|
|
2246
|
+
!Array.isArray(handles) ||
|
|
2247
|
+
!point ||
|
|
2248
|
+
!Number.isSafeInteger(point.x) ||
|
|
2249
|
+
!Number.isSafeInteger(point.y) ||
|
|
2250
|
+
!Number.isSafeInteger(tolerance) ||
|
|
2251
|
+
tolerance < 0
|
|
2252
|
+
) {
|
|
2253
|
+
return null;
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
let nearest = null;
|
|
2257
|
+
let nearestDistance = Number.POSITIVE_INFINITY;
|
|
2258
|
+
for (const handle of handles) {
|
|
2259
|
+
if (
|
|
2260
|
+
!Number.isSafeInteger(handle?.x) ||
|
|
2261
|
+
!Number.isSafeInteger(handle.yStart) ||
|
|
2262
|
+
!Number.isSafeInteger(handle.yEnd) ||
|
|
2263
|
+
point.y < handle.yStart ||
|
|
2264
|
+
point.y >= handle.yEnd
|
|
2265
|
+
) {
|
|
2266
|
+
continue;
|
|
2267
|
+
}
|
|
2268
|
+
const distance = Math.abs(point.x - handle.x);
|
|
2269
|
+
if (
|
|
2270
|
+
distance <= tolerance &&
|
|
2271
|
+
(distance < nearestDistance ||
|
|
2272
|
+
(distance === nearestDistance && (nearest === null || handle.x < nearest.x)))
|
|
2273
|
+
) {
|
|
2274
|
+
nearest = handle;
|
|
2275
|
+
nearestDistance = distance;
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
return nearest;
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
function beginDividerDrag({ event, handles, tabKey, tolerance = 1 }) {
|
|
2282
|
+
if (event?.action !== "press" || typeof tabKey !== "string" || tabKey.length === 0) {
|
|
2283
|
+
return null;
|
|
2284
|
+
}
|
|
2285
|
+
const handle = hitDivider(handles, event, tolerance);
|
|
2286
|
+
if (!handle) return null;
|
|
2287
|
+
return {
|
|
2288
|
+
tabKey,
|
|
2289
|
+
key: handle.key,
|
|
2290
|
+
startX: event.x,
|
|
2291
|
+
startWidth: handle.width,
|
|
2292
|
+
direction: handle.direction,
|
|
2293
|
+
};
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
function draggedWidth({
|
|
2297
|
+
drag,
|
|
2298
|
+
event,
|
|
2299
|
+
tabKey,
|
|
2300
|
+
fullHeaderVisible,
|
|
2301
|
+
layoutValid = true,
|
|
2302
|
+
}) {
|
|
2303
|
+
if (
|
|
2304
|
+
!drag ||
|
|
2305
|
+
event?.action !== "drag" ||
|
|
2306
|
+
drag.tabKey !== tabKey ||
|
|
2307
|
+
fullHeaderVisible !== true ||
|
|
2308
|
+
layoutValid !== true ||
|
|
2309
|
+
!Number.isSafeInteger(drag.startX) ||
|
|
2310
|
+
!Number.isSafeInteger(drag.startWidth) ||
|
|
2311
|
+
(drag.direction !== 1 && drag.direction !== -1) ||
|
|
2312
|
+
!Number.isSafeInteger(event.x)
|
|
2313
|
+
) {
|
|
2314
|
+
return null;
|
|
2315
|
+
}
|
|
2316
|
+
const nextWidth = drag.startWidth + drag.direction * (event.x - drag.startX);
|
|
2317
|
+
return Number.isSafeInteger(nextWidth) ? { key: drag.key, nextWidth } : null;
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
function sameElementMetrics(left, right) {
|
|
2321
|
+
return (
|
|
2322
|
+
left != null &&
|
|
2323
|
+
right != null &&
|
|
2324
|
+
left.x === right.x &&
|
|
2325
|
+
left.y === right.y &&
|
|
2326
|
+
left.width === right.width &&
|
|
2327
|
+
left.height === right.height
|
|
1860
2328
|
);
|
|
1861
2329
|
}
|
|
1862
2330
|
|
|
@@ -1979,23 +2447,23 @@ function runStatusIcon(run, spin) {
|
|
|
1979
2447
|
}
|
|
1980
2448
|
|
|
1981
2449
|
const ACTIONS_HEADER = [
|
|
1982
|
-
{ label: "", props: { width: 3 } },
|
|
1983
|
-
{ label: "TITLE", props: { grow: true } },
|
|
1984
|
-
{ label: "WORKFLOW", props: { width: 10 } },
|
|
1985
|
-
{ label: "BRANCH", props: { width: 14 } },
|
|
1986
|
-
{ label: "TIME", props: { width: 7 } },
|
|
1987
|
-
{ label: "UPDATED", props: { width: 8 } },
|
|
2450
|
+
{ key: "status", label: "", props: { width: 3 }, adjustable: false },
|
|
2451
|
+
{ key: "title", label: "TITLE", props: { grow: true }, adjustable: false },
|
|
2452
|
+
{ key: "workflow", label: "WORKFLOW", props: { width: 10 }, adjustable: true, minWidth: 5 },
|
|
2453
|
+
{ key: "branch", label: "BRANCH", props: { width: 14 }, adjustable: true, minWidth: 6 },
|
|
2454
|
+
{ key: "time", label: "TIME", props: { width: 7 }, adjustable: true, minWidth: 5 },
|
|
2455
|
+
{ key: "updated", label: "UPDATED", props: { width: 8 }, adjustable: true, minWidth: 6 },
|
|
1988
2456
|
];
|
|
1989
2457
|
|
|
1990
2458
|
// Dropped in order of least value first: BRANCH and WORKFLOW are inferable
|
|
1991
2459
|
// from the title far more often than the status icon or the age are.
|
|
1992
2460
|
const ACTIONS_HEADER_COMPACT = [
|
|
1993
|
-
{ label: "", props: { width: 3 } },
|
|
1994
|
-
{ label: "TITLE", props: { grow: true } },
|
|
1995
|
-
{ label: "UPDATED", props: { width: 8 } },
|
|
2461
|
+
{ key: "status", label: "", props: { width: 3 } },
|
|
2462
|
+
{ key: "title", label: "TITLE", props: { grow: true } },
|
|
2463
|
+
{ key: "updated", label: "UPDATED", props: { width: 8 } },
|
|
1996
2464
|
];
|
|
1997
2465
|
|
|
1998
|
-
function ActionsRow({ item, now, spin, compact, cursor }) {
|
|
2466
|
+
function ActionsRow({ item, now, spin, compact, cursor, columns }) {
|
|
1999
2467
|
const { icon, color, label } = runStatusIcon(item, spin);
|
|
2000
2468
|
const started = new Date(item.startedAt);
|
|
2001
2469
|
const finished = item.status === "completed" ? new Date(item.updatedAt) : now;
|
|
@@ -2003,52 +2471,52 @@ function ActionsRow({ item, now, spin, compact, cursor }) {
|
|
|
2003
2471
|
return e(
|
|
2004
2472
|
Box,
|
|
2005
2473
|
{ flexDirection: "row" },
|
|
2006
|
-
e(Column, {
|
|
2007
|
-
e(Column,
|
|
2008
|
-
e(Column, {
|
|
2474
|
+
e(Column, { ...columnProps(columns, "status"), color, label }, `${cursor ? ">" : " "}${icon}`),
|
|
2475
|
+
e(Column, columnProps(columns, "title"), item.displayTitle),
|
|
2476
|
+
e(Column, { ...columnProps(columns, "updated"), dim: true }, formatAge(new Date(item.updatedAt), now)),
|
|
2009
2477
|
);
|
|
2010
2478
|
}
|
|
2011
2479
|
return e(
|
|
2012
2480
|
Box,
|
|
2013
2481
|
{ flexDirection: "row" },
|
|
2014
|
-
e(Column, {
|
|
2015
|
-
e(Column,
|
|
2482
|
+
e(Column, { ...columnProps(columns, "status"), color, label }, `${cursor ? ">" : " "}${icon}`),
|
|
2483
|
+
e(Column, columnProps(columns, "title"), item.displayTitle),
|
|
2016
2484
|
// The run number is the actionable half and used to be the first thing
|
|
2017
2485
|
// truncation ate, since it sat at the tail of a 10-column cell.
|
|
2018
|
-
e(Column, {
|
|
2019
|
-
e(Column, {
|
|
2020
|
-
e(Column,
|
|
2021
|
-
e(Column, {
|
|
2486
|
+
e(Column, { ...columnProps(columns, "workflow"), color: IDENTIFIER }, `#${item.number} ${item.workflowName}`),
|
|
2487
|
+
e(Column, { ...columnProps(columns, "branch"), color: REF, wrap: "truncate-middle" }, item.headBranch),
|
|
2488
|
+
e(Column, columnProps(columns, "time"), formatDuration(finished - started)),
|
|
2489
|
+
e(Column, { ...columnProps(columns, "updated"), dim: true }, formatAge(new Date(item.updatedAt), now)),
|
|
2022
2490
|
);
|
|
2023
2491
|
}
|
|
2024
2492
|
|
|
2025
2493
|
// ---------- Issues tab ----------
|
|
2026
2494
|
|
|
2027
2495
|
const ISSUES_HEADER = [
|
|
2028
|
-
{ label: "", props: { width: 3 } },
|
|
2029
|
-
{ label: "TITLE", props: { grow: true } },
|
|
2030
|
-
{ label: "AUTHOR", props: { width: 12 } },
|
|
2031
|
-
{ label: "LABEL", props: { width: 14 } },
|
|
2032
|
-
{ label: "UPDATED", props: { width: 8 } },
|
|
2496
|
+
{ key: "status", label: "", props: { width: 3 }, adjustable: false },
|
|
2497
|
+
{ key: "title", label: "TITLE", props: { grow: true }, adjustable: false },
|
|
2498
|
+
{ key: "author", label: "AUTHOR", props: { width: 12 }, adjustable: true, minWidth: 6 },
|
|
2499
|
+
{ key: "label", label: "LABEL", props: { width: 14 }, adjustable: true, minWidth: 6 },
|
|
2500
|
+
{ key: "updated", label: "UPDATED", props: { width: 8 }, adjustable: true, minWidth: 6 },
|
|
2033
2501
|
];
|
|
2034
2502
|
|
|
2035
2503
|
const ISSUES_HEADER_COMPACT = [
|
|
2036
|
-
{ label: "", props: { width: 3 } },
|
|
2037
|
-
{ label: "TITLE", props: { grow: true } },
|
|
2038
|
-
{ label: "UPDATED", props: { width: 8 } },
|
|
2504
|
+
{ key: "status", label: "", props: { width: 3 } },
|
|
2505
|
+
{ key: "title", label: "TITLE", props: { grow: true } },
|
|
2506
|
+
{ key: "updated", label: "UPDATED", props: { width: 8 } },
|
|
2039
2507
|
];
|
|
2040
2508
|
|
|
2041
|
-
function IssueRow({ item, now, compact, cursor }) {
|
|
2509
|
+
function IssueRow({ item, now, compact, cursor, columns }) {
|
|
2042
2510
|
const cells = [
|
|
2043
|
-
e(Column, { key: "
|
|
2044
|
-
e(Column, { key: "
|
|
2511
|
+
e(Column, { key: "status", ...columnProps(columns, "status"), color: OK, label: "open issue" }, `${cursor ? ">" : " "}${OCT.issueOpened}`),
|
|
2512
|
+
e(Column, { key: "title", ...columnProps(columns, "title") }, `#${item.number} ${item.title}`),
|
|
2045
2513
|
];
|
|
2046
2514
|
if (!compact) {
|
|
2047
|
-
cells.push(e(Column, { key: "
|
|
2048
|
-
cells.push(e(Column, { key: "
|
|
2515
|
+
cells.push(e(Column, { key: "author", ...columnProps(columns, "author"), color: IDENTIFIER }, item.author));
|
|
2516
|
+
cells.push(e(Column, { key: "label", ...columnProps(columns, "label"), color: REF }, item.label));
|
|
2049
2517
|
}
|
|
2050
2518
|
cells.push(
|
|
2051
|
-
e(Column, { key: "
|
|
2519
|
+
e(Column, { key: "updated", ...columnProps(columns, "updated"), dim: true }, formatAge(new Date(item.updatedAt), now)),
|
|
2052
2520
|
);
|
|
2053
2521
|
return e(Box, { flexDirection: "row" }, ...cells);
|
|
2054
2522
|
}
|
|
@@ -2056,18 +2524,18 @@ function IssueRow({ item, now, compact, cursor }) {
|
|
|
2056
2524
|
// ---------- Pull requests tab ----------
|
|
2057
2525
|
|
|
2058
2526
|
const PRS_HEADER = [
|
|
2059
|
-
{ label: "", props: { width: 3 } },
|
|
2060
|
-
{ label: "TITLE", props: { grow: true } },
|
|
2061
|
-
{ label: "AUTHOR", props: { width: 12 } },
|
|
2062
|
-
{ label: "BRANCH", props: { width: 14 } },
|
|
2063
|
-
{ label: "REVIEW", props: { width: 10 } },
|
|
2064
|
-
{ label: "UPDATED", props: { width: 8 } },
|
|
2527
|
+
{ key: "status", label: "", props: { width: 3 }, adjustable: false },
|
|
2528
|
+
{ key: "title", label: "TITLE", props: { grow: true }, adjustable: false },
|
|
2529
|
+
{ key: "author", label: "AUTHOR", props: { width: 12 }, adjustable: true, minWidth: 6 },
|
|
2530
|
+
{ key: "branch", label: "BRANCH", props: { width: 14 }, adjustable: true, minWidth: 6 },
|
|
2531
|
+
{ key: "review", label: "REVIEW", props: { width: 10 }, adjustable: true, minWidth: 7 },
|
|
2532
|
+
{ key: "updated", label: "UPDATED", props: { width: 8 }, adjustable: true, minWidth: 6 },
|
|
2065
2533
|
];
|
|
2066
2534
|
|
|
2067
2535
|
const PRS_HEADER_COMPACT = [
|
|
2068
|
-
{ label: "", props: { width: 3 } },
|
|
2069
|
-
{ label: "TITLE", props: { grow: true } },
|
|
2070
|
-
{ label: "REVIEW", props: { width: 10 } },
|
|
2536
|
+
{ key: "status", label: "", props: { width: 3 } },
|
|
2537
|
+
{ key: "title", label: "TITLE", props: { grow: true } },
|
|
2538
|
+
{ key: "review", label: "REVIEW", props: { width: 10 } },
|
|
2071
2539
|
];
|
|
2072
2540
|
|
|
2073
2541
|
const REVIEW_LABEL = {
|
|
@@ -2077,25 +2545,25 @@ const REVIEW_LABEL = {
|
|
|
2077
2545
|
};
|
|
2078
2546
|
const REVIEW_NONE = { label: "", color: INERT };
|
|
2079
2547
|
|
|
2080
|
-
function PRRow({ item, now, compact, cursor }) {
|
|
2548
|
+
function PRRow({ item, now, compact, cursor, columns }) {
|
|
2081
2549
|
const prIcon = item.isDraft
|
|
2082
2550
|
? { icon: OCT.pullRequestDraft, color: INERT, label: "draft pull request" }
|
|
2083
2551
|
: { icon: OCT.pullRequest, color: OK, label: "open pull request" };
|
|
2084
2552
|
const review = pick(REVIEW_LABEL, item.reviewDecision, REVIEW_NONE);
|
|
2085
2553
|
const cells = [
|
|
2086
|
-
e(Column, { key: "
|
|
2087
|
-
e(Column, { key: "
|
|
2554
|
+
e(Column, { key: "status", ...columnProps(columns, "status"), color: prIcon.color, label: prIcon.label }, `${cursor ? ">" : " "}${prIcon.icon}`),
|
|
2555
|
+
e(Column, { key: "title", ...columnProps(columns, "title") }, `#${item.number} ${item.title}`),
|
|
2088
2556
|
];
|
|
2089
2557
|
if (!compact) {
|
|
2090
|
-
cells.push(e(Column, { key: "
|
|
2558
|
+
cells.push(e(Column, { key: "author", ...columnProps(columns, "author"), color: IDENTIFIER }, item.author));
|
|
2091
2559
|
cells.push(
|
|
2092
|
-
e(Column, { key: "
|
|
2560
|
+
e(Column, { key: "branch", ...columnProps(columns, "branch"), color: REF, wrap: "truncate-middle" }, item.headRefName),
|
|
2093
2561
|
);
|
|
2094
2562
|
}
|
|
2095
|
-
cells.push(e(Column, { key: "
|
|
2563
|
+
cells.push(e(Column, { key: "review", ...columnProps(columns, "review"), color: review.color }, review.label));
|
|
2096
2564
|
if (!compact) {
|
|
2097
2565
|
cells.push(
|
|
2098
|
-
e(Column, { key: "
|
|
2566
|
+
e(Column, { key: "updated", ...columnProps(columns, "updated"), dim: true }, formatAge(new Date(item.updatedAt), now)),
|
|
2099
2567
|
);
|
|
2100
2568
|
}
|
|
2101
2569
|
return e(Box, { flexDirection: "row" }, ...cells);
|
|
@@ -2109,19 +2577,19 @@ function PRRow({ item, now, compact, cursor }) {
|
|
|
2109
2577
|
// text column states it outright; the width comes out of PACKAGE / FILE, whose
|
|
2110
2578
|
// contents were already being truncated.
|
|
2111
2579
|
const SECURITY_HEADER = [
|
|
2112
|
-
{ label: "", props: { width: 3 } },
|
|
2113
|
-
{ label: "SEV", props: { width: 4 } },
|
|
2114
|
-
{ label: "PACKAGE / FILE", props: { width: 16 } },
|
|
2115
|
-
{ label: "SUMMARY", props: { grow: true } },
|
|
2116
|
-
{ label: "AGE", props: { width: 8 } },
|
|
2580
|
+
{ key: "status", label: "", props: { width: 3 }, adjustable: false },
|
|
2581
|
+
{ key: "severity", label: "SEV", props: { width: 4 }, adjustable: false },
|
|
2582
|
+
{ key: "package", label: "PACKAGE / FILE", props: { width: 16 }, adjustable: true, minWidth: 6 },
|
|
2583
|
+
{ key: "summary", label: "SUMMARY", props: { grow: true }, adjustable: false },
|
|
2584
|
+
{ key: "age", label: "AGE", props: { width: 8 }, adjustable: true, minWidth: 6 },
|
|
2117
2585
|
];
|
|
2118
2586
|
|
|
2119
2587
|
// SEV is the last thing to drop on this tab: it is the whole point of the pane
|
|
2120
2588
|
// and the only non-colour severity channel.
|
|
2121
2589
|
const SECURITY_HEADER_COMPACT = [
|
|
2122
|
-
{ label: "", props: { width: 3 } },
|
|
2123
|
-
{ label: "SEV", props: { width: 4 } },
|
|
2124
|
-
{ label: "SUMMARY", props: { grow: true } },
|
|
2590
|
+
{ key: "status", label: "", props: { width: 3 } },
|
|
2591
|
+
{ key: "severity", label: "SEV", props: { width: 4 } },
|
|
2592
|
+
{ key: "summary", label: "SUMMARY", props: { grow: true } },
|
|
2125
2593
|
];
|
|
2126
2594
|
|
|
2127
2595
|
const SEVERITY_STYLE = {
|
|
@@ -2134,19 +2602,19 @@ const SEVERITY_STYLE = {
|
|
|
2134
2602
|
};
|
|
2135
2603
|
const SEVERITY_UNKNOWN = SEVERITY_STYLE.unknown;
|
|
2136
2604
|
|
|
2137
|
-
function SecurityRow({ item, now, compact, cursor }) {
|
|
2605
|
+
function SecurityRow({ item, now, compact, cursor, columns }) {
|
|
2138
2606
|
const sev = pick(SEVERITY_STYLE, item.severity, SEVERITY_UNKNOWN);
|
|
2139
2607
|
const cells = [
|
|
2140
|
-
e(Column, { key: "
|
|
2141
|
-
e(Column, { key: "
|
|
2608
|
+
e(Column, { key: "status", ...columnProps(columns, "status"), color: sev.color, label: `${sev.short} severity` }, `${cursor ? ">" : " "}${OCT.shield}`),
|
|
2609
|
+
e(Column, { key: "severity", ...columnProps(columns, "severity"), color: sev.color }, sev.short),
|
|
2142
2610
|
];
|
|
2143
2611
|
if (!compact) {
|
|
2144
|
-
cells.push(e(Column, { key: "
|
|
2612
|
+
cells.push(e(Column, { key: "package", ...columnProps(columns, "package"), color: IDENTIFIER }, item.detail || item.kind));
|
|
2145
2613
|
}
|
|
2146
|
-
cells.push(e(Column, { key: "
|
|
2614
|
+
cells.push(e(Column, { key: "summary", ...columnProps(columns, "summary") }, item.title));
|
|
2147
2615
|
if (!compact) {
|
|
2148
2616
|
cells.push(
|
|
2149
|
-
e(Column, { key: "
|
|
2617
|
+
e(Column, { key: "age", ...columnProps(columns, "age"), dim: true }, formatAge(new Date(item.createdAt), now)),
|
|
2150
2618
|
);
|
|
2151
2619
|
}
|
|
2152
2620
|
return e(Box, { flexDirection: "row" }, ...cells);
|
|
@@ -2207,6 +2675,181 @@ const TABS = [
|
|
|
2207
2675
|
},
|
|
2208
2676
|
];
|
|
2209
2677
|
|
|
2678
|
+
function tabForKey(key) {
|
|
2679
|
+
return TABS.find((tab) => tab.key === key);
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
const EMPTY_WIDTH_OVERRIDES = Object.freeze({});
|
|
2683
|
+
|
|
2684
|
+
function columnProps(columns, key) {
|
|
2685
|
+
const column = columns.find((candidate) => candidate.key === key);
|
|
2686
|
+
if (!column) throw new Error(`Unknown column: ${key}`);
|
|
2687
|
+
return column.props;
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
function isAdjustableWidthColumn(column) {
|
|
2691
|
+
return (
|
|
2692
|
+
column?.adjustable &&
|
|
2693
|
+
Number.isSafeInteger(column.props.width) &&
|
|
2694
|
+
Number.isSafeInteger(column.minWidth)
|
|
2695
|
+
);
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
function adjustableWidthKeys(tab) {
|
|
2699
|
+
if (!tab || !Array.isArray(tab.header)) return [];
|
|
2700
|
+
return tab.header.filter(isAdjustableWidthColumn).map((column) => column.key);
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2703
|
+
function selectWidthKey(tab, rememberedKey = null) {
|
|
2704
|
+
const keys = adjustableWidthKeys(tab);
|
|
2705
|
+
return keys.includes(rememberedKey) ? rememberedKey : (keys[0] ?? null);
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
function cycleWidthKey(tab, selectedKey, direction) {
|
|
2709
|
+
if (!Number.isSafeInteger(direction) || direction === 0) return selectWidthKey(tab, selectedKey);
|
|
2710
|
+
const keys = adjustableWidthKeys(tab);
|
|
2711
|
+
if (keys.length === 0) return null;
|
|
2712
|
+
const current = keys.indexOf(selectedKey);
|
|
2713
|
+
if (current < 0) return direction > 0 ? keys[0] : keys[keys.length - 1];
|
|
2714
|
+
return keys[(current + Math.sign(direction) + keys.length) % keys.length];
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2717
|
+
function resolveHeader(base, overrides = EMPTY_WIDTH_OVERRIDES) {
|
|
2718
|
+
let changed = false;
|
|
2719
|
+
const resolved = base.map((column) => {
|
|
2720
|
+
if (
|
|
2721
|
+
!isAdjustableWidthColumn(column) ||
|
|
2722
|
+
overrides == null ||
|
|
2723
|
+
!Object.hasOwn(overrides, column.key) ||
|
|
2724
|
+
!Number.isSafeInteger(overrides[column.key])
|
|
2725
|
+
) {
|
|
2726
|
+
return column;
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2729
|
+
const width = Math.max(column.minWidth, overrides[column.key]);
|
|
2730
|
+
if (width === column.props.width) return column;
|
|
2731
|
+
changed = true;
|
|
2732
|
+
return { ...column, props: { ...column.props, width } };
|
|
2733
|
+
});
|
|
2734
|
+
return changed ? resolved : base;
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2737
|
+
function fitHeaderToFrame(preferred, defaults, frameCols) {
|
|
2738
|
+
const preferredFloor = minimumWidthFor(preferred);
|
|
2739
|
+
if (preferredFloor <= frameCols) return preferred;
|
|
2740
|
+
if (minimumWidthFor(defaults) > frameCols) return null;
|
|
2741
|
+
|
|
2742
|
+
let remaining = preferredFloor - frameCols;
|
|
2743
|
+
return preferred.map((column) => {
|
|
2744
|
+
if (remaining <= 0 || !Number.isSafeInteger(column.props.width)) return column;
|
|
2745
|
+
const defaultColumn = defaults.find((candidate) => candidate.key === column.key);
|
|
2746
|
+
const defaultWidth = defaultColumn?.props.width;
|
|
2747
|
+
if (!Number.isSafeInteger(defaultWidth) || column.props.width <= defaultWidth) return column;
|
|
2748
|
+
|
|
2749
|
+
const shrinkBy = Math.min(remaining, column.props.width - defaultWidth);
|
|
2750
|
+
remaining -= shrinkBy;
|
|
2751
|
+
return { ...column, props: { ...column.props, width: column.props.width - shrinkBy } };
|
|
2752
|
+
});
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
function effectiveHeaderFor(tab, tabOverrides, frameCols) {
|
|
2756
|
+
return fitHeaderToFrame(
|
|
2757
|
+
resolveHeader(tab.header, tabOverrides),
|
|
2758
|
+
tab.header,
|
|
2759
|
+
frameCols,
|
|
2760
|
+
);
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2763
|
+
function adjustWidth({ header, key, delta, frameCols }) {
|
|
2764
|
+
if (!Number.isSafeInteger(delta) || delta === 0 || !Number.isSafeInteger(frameCols)) return header;
|
|
2765
|
+
const index = header.findIndex((column) => column.key === key);
|
|
2766
|
+
if (index < 0) return header;
|
|
2767
|
+
|
|
2768
|
+
const column = header[index];
|
|
2769
|
+
if (!isAdjustableWidthColumn(column)) return header;
|
|
2770
|
+
|
|
2771
|
+
const available = Math.max(0, frameCols - minimumWidthFor(header));
|
|
2772
|
+
const maximum = column.props.width + available;
|
|
2773
|
+
const width = Math.min(maximum, Math.max(column.minWidth, column.props.width + delta));
|
|
2774
|
+
if (width === column.props.width) return header;
|
|
2775
|
+
|
|
2776
|
+
const adjusted = [...header];
|
|
2777
|
+
adjusted[index] = { ...column, props: { ...column.props, width } };
|
|
2778
|
+
return adjusted;
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2781
|
+
function removeWidthOverride(overrides, tabKey, key) {
|
|
2782
|
+
if (!isRecord(overrides)) return overrides;
|
|
2783
|
+
const tabOverrides = overrides[tabKey];
|
|
2784
|
+
if (!isRecord(tabOverrides) || !Object.hasOwn(tabOverrides, key)) return overrides;
|
|
2785
|
+
|
|
2786
|
+
const nextTab = { ...tabOverrides };
|
|
2787
|
+
delete nextTab[key];
|
|
2788
|
+
if (Object.keys(nextTab).length > 0) return { ...overrides, [tabKey]: nextTab };
|
|
2789
|
+
|
|
2790
|
+
const next = { ...overrides };
|
|
2791
|
+
delete next[tabKey];
|
|
2792
|
+
return next;
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2795
|
+
// One immutable preference reducer for keyboard deltas and the pointer phase's
|
|
2796
|
+
// absolute drag snapshots. The optional geometry arguments are the live fitted
|
|
2797
|
+
// header and frame budget; omitting them gives unit callers semantic-minimum
|
|
2798
|
+
// clamping without inventing a terminal width.
|
|
2799
|
+
function updateWidthPreference({
|
|
2800
|
+
overrides,
|
|
2801
|
+
tab,
|
|
2802
|
+
key,
|
|
2803
|
+
nextWidth,
|
|
2804
|
+
effectiveHeader,
|
|
2805
|
+
frameCols,
|
|
2806
|
+
}) {
|
|
2807
|
+
if (!isRecord(overrides) || !tab || !Array.isArray(tab.header) || !Number.isSafeInteger(nextWidth)) {
|
|
2808
|
+
return overrides;
|
|
2809
|
+
}
|
|
2810
|
+
const defaultColumn = tab.header.find((column) => column.key === key);
|
|
2811
|
+
if (!isAdjustableWidthColumn(defaultColumn)) return overrides;
|
|
2812
|
+
|
|
2813
|
+
const tabOverrides = isRecord(overrides[tab.key]) ? overrides[tab.key] : EMPTY_WIDTH_OVERRIDES;
|
|
2814
|
+
const currentHeader = Array.isArray(effectiveHeader)
|
|
2815
|
+
? effectiveHeader
|
|
2816
|
+
: resolveHeader(tab.header, tabOverrides);
|
|
2817
|
+
const currentColumn = currentHeader.find((column) => column.key === key);
|
|
2818
|
+
if (!isAdjustableWidthColumn(currentColumn)) return overrides;
|
|
2819
|
+
|
|
2820
|
+
const delta = nextWidth - currentColumn.props.width;
|
|
2821
|
+
const liveFrameCols = Number.isSafeInteger(frameCols)
|
|
2822
|
+
? frameCols
|
|
2823
|
+
: minimumWidthFor(currentHeader) + Math.max(0, delta);
|
|
2824
|
+
const adjusted = adjustWidth({ header: currentHeader, key, delta, frameCols: liveFrameCols });
|
|
2825
|
+
if (adjusted === currentHeader) return overrides;
|
|
2826
|
+
const width = adjusted.find((column) => column.key === key).props.width;
|
|
2827
|
+
const hasOverride = Object.hasOwn(tabOverrides, key);
|
|
2828
|
+
|
|
2829
|
+
if (width === defaultColumn.props.width) {
|
|
2830
|
+
return removeWidthOverride(overrides, tab.key, key);
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2833
|
+
if (hasOverride && tabOverrides[key] === width) return overrides;
|
|
2834
|
+
return { ...overrides, [tab.key]: { ...tabOverrides, [key]: width } };
|
|
2835
|
+
}
|
|
2836
|
+
|
|
2837
|
+
function resetWidthPreference(overrides, tabKey, key) {
|
|
2838
|
+
if (!isRecord(overrides)) return overrides;
|
|
2839
|
+
const tab = tabForKey(tabKey);
|
|
2840
|
+
const column = tab?.header.find((candidate) => candidate.key === key);
|
|
2841
|
+
if (!isAdjustableWidthColumn(column)) return overrides;
|
|
2842
|
+
return removeWidthOverride(overrides, tabKey, key);
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2845
|
+
function resetTabWidthPreferences(overrides, tabKey) {
|
|
2846
|
+
if (!isRecord(overrides) || !Object.hasOwn(overrides, tabKey)) return overrides;
|
|
2847
|
+
if (!tabForKey(tabKey)) return overrides;
|
|
2848
|
+
const next = { ...overrides };
|
|
2849
|
+
delete next[tabKey];
|
|
2850
|
+
return next;
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2210
2853
|
// The narrowest width each tab's table can render without its fixed columns
|
|
2211
2854
|
// overflowing the frame. Derived from the header descriptors rather than
|
|
2212
2855
|
// hard-coded, so adding or resizing a column cannot silently invalidate it.
|
|
@@ -2219,6 +2862,227 @@ function minimumWidthFor(header) {
|
|
|
2219
2862
|
// + 2 border verticals, + 2 paddingX, + at least 4 columns for TITLE.
|
|
2220
2863
|
return fixed + 8;
|
|
2221
2864
|
}
|
|
2865
|
+
|
|
2866
|
+
// ---------- Width preferences ----------
|
|
2867
|
+
|
|
2868
|
+
const WIDTH_PREFERENCES_VERSION = 1;
|
|
2869
|
+
let widthPreferenceTempSequence = 0;
|
|
2870
|
+
|
|
2871
|
+
function widthPreferencesPath({
|
|
2872
|
+
env = process.env,
|
|
2873
|
+
platform = process.platform,
|
|
2874
|
+
home = homedir(),
|
|
2875
|
+
} = {}) {
|
|
2876
|
+
const xdgRoot = env?.XDG_CONFIG_HOME;
|
|
2877
|
+
const root =
|
|
2878
|
+
typeof xdgRoot === "string" && xdgRoot.length > 0 && isAbsolute(xdgRoot)
|
|
2879
|
+
? xdgRoot
|
|
2880
|
+
: platform === "darwin"
|
|
2881
|
+
? join(home, "Library", "Application Support")
|
|
2882
|
+
: join(home, ".config");
|
|
2883
|
+
return join(root, "gh-glance", "preferences.json");
|
|
2884
|
+
}
|
|
2885
|
+
|
|
2886
|
+
function isRecord(value) {
|
|
2887
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2890
|
+
function normalizeWidthOverrides(overrides, tabs = TABS, { omitDefaults = false } = {}) {
|
|
2891
|
+
if (!isRecord(overrides)) return {};
|
|
2892
|
+
|
|
2893
|
+
const normalized = {};
|
|
2894
|
+
for (const tab of tabs) {
|
|
2895
|
+
if (!Object.hasOwn(overrides, tab.key) || !isRecord(overrides[tab.key])) continue;
|
|
2896
|
+
|
|
2897
|
+
const source = overrides[tab.key];
|
|
2898
|
+
const tabOverrides = {};
|
|
2899
|
+
for (const column of tab.header) {
|
|
2900
|
+
if (!isAdjustableWidthColumn(column) || !Object.hasOwn(source, column.key)) continue;
|
|
2901
|
+
const width = source[column.key];
|
|
2902
|
+
if (!Number.isSafeInteger(width) || width < column.minWidth) continue;
|
|
2903
|
+
if (omitDefaults && width === column.props.width) continue;
|
|
2904
|
+
tabOverrides[column.key] = width;
|
|
2905
|
+
}
|
|
2906
|
+
if (Object.keys(tabOverrides).length > 0) normalized[tab.key] = tabOverrides;
|
|
2907
|
+
}
|
|
2908
|
+
return normalized;
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2911
|
+
function decodeWidthPreferences(raw, tabs = TABS) {
|
|
2912
|
+
let document;
|
|
2913
|
+
try {
|
|
2914
|
+
document = JSON.parse(raw);
|
|
2915
|
+
} catch (error) {
|
|
2916
|
+
return { preferences: {}, error };
|
|
2917
|
+
}
|
|
2918
|
+
|
|
2919
|
+
if (
|
|
2920
|
+
!isRecord(document) ||
|
|
2921
|
+
!Object.hasOwn(document, "version") ||
|
|
2922
|
+
document.version !== WIDTH_PREFERENCES_VERSION ||
|
|
2923
|
+
!Object.hasOwn(document, "tabs") ||
|
|
2924
|
+
!isRecord(document.tabs)
|
|
2925
|
+
) {
|
|
2926
|
+
return { preferences: {}, error: new Error("Unsupported width preferences document") };
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2929
|
+
return { preferences: normalizeWidthOverrides(document.tabs, tabs), error: null };
|
|
2930
|
+
}
|
|
2931
|
+
|
|
2932
|
+
function parseWidthPreferences(raw, tabs = TABS) {
|
|
2933
|
+
return decodeWidthPreferences(raw, tabs).preferences;
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
function serializeWidthPreferences(overrides, tabs = TABS) {
|
|
2937
|
+
const document = {
|
|
2938
|
+
version: WIDTH_PREFERENCES_VERSION,
|
|
2939
|
+
tabs: normalizeWidthOverrides(overrides, tabs, { omitDefaults: true }),
|
|
2940
|
+
};
|
|
2941
|
+
return `${JSON.stringify(document, null, 2)}\n`;
|
|
2942
|
+
}
|
|
2943
|
+
|
|
2944
|
+
function loadWidthPreferences(path, tabs = TABS) {
|
|
2945
|
+
let raw;
|
|
2946
|
+
try {
|
|
2947
|
+
raw = readFileSync(path, "utf8");
|
|
2948
|
+
} catch (error) {
|
|
2949
|
+
return error?.code === "ENOENT"
|
|
2950
|
+
? { preferences: {}, error: null }
|
|
2951
|
+
: { preferences: {}, error };
|
|
2952
|
+
}
|
|
2953
|
+
return decodeWidthPreferences(raw, tabs);
|
|
2954
|
+
}
|
|
2955
|
+
|
|
2956
|
+
function saveWidthPreferences(path, overrides, tabs = TABS) {
|
|
2957
|
+
let tempPath = null;
|
|
2958
|
+
try {
|
|
2959
|
+
const payload = serializeWidthPreferences(overrides, tabs);
|
|
2960
|
+
const parent = dirname(path);
|
|
2961
|
+
mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
2962
|
+
widthPreferenceTempSequence += 1;
|
|
2963
|
+
tempPath = `${path}.${process.pid}.${Date.now()}.${widthPreferenceTempSequence}.tmp`;
|
|
2964
|
+
writeFileSync(tempPath, payload, {
|
|
2965
|
+
encoding: "utf8",
|
|
2966
|
+
mode: 0o600,
|
|
2967
|
+
flag: "wx",
|
|
2968
|
+
});
|
|
2969
|
+
renameSync(tempPath, path);
|
|
2970
|
+
return { ok: true };
|
|
2971
|
+
} catch (error) {
|
|
2972
|
+
if (tempPath !== null) {
|
|
2973
|
+
try {
|
|
2974
|
+
unlinkSync(tempPath);
|
|
2975
|
+
} catch {
|
|
2976
|
+
// The write may have failed before creating it. Cleanup is best effort
|
|
2977
|
+
// and deliberately targets only this operation's exact temporary file.
|
|
2978
|
+
}
|
|
2979
|
+
}
|
|
2980
|
+
return { ok: false, error };
|
|
2981
|
+
}
|
|
2982
|
+
}
|
|
2983
|
+
|
|
2984
|
+
function createWidthPreferenceWriter({ write, delay = 200, onResult = () => {} }) {
|
|
2985
|
+
let latest = EMPTY_WIDTH_OVERRIDES;
|
|
2986
|
+
let generation = 0;
|
|
2987
|
+
let persistedGeneration = 0;
|
|
2988
|
+
let timer = null;
|
|
2989
|
+
let flushPromise = null;
|
|
2990
|
+
|
|
2991
|
+
function report(result) {
|
|
2992
|
+
try {
|
|
2993
|
+
onResult(result);
|
|
2994
|
+
} catch {
|
|
2995
|
+
// Persistence reporting is advisory and must not reach Ink's render path.
|
|
2996
|
+
}
|
|
2997
|
+
return result;
|
|
2998
|
+
}
|
|
2999
|
+
|
|
3000
|
+
function failed(error) {
|
|
3001
|
+
return { ok: false, error };
|
|
3002
|
+
}
|
|
3003
|
+
|
|
3004
|
+
async function writeGeneration(value) {
|
|
3005
|
+
let pending;
|
|
3006
|
+
try {
|
|
3007
|
+
// Keep this call before the first await. The production writer is
|
|
3008
|
+
// synchronous, so an unmount flush completes the filesystem replacement
|
|
3009
|
+
// before React hands terminal teardown back to its caller.
|
|
3010
|
+
pending = write(value);
|
|
3011
|
+
} catch (error) {
|
|
3012
|
+
return failed(error);
|
|
3013
|
+
}
|
|
3014
|
+
|
|
3015
|
+
try {
|
|
3016
|
+
const result = await pending;
|
|
3017
|
+
return result?.ok === false ? result : (result ?? { ok: true });
|
|
3018
|
+
} catch (error) {
|
|
3019
|
+
return failed(error);
|
|
3020
|
+
}
|
|
3021
|
+
}
|
|
3022
|
+
|
|
3023
|
+
async function drain() {
|
|
3024
|
+
let result = { ok: true, written: false };
|
|
3025
|
+
while (persistedGeneration < generation) {
|
|
3026
|
+
const attemptedGeneration = generation;
|
|
3027
|
+
const value = latest;
|
|
3028
|
+
result = await writeGeneration(value);
|
|
3029
|
+
|
|
3030
|
+
const isLatest = attemptedGeneration === generation;
|
|
3031
|
+
if (result.ok !== false) {
|
|
3032
|
+
persistedGeneration = Math.max(persistedGeneration, attemptedGeneration);
|
|
3033
|
+
}
|
|
3034
|
+
|
|
3035
|
+
// The loop serializes a superseding write behind this completion, so its
|
|
3036
|
+
// result owns the warning state. A stale completion is deliberately not
|
|
3037
|
+
// reported, while async replacements can neither race on disk nor report
|
|
3038
|
+
// out of order.
|
|
3039
|
+
if (!isLatest) continue;
|
|
3040
|
+
report(result);
|
|
3041
|
+
// Leave the latest generation dirty after failure. A later flush/dispose
|
|
3042
|
+
// retries it; concurrent callers still share this completed attempt.
|
|
3043
|
+
if (result.ok === false) return result;
|
|
3044
|
+
}
|
|
3045
|
+
return result;
|
|
3046
|
+
}
|
|
3047
|
+
|
|
3048
|
+
function flush() {
|
|
3049
|
+
if (timer !== null) {
|
|
3050
|
+
clearTimeout(timer);
|
|
3051
|
+
timer = null;
|
|
3052
|
+
}
|
|
3053
|
+
if (flushPromise !== null) return flushPromise;
|
|
3054
|
+
if (persistedGeneration >= generation) {
|
|
3055
|
+
return Promise.resolve({ ok: true, written: false });
|
|
3056
|
+
}
|
|
3057
|
+
|
|
3058
|
+
const pending = drain().finally(() => {
|
|
3059
|
+
if (flushPromise === pending) flushPromise = null;
|
|
3060
|
+
});
|
|
3061
|
+
flushPromise = pending;
|
|
3062
|
+
return pending;
|
|
3063
|
+
}
|
|
3064
|
+
|
|
3065
|
+
function schedule(value) {
|
|
3066
|
+
latest = value;
|
|
3067
|
+
generation += 1;
|
|
3068
|
+
if (timer === null) {
|
|
3069
|
+
timer = setTimeout(() => {
|
|
3070
|
+
timer = null;
|
|
3071
|
+
void flush();
|
|
3072
|
+
}, delay);
|
|
3073
|
+
timer.unref?.();
|
|
3074
|
+
} else {
|
|
3075
|
+
timer.refresh();
|
|
3076
|
+
}
|
|
3077
|
+
}
|
|
3078
|
+
|
|
3079
|
+
return {
|
|
3080
|
+
schedule,
|
|
3081
|
+
flush,
|
|
3082
|
+
dispose: flush,
|
|
3083
|
+
};
|
|
3084
|
+
}
|
|
3085
|
+
|
|
2222
3086
|
// Below this the full column set cannot render without its fixed columns
|
|
2223
3087
|
// overflowing the frame -- rows hard-wrap and ink switches to clearing and
|
|
2224
3088
|
// repainting the whole screen every frame. Worse, in the band just above the
|
|
@@ -2307,6 +3171,7 @@ const KEY_HINTS = [
|
|
|
2307
3171
|
{ label: "Move", keys: "↑↓" },
|
|
2308
3172
|
{ label: "Open", keys: "Ent" },
|
|
2309
3173
|
{ label: "Refresh", keys: "r" },
|
|
3174
|
+
{ label: "Width", keys: "w" },
|
|
2310
3175
|
{ label: "Quit", keys: "q" },
|
|
2311
3176
|
];
|
|
2312
3177
|
|
|
@@ -2333,6 +3198,26 @@ const REMOTE_SETUP_NONINTERACTIVE_LINES = [
|
|
|
2333
3198
|
// finishes. Wide enough for the spinner, a space and "Fetching".
|
|
2334
3199
|
const FETCHING_WIDTH = 12;
|
|
2335
3200
|
|
|
3201
|
+
function widthStatusText({ label, width, cols, saveError = false }) {
|
|
3202
|
+
const budget = Number.isSafeInteger(cols) ? Math.max(0, cols) : 0;
|
|
3203
|
+
const safeLabel = String(label ?? "").replace(/[^\x20-\x7e]/g, "?");
|
|
3204
|
+
const safeWidth = Number.isSafeInteger(width) ? String(width) : "?";
|
|
3205
|
+
const variants = saveError
|
|
3206
|
+
? [
|
|
3207
|
+
`Width: ${safeLabel} ${safeWidth} Widths not saved`,
|
|
3208
|
+
`${safeLabel} ${safeWidth} Widths not saved`,
|
|
3209
|
+
"Widths not saved",
|
|
3210
|
+
]
|
|
3211
|
+
: [
|
|
3212
|
+
`Width: ${safeLabel} ${safeWidth} Tab select <- -> resize r reset Esc done`,
|
|
3213
|
+
`Width: ${safeLabel} ${safeWidth} <- -> resize r reset Esc done`,
|
|
3214
|
+
`${safeLabel} ${safeWidth} <- -> r reset Esc done`,
|
|
3215
|
+
`${safeLabel} ${safeWidth} <- -> r Esc`,
|
|
3216
|
+
];
|
|
3217
|
+
const fitting = variants.find((variant) => variant.length <= budget);
|
|
3218
|
+
return fitting ?? variants[variants.length - 1].slice(0, budget);
|
|
3219
|
+
}
|
|
3220
|
+
|
|
2336
3221
|
// Compact drops the labels and keeps the keys, separated by a space:
|
|
2337
3222
|
// "↑↓ Ent r q". No constant for its width -- nothing branches on it, and the
|
|
2338
3223
|
// keys are short enough that it fits anywhere the frame itself does.
|
|
@@ -2349,7 +3234,37 @@ const FETCHING_WIDTH = 12;
|
|
|
2349
3234
|
// finding at a glance, so they get the accent colour and the words describing
|
|
2350
3235
|
// them stay dim. The accent is the panel-title cyan rather than the amber used
|
|
2351
3236
|
// for in-progress status, so amber means exactly one thing across the product.
|
|
2352
|
-
function StatusBar({
|
|
3237
|
+
function StatusBar({
|
|
3238
|
+
fetching,
|
|
3239
|
+
spin,
|
|
3240
|
+
stale,
|
|
3241
|
+
throttle,
|
|
3242
|
+
interactive,
|
|
3243
|
+
cols,
|
|
3244
|
+
remoteSetup = false,
|
|
3245
|
+
widthMode = false,
|
|
3246
|
+
widthColumn = null,
|
|
3247
|
+
widthSaveError = null,
|
|
3248
|
+
}) {
|
|
3249
|
+
// Width mode owns the whole bar, so the throttle badge is deliberately not
|
|
3250
|
+
// shown here: this state is transient and explicitly entered, and the widened
|
|
3251
|
+
// interval is still reported the moment the user leaves it.
|
|
3252
|
+
if (widthMode && widthColumn) {
|
|
3253
|
+
return e(
|
|
3254
|
+
Box,
|
|
3255
|
+
{ flexDirection: "row" },
|
|
3256
|
+
e(
|
|
3257
|
+
Text,
|
|
3258
|
+
{ color: widthSaveError ? ATTENTION : undefined, wrap: "truncate-end" },
|
|
3259
|
+
widthStatusText({
|
|
3260
|
+
label: widthColumn.label,
|
|
3261
|
+
width: widthColumn.props.width,
|
|
3262
|
+
cols,
|
|
3263
|
+
saveError: Boolean(widthSaveError),
|
|
3264
|
+
}),
|
|
3265
|
+
),
|
|
3266
|
+
);
|
|
3267
|
+
}
|
|
2353
3268
|
// Without raw mode none of the key handlers run, so advertising them would be
|
|
2354
3269
|
// telling the user something untrue about what the app can do. Ctrl+C still
|
|
2355
3270
|
// works there, because the tty delivers a real SIGINT.
|
|
@@ -2386,6 +3301,12 @@ function StatusBar({ fetching, spin, stale, interactive, cols, remoteSetup = fal
|
|
|
2386
3301
|
stale
|
|
2387
3302
|
? e(Box, { marginRight: 1, flexShrink: 0 }, e(Text, { color: ATTENTION }, stale))
|
|
2388
3303
|
: null,
|
|
3304
|
+
// Same free-slot treatment as `stale` above. Dim rather than ATTENTION: a
|
|
3305
|
+
// widened interval is the throttle working, not a failure, and colouring it
|
|
3306
|
+
// like a problem would send the user looking for one.
|
|
3307
|
+
throttle
|
|
3308
|
+
? e(Box, { marginRight: 1, flexShrink: 0 }, e(Text, { dimColor: true }, throttle))
|
|
3309
|
+
: null,
|
|
2389
3310
|
...hints
|
|
2390
3311
|
.flatMap((hint, i) => [
|
|
2391
3312
|
i > 0 &&
|
|
@@ -2398,6 +3319,12 @@ function StatusBar({ fetching, spin, stale, interactive, cols, remoteSetup = fal
|
|
|
2398
3319
|
),
|
|
2399
3320
|
])
|
|
2400
3321
|
.filter(Boolean),
|
|
3322
|
+
// Right-aligned, like lazygit's footer. Dropped in compact mode rather than
|
|
3323
|
+
// left to shrink alongside the hints: it would compete for the same shrink
|
|
3324
|
+
// budget as "Quit", the one hint the comment above already fought to keep
|
|
3325
|
+
// on screen down to 45 columns, and version digits are not worth that.
|
|
3326
|
+
!compact && e(Box, { flexGrow: 1 }),
|
|
3327
|
+
!compact && e(Text, { key: "version", dimColor: true, wrap: "truncate-end" }, version),
|
|
2401
3328
|
);
|
|
2402
3329
|
}
|
|
2403
3330
|
|
|
@@ -2449,8 +3376,39 @@ function useTerminalSize(stdout) {
|
|
|
2449
3376
|
function App({ onCreateRemote = () => {} } = {}) {
|
|
2450
3377
|
const { stdout } = useStdout();
|
|
2451
3378
|
const { isRawModeSupported } = useStdin();
|
|
2452
|
-
const { exit } = useApp();
|
|
3379
|
+
const { exit, suspendTerminal } = useApp();
|
|
2453
3380
|
const [activeIndex, setActiveIndex] = useState(runtime.initialTabIndex);
|
|
3381
|
+
const [preferencePath] = useState(() => widthPreferencesPath());
|
|
3382
|
+
const [widthOverrides, setWidthOverrides] = useState(
|
|
3383
|
+
() => loadWidthPreferences(preferencePath).preferences,
|
|
3384
|
+
);
|
|
3385
|
+
const widthOverridesRef = useRef(widthOverrides);
|
|
3386
|
+
widthOverridesRef.current = widthOverrides;
|
|
3387
|
+
const [widthSaveError, setWidthSaveError] = useState(null);
|
|
3388
|
+
const widthPreferencesMountedRef = useRef(true);
|
|
3389
|
+
const [widthPreferenceWriter] = useState(() =>
|
|
3390
|
+
createWidthPreferenceWriter({
|
|
3391
|
+
write: (overrides) => saveWidthPreferences(preferencePath, overrides),
|
|
3392
|
+
onResult: (result) => {
|
|
3393
|
+
if (!widthPreferencesMountedRef.current) return;
|
|
3394
|
+
setWidthSaveError(
|
|
3395
|
+
result?.ok === false
|
|
3396
|
+
? (result.error ?? new Error("Width preferences could not be saved"))
|
|
3397
|
+
: null,
|
|
3398
|
+
);
|
|
3399
|
+
},
|
|
3400
|
+
}),
|
|
3401
|
+
);
|
|
3402
|
+
useEffect(() => {
|
|
3403
|
+
widthPreferencesMountedRef.current = true;
|
|
3404
|
+
return () => {
|
|
3405
|
+
// saveWidthPreferences() itself runs synchronously inside dispose(), but
|
|
3406
|
+
// writer result reporting settles through a promise. Mark unmounted first
|
|
3407
|
+
// so that later callback cannot enqueue state on a departing App.
|
|
3408
|
+
widthPreferencesMountedRef.current = false;
|
|
3409
|
+
void widthPreferenceWriter.dispose();
|
|
3410
|
+
};
|
|
3411
|
+
}, [widthPreferenceWriter]);
|
|
2454
3412
|
// `null` means "never resolved" -- distinct from `[]`, which means "resolved
|
|
2455
3413
|
// and genuinely empty". The tab bar and the body render those differently.
|
|
2456
3414
|
const [data, setData] = useState({ actions: null, issues: null, prs: null, security: null });
|
|
@@ -2474,6 +3432,9 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
2474
3432
|
const [errors, setErrors] = useState({ actions: null, issues: null, prs: null, security: null });
|
|
2475
3433
|
const [failureContext, setFailureContext] = useState(null);
|
|
2476
3434
|
const [loading, setLoading] = useState({ actions: true, issues: true, prs: true, security: true });
|
|
3435
|
+
// The widened poll interval in ms while the adaptive throttle is holding one,
|
|
3436
|
+
// or null at the configured refresh. Only ever set from inside the poll effect.
|
|
3437
|
+
const [throttleMs, setThrottleMs] = useState(null);
|
|
2477
3438
|
const [now, setNow] = useState(new Date());
|
|
2478
3439
|
const { rows, cols, useShortLabels } = useTerminalSize(stdout);
|
|
2479
3440
|
const [frame, setFrame] = useState(0);
|
|
@@ -2482,6 +3443,8 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
2482
3443
|
// renders byte-identical frames and ink still writes nothing.
|
|
2483
3444
|
const [selected, setSelected] = useState({});
|
|
2484
3445
|
const [offset, setOffset] = useState({});
|
|
3446
|
+
const [widthMode, setWidthMode] = useState(false);
|
|
3447
|
+
const [selectedWidthKeyByTab, setSelectedWidthKeyByTab] = useState({});
|
|
2485
3448
|
|
|
2486
3449
|
const tab = TABS[activeIndex];
|
|
2487
3450
|
const tabError = errors[tab.key];
|
|
@@ -2577,6 +3540,185 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
2577
3540
|
showHelpRef.current = showHelp;
|
|
2578
3541
|
const remoteSetupRef = useRef(false);
|
|
2579
3542
|
remoteSetupRef.current = remoteSetup;
|
|
3543
|
+
const headerRef = useRef(null);
|
|
3544
|
+
const dragRef = useRef(null);
|
|
3545
|
+
const resizeRef = useRef({
|
|
3546
|
+
active: false,
|
|
3547
|
+
tabKey: "actions",
|
|
3548
|
+
selectedKey: null,
|
|
3549
|
+
effectiveHeader: null,
|
|
3550
|
+
frameCols: 0,
|
|
3551
|
+
compact: true,
|
|
3552
|
+
fullHeaderVisible: false,
|
|
3553
|
+
});
|
|
3554
|
+
|
|
3555
|
+
useEffect(() => {
|
|
3556
|
+
if (!interactive) return;
|
|
3557
|
+
enableMouseReporting();
|
|
3558
|
+
return () => {
|
|
3559
|
+
dragRef.current = null;
|
|
3560
|
+
disableMouseReporting();
|
|
3561
|
+
};
|
|
3562
|
+
}, [interactive]);
|
|
3563
|
+
|
|
3564
|
+
function applyWidthOverrides(next) {
|
|
3565
|
+
const current = widthOverridesRef.current;
|
|
3566
|
+
if (next === current) return false;
|
|
3567
|
+
widthOverridesRef.current = next;
|
|
3568
|
+
setWidthOverrides(next);
|
|
3569
|
+
// Schedule in the input turn, not a post-render effect. Reset, mode exit and
|
|
3570
|
+
// quit all flush immediately, and must not race ahead of React committing the
|
|
3571
|
+
// state that contains the latest width.
|
|
3572
|
+
widthPreferenceWriter.schedule(next);
|
|
3573
|
+
|
|
3574
|
+
const geometry = resizeRef.current;
|
|
3575
|
+
const activeTab = tabForKey(geometry.tabKey);
|
|
3576
|
+
if (activeTab) {
|
|
3577
|
+
const nextTabOverrides = pick(next, activeTab.key, EMPTY_WIDTH_OVERRIDES) ??
|
|
3578
|
+
EMPTY_WIDTH_OVERRIDES;
|
|
3579
|
+
const effective = effectiveHeaderFor(activeTab, nextTabOverrides, geometry.frameCols);
|
|
3580
|
+
resizeRef.current = {
|
|
3581
|
+
...geometry,
|
|
3582
|
+
effectiveHeader: effective,
|
|
3583
|
+
compact: effective == null,
|
|
3584
|
+
fullHeaderVisible: geometry.fullHeaderVisible && effective != null,
|
|
3585
|
+
};
|
|
3586
|
+
}
|
|
3587
|
+
return true;
|
|
3588
|
+
}
|
|
3589
|
+
|
|
3590
|
+
function flushWidthPreferences() {
|
|
3591
|
+
void widthPreferenceWriter.flush();
|
|
3592
|
+
}
|
|
3593
|
+
|
|
3594
|
+
function leaveWidthMode() {
|
|
3595
|
+
dragRef.current = null;
|
|
3596
|
+
resizeRef.current = { ...resizeRef.current, active: false };
|
|
3597
|
+
setWidthMode(false);
|
|
3598
|
+
flushWidthPreferences();
|
|
3599
|
+
}
|
|
3600
|
+
|
|
3601
|
+
function rememberWidthSelection(key) {
|
|
3602
|
+
if (key === null) return;
|
|
3603
|
+
const tabKey = resizeRef.current.tabKey;
|
|
3604
|
+
resizeRef.current = { ...resizeRef.current, selectedKey: key };
|
|
3605
|
+
setSelectedWidthKeyByTab((current) =>
|
|
3606
|
+
current[tabKey] === key ? current : { ...current, [tabKey]: key },
|
|
3607
|
+
);
|
|
3608
|
+
}
|
|
3609
|
+
|
|
3610
|
+
function enterWidthMode(requestedKey = null) {
|
|
3611
|
+
const geometry = resizeRef.current;
|
|
3612
|
+
if (!geometry.fullHeaderVisible || geometry.compact) return false;
|
|
3613
|
+
const activeTab = tabForKey(geometry.tabKey);
|
|
3614
|
+
const selectedKey = selectWidthKey(activeTab, requestedKey ?? geometry.selectedKey);
|
|
3615
|
+
if (selectedKey === null) return false;
|
|
3616
|
+
rememberWidthSelection(selectedKey);
|
|
3617
|
+
resizeRef.current = { ...resizeRef.current, active: true, selectedKey };
|
|
3618
|
+
setWidthMode(true);
|
|
3619
|
+
return true;
|
|
3620
|
+
}
|
|
3621
|
+
|
|
3622
|
+
function resizeSelectedWidth(delta) {
|
|
3623
|
+
const geometry = resizeRef.current;
|
|
3624
|
+
if (!geometry.active || !geometry.fullHeaderVisible || geometry.compact) return;
|
|
3625
|
+
const activeTab = tabForKey(geometry.tabKey);
|
|
3626
|
+
const column = geometry.effectiveHeader?.find(
|
|
3627
|
+
(candidate) => candidate.key === geometry.selectedKey,
|
|
3628
|
+
);
|
|
3629
|
+
if (!activeTab || !isAdjustableWidthColumn(column)) return;
|
|
3630
|
+
const next = updateWidthPreference({
|
|
3631
|
+
overrides: widthOverridesRef.current,
|
|
3632
|
+
tab: activeTab,
|
|
3633
|
+
key: geometry.selectedKey,
|
|
3634
|
+
nextWidth: column.props.width + delta,
|
|
3635
|
+
effectiveHeader: geometry.effectiveHeader,
|
|
3636
|
+
frameCols: geometry.frameCols,
|
|
3637
|
+
});
|
|
3638
|
+
applyWidthOverrides(next);
|
|
3639
|
+
}
|
|
3640
|
+
|
|
3641
|
+
function resetSelectedWidth() {
|
|
3642
|
+
dragRef.current = null;
|
|
3643
|
+
const { tabKey, selectedKey } = resizeRef.current;
|
|
3644
|
+
applyWidthOverrides(resetWidthPreference(widthOverridesRef.current, tabKey, selectedKey));
|
|
3645
|
+
flushWidthPreferences();
|
|
3646
|
+
}
|
|
3647
|
+
|
|
3648
|
+
function resetActiveTabWidths() {
|
|
3649
|
+
dragRef.current = null;
|
|
3650
|
+
const { tabKey } = resizeRef.current;
|
|
3651
|
+
applyWidthOverrides(resetTabWidthPreferences(widthOverridesRef.current, tabKey));
|
|
3652
|
+
flushWidthPreferences();
|
|
3653
|
+
}
|
|
3654
|
+
|
|
3655
|
+
const cancelWidthDrag = useCallback(() => {
|
|
3656
|
+
if (dragRef.current === null) return false;
|
|
3657
|
+
dragRef.current = null;
|
|
3658
|
+
void widthPreferenceWriter.flush();
|
|
3659
|
+
return true;
|
|
3660
|
+
}, [widthPreferenceWriter]);
|
|
3661
|
+
|
|
3662
|
+
const measuredHeader = useCallback(() => {
|
|
3663
|
+
return headerRef.current ? measureElement(headerRef.current) : null;
|
|
3664
|
+
}, []);
|
|
3665
|
+
|
|
3666
|
+
function handleSgrMouse(event) {
|
|
3667
|
+
if (event.action === "release") {
|
|
3668
|
+
dragRef.current = null;
|
|
3669
|
+
flushWidthPreferences();
|
|
3670
|
+
return;
|
|
3671
|
+
}
|
|
3672
|
+
|
|
3673
|
+
const geometry = resizeRef.current;
|
|
3674
|
+
if (event.action === "press") {
|
|
3675
|
+
if (!interactive || !geometry.fullHeaderVisible || geometry.compact) return;
|
|
3676
|
+
const metrics = measuredHeader();
|
|
3677
|
+
if (!metrics) return;
|
|
3678
|
+
const drag = beginDividerDrag({
|
|
3679
|
+
event,
|
|
3680
|
+
handles: dividerHandles({ header: geometry.effectiveHeader, metrics }),
|
|
3681
|
+
tabKey: geometry.tabKey,
|
|
3682
|
+
});
|
|
3683
|
+
if (!drag || !enterWidthMode(drag.key)) return;
|
|
3684
|
+
dragRef.current = { ...drag, metrics };
|
|
3685
|
+
return;
|
|
3686
|
+
}
|
|
3687
|
+
|
|
3688
|
+
const drag = dragRef.current;
|
|
3689
|
+
if (!drag) return;
|
|
3690
|
+
const metrics = measuredHeader();
|
|
3691
|
+
const layoutValid = sameElementMetrics(drag.metrics, metrics);
|
|
3692
|
+
const proposal = draggedWidth({
|
|
3693
|
+
drag,
|
|
3694
|
+
event,
|
|
3695
|
+
tabKey: geometry.tabKey,
|
|
3696
|
+
fullHeaderVisible: geometry.fullHeaderVisible,
|
|
3697
|
+
layoutValid,
|
|
3698
|
+
});
|
|
3699
|
+
if (!proposal) {
|
|
3700
|
+
if (!layoutValid || drag.tabKey !== geometry.tabKey || !geometry.fullHeaderVisible) {
|
|
3701
|
+
cancelWidthDrag();
|
|
3702
|
+
}
|
|
3703
|
+
return;
|
|
3704
|
+
}
|
|
3705
|
+
|
|
3706
|
+
const activeTab = tabForKey(geometry.tabKey);
|
|
3707
|
+
if (!activeTab) {
|
|
3708
|
+
cancelWidthDrag();
|
|
3709
|
+
return;
|
|
3710
|
+
}
|
|
3711
|
+
applyWidthOverrides(
|
|
3712
|
+
updateWidthPreference({
|
|
3713
|
+
overrides: widthOverridesRef.current,
|
|
3714
|
+
tab: activeTab,
|
|
3715
|
+
key: proposal.key,
|
|
3716
|
+
nextWidth: proposal.nextWidth,
|
|
3717
|
+
effectiveHeader: geometry.effectiveHeader,
|
|
3718
|
+
frameCols: geometry.frameCols,
|
|
3719
|
+
}),
|
|
3720
|
+
);
|
|
3721
|
+
}
|
|
2580
3722
|
|
|
2581
3723
|
function moveSelection(delta) {
|
|
2582
3724
|
const { items, key: currentKey, bodyRows: rows_, tabKey, offset: offsetRaw } = navRef.current;
|
|
@@ -2652,7 +3794,38 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
2652
3794
|
|
|
2653
3795
|
useInput(
|
|
2654
3796
|
(input, key) => {
|
|
2655
|
-
|
|
3797
|
+
// Ink delivers a complete unrecognised CSI token with its leading Escape
|
|
3798
|
+
// removed. Consume the whole SGR namespace here, including unsupported
|
|
3799
|
+
// buttons/modifiers, so no report can fall through to keyboard bindings.
|
|
3800
|
+
if (input.startsWith("[<")) {
|
|
3801
|
+
const mouse = parseSgrMouse(input);
|
|
3802
|
+
if (mouse) handleSgrMouse(mouse);
|
|
3803
|
+
return;
|
|
3804
|
+
}
|
|
3805
|
+
if (input === "q" || (input === "c" && key.ctrl)) {
|
|
3806
|
+
flushWidthPreferences();
|
|
3807
|
+
exit();
|
|
3808
|
+
} else if (resizeRef.current.active) {
|
|
3809
|
+
if (input === "w" || key.return || key.escape) {
|
|
3810
|
+
leaveWidthMode();
|
|
3811
|
+
} else if (key.tab) {
|
|
3812
|
+
const activeTab = tabForKey(resizeRef.current.tabKey);
|
|
3813
|
+
rememberWidthSelection(
|
|
3814
|
+
cycleWidthKey(activeTab, resizeRef.current.selectedKey, key.shift ? -1 : 1),
|
|
3815
|
+
);
|
|
3816
|
+
} else if (key.leftArrow) {
|
|
3817
|
+
resizeSelectedWidth(key.shift ? -5 : -1);
|
|
3818
|
+
} else if (key.rightArrow) {
|
|
3819
|
+
resizeSelectedWidth(key.shift ? 5 : 1);
|
|
3820
|
+
} else if (input === "r") {
|
|
3821
|
+
resetSelectedWidth();
|
|
3822
|
+
} else if (input === "R") {
|
|
3823
|
+
resetActiveTabWidths();
|
|
3824
|
+
}
|
|
3825
|
+
// Width mode owns every other key. In particular, digits, arrows, Tab,
|
|
3826
|
+
// Enter and r must never fall through to their ordinary meanings.
|
|
3827
|
+
} else if (key.escape) {
|
|
3828
|
+
flushWidthPreferences();
|
|
2656
3829
|
exit();
|
|
2657
3830
|
} else if (showHelpRef.current) {
|
|
2658
3831
|
// Any key dismisses -- except quit, handled above, which must never be
|
|
@@ -2662,6 +3835,8 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
2662
3835
|
setShowHelp(false);
|
|
2663
3836
|
} else if (input === "?") {
|
|
2664
3837
|
setShowHelp(true);
|
|
3838
|
+
} else if (input === "w") {
|
|
3839
|
+
enterWidthMode();
|
|
2665
3840
|
} else if (key.downArrow || input === "j") {
|
|
2666
3841
|
moveSelection(1);
|
|
2667
3842
|
} else if (key.upArrow || input === "k") {
|
|
@@ -2671,7 +3846,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
2671
3846
|
} else if (key.pageUp) {
|
|
2672
3847
|
moveSelection(-pageStep);
|
|
2673
3848
|
} else if (key.return) {
|
|
2674
|
-
if (remoteSetupRef.current) onCreateRemote();
|
|
3849
|
+
if (remoteSetupRef.current) onCreateRemote(suspendTerminal);
|
|
2675
3850
|
else openSelected();
|
|
2676
3851
|
} else if (input === "r") {
|
|
2677
3852
|
// Goes through the same per-tab in-flight guard as the poll loop, so
|
|
@@ -2710,6 +3885,13 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
2710
3885
|
// ref or a setState function, both of which the rule treats as stable -- so
|
|
2711
3886
|
// this needs no suppression, and if one ever becomes necessary that is the
|
|
2712
3887
|
// signal that a real dependency crept in.
|
|
3888
|
+
//
|
|
3889
|
+
// The adaptive throttle re-arms the timer, which is not a contradiction of the
|
|
3890
|
+
// above: `rearm` swaps the interval from *inside* this effect, so the effect
|
|
3891
|
+
// still runs exactly once and no closure, ref or AbortController is ever
|
|
3892
|
+
// recreated. What is created once is the effect; what changes is only the
|
|
3893
|
+
// delay `tick` is scheduled at. Do not move the re-arm into a dependency
|
|
3894
|
+
// array -- that is precisely the teardown this comment exists to prevent.
|
|
2713
3895
|
useEffect(() => {
|
|
2714
3896
|
let cancelled = false;
|
|
2715
3897
|
let ticks = 0;
|
|
@@ -2746,6 +3928,10 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
2746
3928
|
setLoading((l) => (l[key] ? l : { ...l, [key]: true }));
|
|
2747
3929
|
return run()
|
|
2748
3930
|
.then((result) => {
|
|
3931
|
+
// Billed before the `cancelled` early return: the requests were made
|
|
3932
|
+
// and counted by GitHub whether or not this process still wants the
|
|
3933
|
+
// answer.
|
|
3934
|
+
restSpentTotal += result?.restSpent ?? 0;
|
|
2749
3935
|
if (cancelled) return;
|
|
2750
3936
|
const { raw, parse, limit } = result;
|
|
2751
3937
|
// Identical payload: skip the parse *and* the state update. Returning
|
|
@@ -2795,6 +3981,11 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
2795
3981
|
}
|
|
2796
3982
|
})
|
|
2797
3983
|
.catch((err) => {
|
|
3984
|
+
// A rejected fetch carries no result, so its own spend is unknowable
|
|
3985
|
+
// here; bill the tab's table cost instead. A request that failed after
|
|
3986
|
+
// reaching GitHub still counted, and over-billing by an aborted call
|
|
3987
|
+
// errs toward throttling harder, which is the safe direction.
|
|
3988
|
+
restSpentTotal += REST_PER_FETCH[key] ?? 0;
|
|
2798
3989
|
if (cancelled || err?.name === "AbortError") return;
|
|
2799
3990
|
cleanRef.current[key] = false;
|
|
2800
3991
|
// Preserve both the verdict and the bounded raw error in state. The
|
|
@@ -2825,7 +4016,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
2825
4016
|
// exactly the moment the pane looks broken and they reach for it.
|
|
2826
4017
|
function fetchTab(key, { force = false } = {}) {
|
|
2827
4018
|
const signal = controller.signal;
|
|
2828
|
-
const descriptor =
|
|
4019
|
+
const descriptor = tabForKey(key);
|
|
2829
4020
|
if (!force && backoffActive(`tab:${key}`, performance.now())) return Promise.resolve();
|
|
2830
4021
|
if (force) clearBackoff(`tab:${key}`);
|
|
2831
4022
|
// Every tab carries its own fetcher on the registry, so adding a tab is a
|
|
@@ -2841,6 +4032,10 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
2841
4032
|
// so stop spawning subprocesses once any endpoint proves there is no
|
|
2842
4033
|
// remote. The interval may still wake, but this branch is otherwise idle.
|
|
2843
4034
|
if (remoteSetupRef.current) return;
|
|
4035
|
+
// Kicked off, never awaited: the budget probe must not delay the fetches
|
|
4036
|
+
// this tick is for. `probeInFlight` keeps a slow probe from stampeding.
|
|
4037
|
+
const nowMs = Date.now();
|
|
4038
|
+
if (!probeInFlight && nowMs - lastProbeAt >= BUDGET_PROBE_MS) void probeBudget(nowMs);
|
|
2844
4039
|
// Only the tab you're looking at needs to keep up with REFRESH_MS. The
|
|
2845
4040
|
// other three exist to keep the tab-bar counts honest, which tolerates
|
|
2846
4041
|
// being a minute behind -- so they refresh every BACKGROUND_EVERY ticks
|
|
@@ -2861,11 +4056,64 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
2861
4056
|
);
|
|
2862
4057
|
}
|
|
2863
4058
|
|
|
4059
|
+
// The adaptive throttle. `appliedMs` is what the timer is currently set to;
|
|
4060
|
+
// runtime.refreshMs stays the floor it may widen from and never tighten past.
|
|
4061
|
+
let appliedMs = runtime.refreshMs;
|
|
4062
|
+
let intervalId = null;
|
|
4063
|
+
// 0 rather than `now`, so the first tick probes immediately: a pane started
|
|
4064
|
+
// into an already-drained budget must back off at once rather than spend a
|
|
4065
|
+
// minute at full rate first.
|
|
4066
|
+
let lastProbeAt = 0;
|
|
4067
|
+
let probeInFlight = false;
|
|
4068
|
+
// The open probe window, and the last share it was able to measure. Both are
|
|
4069
|
+
// carried by nextProbeWindow/inferShare rather than reset every probe -- see
|
|
4070
|
+
// those functions for why a throttled pane must not re-measure from scratch.
|
|
4071
|
+
let probeWindow = null;
|
|
4072
|
+
let lastShare = 1;
|
|
4073
|
+
|
|
4074
|
+
function rearm(ms) {
|
|
4075
|
+
appliedMs = ms;
|
|
4076
|
+
if (intervalId) clearInterval(intervalId);
|
|
4077
|
+
intervalId = setInterval(tick, ms);
|
|
4078
|
+
}
|
|
4079
|
+
|
|
4080
|
+
async function probeBudget(nowMs) {
|
|
4081
|
+
probeInFlight = true;
|
|
4082
|
+
lastProbeAt = nowMs; // set before awaiting, so ticks cannot stampede
|
|
4083
|
+
try {
|
|
4084
|
+
// `controller` is this effect's own AbortController, so a probe still in
|
|
4085
|
+
// flight at unmount is killed along with everything else.
|
|
4086
|
+
const budget = await readCoreBudget(controller.signal);
|
|
4087
|
+
if (cancelled || !budget) return;
|
|
4088
|
+
const { sample, next } = nextProbeWindow(probeWindow, budget, restSpentTotal);
|
|
4089
|
+
probeWindow = next;
|
|
4090
|
+
// Updated before the law reads it. inferShare is idempotent, so passing
|
|
4091
|
+
// the already-updated value cannot change the answer -- it just keeps
|
|
4092
|
+
// one variable holding the current share rather than two.
|
|
4093
|
+
lastShare = inferShare(sample, lastShare);
|
|
4094
|
+
|
|
4095
|
+
const target = adaptiveRefreshMs({
|
|
4096
|
+
budget,
|
|
4097
|
+
sample,
|
|
4098
|
+
lastShare,
|
|
4099
|
+
floorMs: runtime.refreshMs,
|
|
4100
|
+
nowMs: Date.now(),
|
|
4101
|
+
restPerTick: restPerTick(TABS[activeIndexRef.current].key),
|
|
4102
|
+
});
|
|
4103
|
+
if (adaptiveChangeWorthApplying(appliedMs, target)) {
|
|
4104
|
+
rearm(target);
|
|
4105
|
+
setThrottleMs(target > runtime.refreshMs ? target : null);
|
|
4106
|
+
}
|
|
4107
|
+
} finally {
|
|
4108
|
+
probeInFlight = false;
|
|
4109
|
+
}
|
|
4110
|
+
}
|
|
4111
|
+
|
|
2864
4112
|
tick();
|
|
2865
|
-
|
|
4113
|
+
rearm(runtime.refreshMs);
|
|
2866
4114
|
return () => {
|
|
2867
4115
|
cancelled = true;
|
|
2868
|
-
clearInterval(
|
|
4116
|
+
clearInterval(intervalId);
|
|
2869
4117
|
if (contextCoordinatorRef.current === coordinator) contextCoordinatorRef.current = null;
|
|
2870
4118
|
// `cancelled` stops state updates from a promise that already resolved;
|
|
2871
4119
|
// the signal stops the subprocess itself, so quitting doesn't orphan up to
|
|
@@ -3049,8 +4297,14 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
3049
4297
|
// the cost -- different tabs showing different column counts at the same width
|
|
3050
4298
|
// -- is not something you can actually see. MIN_TABLE_WIDTH stays as the
|
|
3051
4299
|
// exported worst case, which is what the tests pin.
|
|
3052
|
-
const
|
|
3053
|
-
|
|
4300
|
+
const tabOverrides = pick(widthOverridesRef.current, tab.key, EMPTY_WIDTH_OVERRIDES) ??
|
|
4301
|
+
EMPTY_WIDTH_OVERRIDES;
|
|
4302
|
+
const effectiveHeader = useMemo(
|
|
4303
|
+
() => effectiveHeaderFor(tab, tabOverrides, frameCols),
|
|
4304
|
+
[tab, tabOverrides, frameCols],
|
|
4305
|
+
);
|
|
4306
|
+
const compact = effectiveHeader == null;
|
|
4307
|
+
const header = compact ? tab.compactHeader : effectiveHeader;
|
|
3054
4308
|
|
|
3055
4309
|
// Below the compact set's own floor even the fixed columns overflow, which
|
|
3056
4310
|
// hard-wraps every row and drives ink into clearing and repainting the whole
|
|
@@ -3059,6 +4313,50 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
3059
4313
|
// sits after usableSize(), which substitutes DEFAULT_COLS for a 0 reported
|
|
3060
4314
|
// mid-resize, so a transient zero cannot be mistaken for a genuinely tiny pane.
|
|
3061
4315
|
const tooNarrow = frameCols < MIN_COMPACT_WIDTH;
|
|
4316
|
+
const fullHeaderVisible = !compact && !showHelp && !tooNarrow && !remoteSetup;
|
|
4317
|
+
const selectedWidthKey = selectedWidthKeyByTab[tab.key] ?? null;
|
|
4318
|
+
const selectedWidthColumn = effectiveHeader?.find(
|
|
4319
|
+
(column) => column.key === selectedWidthKey,
|
|
4320
|
+
) ?? null;
|
|
4321
|
+
resizeRef.current = {
|
|
4322
|
+
active: widthMode && fullHeaderVisible && selectedWidthColumn !== null,
|
|
4323
|
+
tabKey: tab.key,
|
|
4324
|
+
selectedKey: selectedWidthKey,
|
|
4325
|
+
effectiveHeader,
|
|
4326
|
+
frameCols,
|
|
4327
|
+
compact,
|
|
4328
|
+
fullHeaderVisible,
|
|
4329
|
+
};
|
|
4330
|
+
|
|
4331
|
+
// A resize, help/setup transition, or replacement layout can remove the full
|
|
4332
|
+
// header without a keypress. Stop owning input immediately through resizeRef,
|
|
4333
|
+
// then settle the visible mode state and durable write in the effect.
|
|
4334
|
+
useEffect(() => {
|
|
4335
|
+
let flushed = false;
|
|
4336
|
+
const drag = dragRef.current;
|
|
4337
|
+
if (drag) {
|
|
4338
|
+
const metrics = measuredHeader();
|
|
4339
|
+
if (
|
|
4340
|
+
drag.tabKey !== tab.key ||
|
|
4341
|
+
!fullHeaderVisible ||
|
|
4342
|
+
!sameElementMetrics(drag.metrics, metrics)
|
|
4343
|
+
) {
|
|
4344
|
+
flushed = cancelWidthDrag();
|
|
4345
|
+
}
|
|
4346
|
+
}
|
|
4347
|
+
if (!widthMode || fullHeaderVisible) return;
|
|
4348
|
+
setWidthMode(false);
|
|
4349
|
+
if (!flushed) void widthPreferenceWriter.flush();
|
|
4350
|
+
}, [
|
|
4351
|
+
widthMode,
|
|
4352
|
+
fullHeaderVisible,
|
|
4353
|
+
tab.key,
|
|
4354
|
+
frameCols,
|
|
4355
|
+
extraLines,
|
|
4356
|
+
cancelWidthDrag,
|
|
4357
|
+
measuredHeader,
|
|
4358
|
+
widthPreferenceWriter,
|
|
4359
|
+
]);
|
|
3062
4360
|
|
|
3063
4361
|
return e(
|
|
3064
4362
|
Box,
|
|
@@ -3135,7 +4433,14 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
3135
4433
|
line,
|
|
3136
4434
|
),
|
|
3137
4435
|
),
|
|
3138
|
-
!showHelp &&
|
|
4436
|
+
!showHelp &&
|
|
4437
|
+
!tooNarrow &&
|
|
4438
|
+
!remoteSetup &&
|
|
4439
|
+
e(MemoHeaderCells, {
|
|
4440
|
+
cells: header,
|
|
4441
|
+
selectedWidthKey: resizeRef.current.active ? selectedWidthKey : null,
|
|
4442
|
+
headerRef,
|
|
4443
|
+
}),
|
|
3139
4444
|
...(tooNarrow || showHelp || remoteSetup ? [] : visibleItems).map((item) => {
|
|
3140
4445
|
const key = itemKey(item);
|
|
3141
4446
|
return e(
|
|
@@ -3145,6 +4450,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
3145
4450
|
item,
|
|
3146
4451
|
now,
|
|
3147
4452
|
compact,
|
|
4453
|
+
columns: header,
|
|
3148
4454
|
cursor: key === selectedKey,
|
|
3149
4455
|
// Only an *executing* Actions row reads this. It used to go to every
|
|
3150
4456
|
// visible row on the tab, so a prop that changes several times a
|
|
@@ -3195,9 +4501,13 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
3195
4501
|
fetching: Object.values(loading).some(Boolean),
|
|
3196
4502
|
spin: showSpinner ? spin : null,
|
|
3197
4503
|
stale: staleLabel,
|
|
4504
|
+
throttle: throttleMs ? `throttled ${Math.round(throttleMs / 1000)}s` : null,
|
|
3198
4505
|
interactive,
|
|
3199
4506
|
cols: frameCols,
|
|
3200
4507
|
remoteSetup,
|
|
4508
|
+
widthMode: resizeRef.current.active,
|
|
4509
|
+
widthColumn: selectedWidthColumn,
|
|
4510
|
+
widthSaveError,
|
|
3201
4511
|
}),
|
|
3202
4512
|
);
|
|
3203
4513
|
}
|
|
@@ -3211,16 +4521,58 @@ function enterAlternateScreen() {
|
|
|
3211
4521
|
process.stdout.write("\x1b[?1049h\x1b[2J\x1b[H");
|
|
3212
4522
|
}
|
|
3213
4523
|
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
4524
|
+
function createTerminalLifecycle(write) {
|
|
4525
|
+
if (typeof write !== "function") throw new TypeError("write must be a function");
|
|
4526
|
+
let mouseReportingEnabled = false;
|
|
4527
|
+
let screenRestored = false;
|
|
4528
|
+
|
|
4529
|
+
function enableMouseReporting() {
|
|
4530
|
+
if (mouseReportingEnabled || screenRestored) return false;
|
|
4531
|
+
write("\x1b[?1002h\x1b[?1006h");
|
|
4532
|
+
mouseReportingEnabled = true;
|
|
4533
|
+
return true;
|
|
4534
|
+
}
|
|
4535
|
+
|
|
4536
|
+
function disableMouseReporting() {
|
|
4537
|
+
if (!mouseReportingEnabled) return false;
|
|
4538
|
+
write("\x1b[?1002l\x1b[?1006l");
|
|
4539
|
+
mouseReportingEnabled = false;
|
|
4540
|
+
return true;
|
|
4541
|
+
}
|
|
4542
|
+
|
|
4543
|
+
function restoreScreen() {
|
|
4544
|
+
if (screenRestored) return false;
|
|
4545
|
+
disableMouseReporting();
|
|
4546
|
+
screenRestored = true;
|
|
4547
|
+
// Ink restores the cursor only through its own unmount path, which an
|
|
4548
|
+
// explicit process.exit() skips.
|
|
4549
|
+
write("\x1b[?25h\x1b[?1049l");
|
|
4550
|
+
return true;
|
|
4551
|
+
}
|
|
4552
|
+
|
|
4553
|
+
return {
|
|
4554
|
+
enableMouseReporting,
|
|
4555
|
+
disableMouseReporting,
|
|
4556
|
+
restoreScreen,
|
|
4557
|
+
isMouseReportingEnabled: () => mouseReportingEnabled,
|
|
4558
|
+
};
|
|
4559
|
+
}
|
|
4560
|
+
|
|
4561
|
+
const terminalLifecycle = createTerminalLifecycle((output) => process.stdout.write(output));
|
|
4562
|
+
|
|
4563
|
+
function enableMouseReporting() {
|
|
4564
|
+
return terminalLifecycle.enableMouseReporting();
|
|
4565
|
+
}
|
|
4566
|
+
|
|
4567
|
+
function disableMouseReporting() {
|
|
4568
|
+
return terminalLifecycle.disableMouseReporting();
|
|
4569
|
+
}
|
|
4570
|
+
|
|
4571
|
+
// Idempotent because both the lifecycle controller and this process-level
|
|
4572
|
+
// wrapper share one restored state. Mouse modes are always disabled before the
|
|
4573
|
+
// alternate buffer is released, including when this is only the exit backstop.
|
|
3220
4574
|
function restoreScreen() {
|
|
3221
|
-
|
|
3222
|
-
screenRestored = true;
|
|
3223
|
-
process.stdout.write("\x1b[?25h\x1b[?1049l");
|
|
4575
|
+
return terminalLifecycle.restoreScreen();
|
|
3224
4576
|
}
|
|
3225
4577
|
|
|
3226
4578
|
// A crash used to be indistinguishable from a clean quit. Ink catches render
|
|
@@ -3229,18 +4581,18 @@ function restoreScreen() {
|
|
|
3229
4581
|
// simply vanished and any wrapper saw success. Restore the primary buffer
|
|
3230
4582
|
// *first*, then write, or the fix reproduces the problem it is fixing.
|
|
3231
4583
|
//
|
|
3232
|
-
//
|
|
3233
|
-
//
|
|
3234
|
-
//
|
|
3235
|
-
//
|
|
3236
|
-
//
|
|
3237
|
-
// handler exists to print with an unrelated one.
|
|
4584
|
+
// Unmount before restore so Ink's final repaint stays in the alternate buffer.
|
|
4585
|
+
// A crash before app assignment, or another failure during unmount, can skip the
|
|
4586
|
+
// poll effect's cleanup, so abortLiveRequests() remains an explicit backstop.
|
|
4587
|
+
// It runs *after* restoreScreen() and is wrapped because an exception there must
|
|
4588
|
+
// not replace the stack trace this handler exists to print.
|
|
3238
4589
|
//
|
|
3239
4590
|
// Both messages go through redact(): a stack can carry a URL with inline
|
|
3240
4591
|
// credentials, and this output is what a user pastes into a bug report -- the
|
|
3241
4592
|
// same reasoning --doctor already applies to its own report.
|
|
3242
|
-
function installCrashHandlers() {
|
|
4593
|
+
function installCrashHandlers(unmountApp) {
|
|
3243
4594
|
const fail = (label) => (err) => {
|
|
4595
|
+
unmountApp();
|
|
3244
4596
|
restoreScreen();
|
|
3245
4597
|
try {
|
|
3246
4598
|
abortLiveRequests();
|
|
@@ -3256,8 +4608,16 @@ function installCrashHandlers() {
|
|
|
3256
4608
|
}
|
|
3257
4609
|
|
|
3258
4610
|
if (IS_MAIN) {
|
|
4611
|
+
let app;
|
|
4612
|
+
const unmountApp = () => {
|
|
4613
|
+
try {
|
|
4614
|
+
app?.unmount();
|
|
4615
|
+
} catch {
|
|
4616
|
+
// Teardown is best effort. Every caller still restores the terminal.
|
|
4617
|
+
}
|
|
4618
|
+
};
|
|
3259
4619
|
disarmDevBuildLeak();
|
|
3260
|
-
installCrashHandlers();
|
|
4620
|
+
installCrashHandlers(unmountApp);
|
|
3261
4621
|
enterAlternateScreen();
|
|
3262
4622
|
process.on("exit", restoreScreen);
|
|
3263
4623
|
|
|
@@ -3271,40 +4631,71 @@ if (IS_MAIN) {
|
|
|
3271
4631
|
// root box is the terminal height. Windows is already documented as untested
|
|
3272
4632
|
// (README Limitations), and the pty harness covers the two platforms that are
|
|
3273
4633
|
// supported, so the flag is verified where it is claimed to work.
|
|
3274
|
-
let app;
|
|
3275
4634
|
let remoteSetupStarted = false;
|
|
3276
|
-
const createRemote = () => {
|
|
4635
|
+
const createRemote = (suspendTerminal) => {
|
|
3277
4636
|
if (remoteSetupStarted) return;
|
|
3278
4637
|
remoteSetupStarted = true;
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
4638
|
+
|
|
4639
|
+
// Release Ink's raw-mode/parser state before the child inherits stdin.
|
|
4640
|
+
// Unmounting alone can leave an active readable dispatch racing the first
|
|
4641
|
+
// bytes typed for gh, so one immediate boundary lets that dispatch return;
|
|
4642
|
+
// the explicit stream cleanup below then prevents any future parent reads.
|
|
4643
|
+
void suspendTerminal()
|
|
4644
|
+
.then(async () => {
|
|
4645
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
4646
|
+
unmountApp();
|
|
4647
|
+
// This is a permanent handoff, so the parent must not retain any stream
|
|
4648
|
+
// consumer that can race the child for terminal bytes. Pausing/removing
|
|
4649
|
+
// Node listeners does not close fd 0; the spawned process still inherits
|
|
4650
|
+
// the same canonical TTY directly from the operating system.
|
|
4651
|
+
process.stdin.pause();
|
|
4652
|
+
process.stdin.removeAllListeners("readable");
|
|
4653
|
+
process.stdin.removeAllListeners("data");
|
|
4654
|
+
restoreScreen();
|
|
4655
|
+
abortLiveRequests();
|
|
4656
|
+
|
|
4657
|
+
await new Promise((resolve) => {
|
|
4658
|
+
// Plain `gh repo create` is the interactive form. Supplying --source
|
|
4659
|
+
// here would switch gh to non-interactive mode and require gh-glance
|
|
4660
|
+
// to choose a visibility on the user's behalf, which this consent
|
|
4661
|
+
// boundary must never do. The prompt tells the user which interactive
|
|
4662
|
+
// path matches this folder.
|
|
4663
|
+
const child = spawn("gh", ["repo", "create"], {
|
|
4664
|
+
stdio: "inherit",
|
|
4665
|
+
env: process.env,
|
|
4666
|
+
});
|
|
4667
|
+
setupChild = child;
|
|
4668
|
+
child.once("error", (err) => {
|
|
4669
|
+
if (setupChild === child) setupChild = null;
|
|
4670
|
+
console.error(
|
|
4671
|
+
`gh-glance: could not start repository setup: ${redact(shortErr(err))}`,
|
|
4672
|
+
);
|
|
4673
|
+
process.exitCode = 1;
|
|
4674
|
+
resolve();
|
|
4675
|
+
});
|
|
4676
|
+
child.once("exit", (code, signal) => {
|
|
4677
|
+
if (setupChild === child) setupChild = null;
|
|
4678
|
+
process.exitCode = signal ? 1 : (code ?? 1);
|
|
4679
|
+
if (code === 0) {
|
|
4680
|
+
console.log(
|
|
4681
|
+
"gh repo create finished. Run gh-glance again when this folder has a remote.",
|
|
4682
|
+
);
|
|
4683
|
+
}
|
|
4684
|
+
resolve();
|
|
4685
|
+
});
|
|
4686
|
+
});
|
|
4687
|
+
})
|
|
4688
|
+
.catch((err) => {
|
|
4689
|
+
unmountApp();
|
|
4690
|
+
restoreScreen();
|
|
4691
|
+
try {
|
|
4692
|
+
abortLiveRequests();
|
|
4693
|
+
} catch {
|
|
4694
|
+
// Preserve the handoff error below.
|
|
4695
|
+
}
|
|
4696
|
+
console.error(`gh-glance: could not hand off the terminal: ${redact(shortErr(err))}`);
|
|
4697
|
+
process.exitCode = 1;
|
|
4698
|
+
});
|
|
3308
4699
|
};
|
|
3309
4700
|
app = render(e(App, { onCreateRemote: createRemote }), { incrementalRendering: true });
|
|
3310
4701
|
|
|
@@ -3334,12 +4725,7 @@ if (IS_MAIN) {
|
|
|
3334
4725
|
if (signalExitStarted) return;
|
|
3335
4726
|
signalExitStarted = true;
|
|
3336
4727
|
const finish = () => {
|
|
3337
|
-
|
|
3338
|
-
app.unmount();
|
|
3339
|
-
} catch {
|
|
3340
|
-
// Teardown is best effort. restoreScreen below, and the `exit` listener
|
|
3341
|
-
// above, both still run, so the terminal is handed back either way.
|
|
3342
|
-
}
|
|
4728
|
+
unmountApp();
|
|
3343
4729
|
restoreScreen();
|
|
3344
4730
|
process.exit(code);
|
|
3345
4731
|
};
|
|
@@ -3374,6 +4760,22 @@ export {
|
|
|
3374
4760
|
MIN_REFRESH_SECONDS,
|
|
3375
4761
|
MAX_REFRESH_SECONDS,
|
|
3376
4762
|
TAB_KEYS,
|
|
4763
|
+
ALERT_SOURCES,
|
|
4764
|
+
REST_PER_FETCH,
|
|
4765
|
+
GRAPHQL_PER_FETCH,
|
|
4766
|
+
projectedHourlyCost,
|
|
4767
|
+
REFRESH_MS,
|
|
4768
|
+
BACKGROUND_EVERY,
|
|
4769
|
+
adaptiveRefreshMs,
|
|
4770
|
+
adaptiveChangeWorthApplying,
|
|
4771
|
+
sampleIsUsable,
|
|
4772
|
+
inferShare,
|
|
4773
|
+
nextProbeWindow,
|
|
4774
|
+
restPerTick,
|
|
4775
|
+
MAX_ADAPTIVE_REFRESH_MS,
|
|
4776
|
+
BUDGET_SAFETY,
|
|
4777
|
+
MIN_SAMPLE_CALLS,
|
|
4778
|
+
ADAPTIVE_HYSTERESIS,
|
|
3377
4779
|
safe,
|
|
3378
4780
|
shortErr,
|
|
3379
4781
|
isUnavailable,
|
|
@@ -3398,17 +4800,44 @@ export {
|
|
|
3398
4800
|
usableSize,
|
|
3399
4801
|
severityRank,
|
|
3400
4802
|
pick,
|
|
4803
|
+
columnProps,
|
|
4804
|
+
adjustableWidthKeys,
|
|
4805
|
+
selectWidthKey,
|
|
4806
|
+
cycleWidthKey,
|
|
4807
|
+
resolveHeader,
|
|
4808
|
+
fitHeaderToFrame,
|
|
4809
|
+
adjustWidth,
|
|
4810
|
+
updateWidthPreference,
|
|
4811
|
+
resetWidthPreference,
|
|
4812
|
+
resetTabWidthPreferences,
|
|
3401
4813
|
minimumWidthFor,
|
|
4814
|
+
WIDTH_PREFERENCES_VERSION,
|
|
4815
|
+
widthPreferencesPath,
|
|
4816
|
+
parseWidthPreferences,
|
|
4817
|
+
serializeWidthPreferences,
|
|
4818
|
+
loadWidthPreferences,
|
|
4819
|
+
saveWidthPreferences,
|
|
4820
|
+
createWidthPreferenceWriter,
|
|
3402
4821
|
runStatusIcon,
|
|
3403
4822
|
RUN_STATUS_ICON,
|
|
3404
4823
|
SEVERITY_STYLE,
|
|
3405
4824
|
REVIEW_LABEL,
|
|
3406
4825
|
MIN_TABLE_WIDTH,
|
|
3407
4826
|
MIN_COMPACT_WIDTH,
|
|
4827
|
+
TABS,
|
|
3408
4828
|
OCT_NERD,
|
|
3409
4829
|
OCT_UNICODE,
|
|
3410
4830
|
KEY_TABLE,
|
|
3411
4831
|
KEY_HINTS,
|
|
4832
|
+
widthStatusText,
|
|
4833
|
+
headerGutterKey,
|
|
4834
|
+
HeaderCells,
|
|
4835
|
+
parseSgrMouse,
|
|
4836
|
+
dividerHandles,
|
|
4837
|
+
hitDivider,
|
|
4838
|
+
beginDividerDrag,
|
|
4839
|
+
draggedWidth,
|
|
4840
|
+
createTerminalLifecycle,
|
|
3412
4841
|
REMOTE_SETUP_HINTS,
|
|
3413
4842
|
REMOTE_SETUP_LINES,
|
|
3414
4843
|
REMOTE_SETUP_NONINTERACTIVE_LINES,
|