gh-glance 0.6.1 → 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 +49 -0
- package/README.md +106 -26
- package/index.mjs +1565 -149
- 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
|
|
@@ -793,6 +804,10 @@ async function fetchActions(limit, signal) {
|
|
|
793
804
|
return {
|
|
794
805
|
raw,
|
|
795
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,
|
|
796
811
|
parse: () =>
|
|
797
812
|
JSON.parse(raw).map((r) => ({
|
|
798
813
|
databaseId: r.databaseId,
|
|
@@ -838,6 +853,9 @@ async function fetchIssues(signal) {
|
|
|
838
853
|
return {
|
|
839
854
|
raw,
|
|
840
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,
|
|
841
859
|
parse: () =>
|
|
842
860
|
JSON.parse(raw).map((i) => ({
|
|
843
861
|
number: i.number,
|
|
@@ -869,6 +887,8 @@ async function fetchPRs(signal) {
|
|
|
869
887
|
return {
|
|
870
888
|
raw,
|
|
871
889
|
limit: LIST_LIMIT,
|
|
890
|
+
// Zero for the same reason as issues: --search makes this a GraphQL call.
|
|
891
|
+
restSpent: REST_PER_FETCH.prs,
|
|
872
892
|
parse: () =>
|
|
873
893
|
JSON.parse(raw).map((p) => ({
|
|
874
894
|
number: p.number,
|
|
@@ -946,6 +966,148 @@ const ALERT_SOURCES = [
|
|
|
946
966
|
},
|
|
947
967
|
];
|
|
948
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
|
+
|
|
949
1111
|
function alertArgs(source) {
|
|
950
1112
|
return ["api", apiPath(source.path), ...apiHostArgs(), "--jq", source.jq];
|
|
951
1113
|
}
|
|
@@ -955,6 +1117,18 @@ function alertArgs(source) {
|
|
|
955
1117
|
// mid-session is picked up within the hour.
|
|
956
1118
|
const alertBackoff = new Map();
|
|
957
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
|
+
|
|
958
1132
|
function backoffActive(key, now) {
|
|
959
1133
|
const state = alertBackoff.get(key);
|
|
960
1134
|
return Boolean(state) && now < state.until;
|
|
@@ -990,13 +1164,20 @@ async function fetchAlertSource(source, signal, now) {
|
|
|
990
1164
|
// The verdict is replayed alongside the note. Replaying only the note left
|
|
991
1165
|
// the tab unable to tell "Dependabot is switched off here" from "we cannot
|
|
992
1166
|
// see Dependabot" for the whole length of a backoff window.
|
|
993
|
-
|
|
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
|
+
};
|
|
994
1174
|
}
|
|
995
1175
|
try {
|
|
996
1176
|
const raw = await runGh(alertArgs(source), { signal });
|
|
997
1177
|
clearBackoff(source.key);
|
|
998
1178
|
return {
|
|
999
1179
|
raw,
|
|
1180
|
+
spent: 1,
|
|
1000
1181
|
parse: () => {
|
|
1001
1182
|
const rows = JSON.parse(raw).filter((a) => a.state === "open");
|
|
1002
1183
|
return {
|
|
@@ -1021,6 +1202,9 @@ async function fetchAlertSource(source, signal, now) {
|
|
|
1021
1202
|
}
|
|
1022
1203
|
return {
|
|
1023
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,
|
|
1024
1208
|
parse: () => ({ alerts: [], note, verdict, truncated: false }),
|
|
1025
1209
|
};
|
|
1026
1210
|
}
|
|
@@ -1054,6 +1238,10 @@ async function fetchSecurity(signal) {
|
|
|
1054
1238
|
// colliding on the same string.
|
|
1055
1239
|
raw: parts.map((p) => p.raw).join("\0"),
|
|
1056
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),
|
|
1057
1245
|
parse: () => {
|
|
1058
1246
|
const parsed = parts.map((p) => p.parse());
|
|
1059
1247
|
const alerts = parsed.flatMap((p) => p.alerts);
|
|
@@ -1168,6 +1356,7 @@ const DOCTOR_ENV_PLAIN = [
|
|
|
1168
1356
|
"NO_PROXY",
|
|
1169
1357
|
"GH_GLANCE_ICONS",
|
|
1170
1358
|
"GH_GLANCE_NO_ANIMATION",
|
|
1359
|
+
"GH_GLANCE_REFRESH",
|
|
1171
1360
|
"NO_COLOR",
|
|
1172
1361
|
"NODE_ENV",
|
|
1173
1362
|
];
|
|
@@ -1293,8 +1482,8 @@ function targetSource() {
|
|
|
1293
1482
|
|
|
1294
1483
|
// How much of the hourly API budget is left, and roughly how fast this
|
|
1295
1484
|
// configuration spends it. The steady-state cost is not small and was invisible:
|
|
1296
|
-
// at the default 5s refresh with the Security tab open it is around 2,
|
|
1297
|
-
// requests an hour -- about
|
|
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`
|
|
1298
1487
|
// projects past the limit outright, so it exhausts inside the hour, every hour.
|
|
1299
1488
|
// The budget is shared with everything else the token does, so the first symptom
|
|
1300
1489
|
// is usually "GitHub is broken" somewhere else entirely.
|
|
@@ -1305,7 +1494,10 @@ function targetSource() {
|
|
|
1305
1494
|
// exactly why this reports the server's own numbers rather than asserting 5,000.
|
|
1306
1495
|
async function rateBudget() {
|
|
1307
1496
|
try {
|
|
1308
|
-
|
|
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()]);
|
|
1309
1501
|
const { resources } = JSON.parse(raw);
|
|
1310
1502
|
// formatAge() is for timestamps in the past and returns "-" for a future one,
|
|
1311
1503
|
// so the reset is rendered as a forward interval instead.
|
|
@@ -1321,24 +1513,52 @@ async function rateBudget() {
|
|
|
1321
1513
|
}
|
|
1322
1514
|
}
|
|
1323
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
|
+
|
|
1324
1546
|
// Requests per hour this configuration will spend once it settles, derived from
|
|
1325
1547
|
// the same constants the poll loop uses rather than from a number written down
|
|
1326
1548
|
// once and left to rot. The active tab refreshes every tick; the other three
|
|
1327
|
-
// every BACKGROUND_EVERY ticks.
|
|
1328
|
-
//
|
|
1329
|
-
//
|
|
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.
|
|
1330
1552
|
function projectedHourlyCost(activeKey) {
|
|
1331
1553
|
const perHour = 3_600_000 / runtime.refreshMs;
|
|
1332
|
-
const restCalls = { actions: 1, issues: 0, prs: 0, security: ALERT_SOURCES.length };
|
|
1333
|
-
const graphqlCalls = { actions: 0, issues: 2, prs: 2, security: 0 };
|
|
1334
1554
|
let rest = 0;
|
|
1335
1555
|
let graphql = 0;
|
|
1336
1556
|
// TAB_KEYS rather than TABS: this runs on the --doctor path, which deliberately
|
|
1337
1557
|
// returns before the render tree is reached, and TABS is declared down there.
|
|
1338
1558
|
for (const key of TAB_KEYS) {
|
|
1339
1559
|
const ticks = key === activeKey ? perHour : perHour / BACKGROUND_EVERY;
|
|
1340
|
-
rest += ticks *
|
|
1341
|
-
graphql += ticks *
|
|
1560
|
+
rest += ticks * REST_PER_FETCH[key];
|
|
1561
|
+
graphql += ticks * GRAPHQL_PER_FETCH[key];
|
|
1342
1562
|
}
|
|
1343
1563
|
return { rest: Math.round(rest), graphql: Math.round(graphql) };
|
|
1344
1564
|
}
|
|
@@ -1495,6 +1715,11 @@ function parseArgs(argv) {
|
|
|
1495
1715
|
doctor: false,
|
|
1496
1716
|
repo: null,
|
|
1497
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,
|
|
1498
1723
|
tab: null,
|
|
1499
1724
|
verbose: false,
|
|
1500
1725
|
};
|
|
@@ -1537,13 +1762,16 @@ function validateArgs(opts, tabKeys) {
|
|
|
1537
1762
|
|
|
1538
1763
|
let refreshMs = null;
|
|
1539
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";
|
|
1540
1768
|
const seconds = Number(opts.refresh);
|
|
1541
1769
|
if (!Number.isFinite(seconds) || !Number.isInteger(seconds)) {
|
|
1542
|
-
throw new Error(
|
|
1770
|
+
throw new Error(`${refreshLabel} must be a whole number of seconds, got: ${opts.refresh}`);
|
|
1543
1771
|
}
|
|
1544
1772
|
if (seconds < MIN_REFRESH_SECONDS || seconds > MAX_REFRESH_SECONDS) {
|
|
1545
1773
|
throw new Error(
|
|
1546
|
-
|
|
1774
|
+
`${refreshLabel} must be between ${MIN_REFRESH_SECONDS} and ${MAX_REFRESH_SECONDS} seconds, got: ${seconds}`,
|
|
1547
1775
|
);
|
|
1548
1776
|
}
|
|
1549
1777
|
refreshMs = seconds * 1000;
|
|
@@ -1581,8 +1809,9 @@ const KEY_TABLE = [
|
|
|
1581
1809
|
["PgUp / PgDn", "Move a page at a time"],
|
|
1582
1810
|
["Enter", "Open the selected item or accept a prompt"],
|
|
1583
1811
|
["r", "Refresh the current tab now"],
|
|
1812
|
+
["w", "Adjust table column widths"],
|
|
1584
1813
|
["?", "Show the keys (any key closes it)"],
|
|
1585
|
-
["q / Esc / Ctrl+C", "Quit"],
|
|
1814
|
+
["q / Esc / Ctrl+C", "Quit (Esc leaves width mode)"],
|
|
1586
1815
|
];
|
|
1587
1816
|
const KEY_COL = Math.max(...KEY_TABLE.map(([k]) => k.length)) + 3;
|
|
1588
1817
|
const keyTableLines = () => KEY_TABLE.map(([k, d]) => `${k.padEnd(KEY_COL)}${d}`);
|
|
@@ -1643,6 +1872,9 @@ Environment:
|
|
|
1643
1872
|
GH_HOST=host GitHub Enterprise or EMU host to send every call to.
|
|
1644
1873
|
A host-qualified GH_REPO does not route \`gh api\`;
|
|
1645
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)
|
|
1646
1878
|
GH_GLANCE_ICONS=unicode Plain-unicode status icons (no Nerd Font needed)
|
|
1647
1879
|
GH_GLANCE_NO_ANIMATION=1 Freeze the spinner (no motion)
|
|
1648
1880
|
NO_COLOR=1 Disable colour (status stays readable)
|
|
@@ -1660,7 +1892,20 @@ Environment:
|
|
|
1660
1892
|
if (IS_MAIN) {
|
|
1661
1893
|
let opts;
|
|
1662
1894
|
try {
|
|
1663
|
-
|
|
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);
|
|
1664
1909
|
} catch (err) {
|
|
1665
1910
|
// Exit 2 for every argv problem, unchanged from when the only possible
|
|
1666
1911
|
// problem was an unrecognised flag. CI asserts this code.
|
|
@@ -1734,10 +1979,10 @@ if (IS_MAIN) {
|
|
|
1734
1979
|
}
|
|
1735
1980
|
|
|
1736
1981
|
const ReactModule = await import("react");
|
|
1737
|
-
const { render, Box, Text, useStdout, useInput, useStdin, useApp } = await import("ink");
|
|
1982
|
+
const { render, measureElement, Box, Text, useStdout, useInput, useStdin, useApp } = await import("ink");
|
|
1738
1983
|
|
|
1739
1984
|
const React = ReactModule.default;
|
|
1740
|
-
const { useState, useEffect, useRef } = ReactModule;
|
|
1985
|
+
const { useState, useEffect, useMemo, useRef, useCallback } = ReactModule;
|
|
1741
1986
|
const e = React.createElement;
|
|
1742
1987
|
|
|
1743
1988
|
// The NODE_ENV escape hatch above is genuinely useful -- React's warnings are
|
|
@@ -1840,10 +2085,10 @@ const ERROR_TEXT = "red";
|
|
|
1840
2085
|
// column is otherwise a private-use codepoint that announces as nothing --
|
|
1841
2086
|
// leaving every row with no status at all. It is derived from the same lookup
|
|
1842
2087
|
// that picks the glyph, so the two cannot drift apart.
|
|
1843
|
-
function Column({ width, grow, children, bold, color, dim, wrap, label }) {
|
|
2088
|
+
function Column({ width, grow, children, bold, color, dim, wrap, label, marginRight = 1 }) {
|
|
1844
2089
|
return e(
|
|
1845
2090
|
Box,
|
|
1846
|
-
{ width, flexGrow: grow ? 1 : 0, flexShrink: grow ? 1 : 0, marginRight
|
|
2091
|
+
{ width, flexGrow: grow ? 1 : 0, flexShrink: grow ? 1 : 0, marginRight },
|
|
1847
2092
|
e(
|
|
1848
2093
|
Text,
|
|
1849
2094
|
{ bold, color, dimColor: dim, wrap: wrap ?? "truncate-end", "aria-label": label },
|
|
@@ -1852,10 +2097,35 @@ function Column({ width, grow, children, bold, color, dim, wrap, label }) {
|
|
|
1852
2097
|
);
|
|
1853
2098
|
}
|
|
1854
2099
|
|
|
1855
|
-
|
|
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);
|
|
1856
2125
|
return e(
|
|
1857
2126
|
Box,
|
|
1858
2127
|
{
|
|
2128
|
+
ref: headerRef,
|
|
1859
2129
|
flexDirection: "row",
|
|
1860
2130
|
borderStyle: "single",
|
|
1861
2131
|
borderTop: false,
|
|
@@ -1863,7 +2133,198 @@ function HeaderCells({ cells }) {
|
|
|
1863
2133
|
borderRight: false,
|
|
1864
2134
|
borderColor: BORDER_COLOR,
|
|
1865
2135
|
},
|
|
1866
|
-
...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
|
|
1867
2328
|
);
|
|
1868
2329
|
}
|
|
1869
2330
|
|
|
@@ -1986,23 +2447,23 @@ function runStatusIcon(run, spin) {
|
|
|
1986
2447
|
}
|
|
1987
2448
|
|
|
1988
2449
|
const ACTIONS_HEADER = [
|
|
1989
|
-
{ label: "", props: { width: 3 } },
|
|
1990
|
-
{ label: "TITLE", props: { grow: true } },
|
|
1991
|
-
{ label: "WORKFLOW", props: { width: 10 } },
|
|
1992
|
-
{ label: "BRANCH", props: { width: 14 } },
|
|
1993
|
-
{ label: "TIME", props: { width: 7 } },
|
|
1994
|
-
{ label: "UPDATED", props: { width: 8 } },
|
|
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 },
|
|
1995
2456
|
];
|
|
1996
2457
|
|
|
1997
2458
|
// Dropped in order of least value first: BRANCH and WORKFLOW are inferable
|
|
1998
2459
|
// from the title far more often than the status icon or the age are.
|
|
1999
2460
|
const ACTIONS_HEADER_COMPACT = [
|
|
2000
|
-
{ label: "", props: { width: 3 } },
|
|
2001
|
-
{ label: "TITLE", props: { grow: true } },
|
|
2002
|
-
{ 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 } },
|
|
2003
2464
|
];
|
|
2004
2465
|
|
|
2005
|
-
function ActionsRow({ item, now, spin, compact, cursor }) {
|
|
2466
|
+
function ActionsRow({ item, now, spin, compact, cursor, columns }) {
|
|
2006
2467
|
const { icon, color, label } = runStatusIcon(item, spin);
|
|
2007
2468
|
const started = new Date(item.startedAt);
|
|
2008
2469
|
const finished = item.status === "completed" ? new Date(item.updatedAt) : now;
|
|
@@ -2010,52 +2471,52 @@ function ActionsRow({ item, now, spin, compact, cursor }) {
|
|
|
2010
2471
|
return e(
|
|
2011
2472
|
Box,
|
|
2012
2473
|
{ flexDirection: "row" },
|
|
2013
|
-
e(Column, {
|
|
2014
|
-
e(Column,
|
|
2015
|
-
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)),
|
|
2016
2477
|
);
|
|
2017
2478
|
}
|
|
2018
2479
|
return e(
|
|
2019
2480
|
Box,
|
|
2020
2481
|
{ flexDirection: "row" },
|
|
2021
|
-
e(Column, {
|
|
2022
|
-
e(Column,
|
|
2482
|
+
e(Column, { ...columnProps(columns, "status"), color, label }, `${cursor ? ">" : " "}${icon}`),
|
|
2483
|
+
e(Column, columnProps(columns, "title"), item.displayTitle),
|
|
2023
2484
|
// The run number is the actionable half and used to be the first thing
|
|
2024
2485
|
// truncation ate, since it sat at the tail of a 10-column cell.
|
|
2025
|
-
e(Column, {
|
|
2026
|
-
e(Column, {
|
|
2027
|
-
e(Column,
|
|
2028
|
-
e(Column, {
|
|
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)),
|
|
2029
2490
|
);
|
|
2030
2491
|
}
|
|
2031
2492
|
|
|
2032
2493
|
// ---------- Issues tab ----------
|
|
2033
2494
|
|
|
2034
2495
|
const ISSUES_HEADER = [
|
|
2035
|
-
{ label: "", props: { width: 3 } },
|
|
2036
|
-
{ label: "TITLE", props: { grow: true } },
|
|
2037
|
-
{ label: "AUTHOR", props: { width: 12 } },
|
|
2038
|
-
{ label: "LABEL", props: { width: 14 } },
|
|
2039
|
-
{ label: "UPDATED", props: { width: 8 } },
|
|
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 },
|
|
2040
2501
|
];
|
|
2041
2502
|
|
|
2042
2503
|
const ISSUES_HEADER_COMPACT = [
|
|
2043
|
-
{ label: "", props: { width: 3 } },
|
|
2044
|
-
{ label: "TITLE", props: { grow: true } },
|
|
2045
|
-
{ 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 } },
|
|
2046
2507
|
];
|
|
2047
2508
|
|
|
2048
|
-
function IssueRow({ item, now, compact, cursor }) {
|
|
2509
|
+
function IssueRow({ item, now, compact, cursor, columns }) {
|
|
2049
2510
|
const cells = [
|
|
2050
|
-
e(Column, { key: "
|
|
2051
|
-
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}`),
|
|
2052
2513
|
];
|
|
2053
2514
|
if (!compact) {
|
|
2054
|
-
cells.push(e(Column, { key: "
|
|
2055
|
-
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));
|
|
2056
2517
|
}
|
|
2057
2518
|
cells.push(
|
|
2058
|
-
e(Column, { key: "
|
|
2519
|
+
e(Column, { key: "updated", ...columnProps(columns, "updated"), dim: true }, formatAge(new Date(item.updatedAt), now)),
|
|
2059
2520
|
);
|
|
2060
2521
|
return e(Box, { flexDirection: "row" }, ...cells);
|
|
2061
2522
|
}
|
|
@@ -2063,18 +2524,18 @@ function IssueRow({ item, now, compact, cursor }) {
|
|
|
2063
2524
|
// ---------- Pull requests tab ----------
|
|
2064
2525
|
|
|
2065
2526
|
const PRS_HEADER = [
|
|
2066
|
-
{ label: "", props: { width: 3 } },
|
|
2067
|
-
{ label: "TITLE", props: { grow: true } },
|
|
2068
|
-
{ label: "AUTHOR", props: { width: 12 } },
|
|
2069
|
-
{ label: "BRANCH", props: { width: 14 } },
|
|
2070
|
-
{ label: "REVIEW", props: { width: 10 } },
|
|
2071
|
-
{ label: "UPDATED", props: { width: 8 } },
|
|
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 },
|
|
2072
2533
|
];
|
|
2073
2534
|
|
|
2074
2535
|
const PRS_HEADER_COMPACT = [
|
|
2075
|
-
{ label: "", props: { width: 3 } },
|
|
2076
|
-
{ label: "TITLE", props: { grow: true } },
|
|
2077
|
-
{ 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 } },
|
|
2078
2539
|
];
|
|
2079
2540
|
|
|
2080
2541
|
const REVIEW_LABEL = {
|
|
@@ -2084,25 +2545,25 @@ const REVIEW_LABEL = {
|
|
|
2084
2545
|
};
|
|
2085
2546
|
const REVIEW_NONE = { label: "", color: INERT };
|
|
2086
2547
|
|
|
2087
|
-
function PRRow({ item, now, compact, cursor }) {
|
|
2548
|
+
function PRRow({ item, now, compact, cursor, columns }) {
|
|
2088
2549
|
const prIcon = item.isDraft
|
|
2089
2550
|
? { icon: OCT.pullRequestDraft, color: INERT, label: "draft pull request" }
|
|
2090
2551
|
: { icon: OCT.pullRequest, color: OK, label: "open pull request" };
|
|
2091
2552
|
const review = pick(REVIEW_LABEL, item.reviewDecision, REVIEW_NONE);
|
|
2092
2553
|
const cells = [
|
|
2093
|
-
e(Column, { key: "
|
|
2094
|
-
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}`),
|
|
2095
2556
|
];
|
|
2096
2557
|
if (!compact) {
|
|
2097
|
-
cells.push(e(Column, { key: "
|
|
2558
|
+
cells.push(e(Column, { key: "author", ...columnProps(columns, "author"), color: IDENTIFIER }, item.author));
|
|
2098
2559
|
cells.push(
|
|
2099
|
-
e(Column, { key: "
|
|
2560
|
+
e(Column, { key: "branch", ...columnProps(columns, "branch"), color: REF, wrap: "truncate-middle" }, item.headRefName),
|
|
2100
2561
|
);
|
|
2101
2562
|
}
|
|
2102
|
-
cells.push(e(Column, { key: "
|
|
2563
|
+
cells.push(e(Column, { key: "review", ...columnProps(columns, "review"), color: review.color }, review.label));
|
|
2103
2564
|
if (!compact) {
|
|
2104
2565
|
cells.push(
|
|
2105
|
-
e(Column, { key: "
|
|
2566
|
+
e(Column, { key: "updated", ...columnProps(columns, "updated"), dim: true }, formatAge(new Date(item.updatedAt), now)),
|
|
2106
2567
|
);
|
|
2107
2568
|
}
|
|
2108
2569
|
return e(Box, { flexDirection: "row" }, ...cells);
|
|
@@ -2116,19 +2577,19 @@ function PRRow({ item, now, compact, cursor }) {
|
|
|
2116
2577
|
// text column states it outright; the width comes out of PACKAGE / FILE, whose
|
|
2117
2578
|
// contents were already being truncated.
|
|
2118
2579
|
const SECURITY_HEADER = [
|
|
2119
|
-
{ label: "", props: { width: 3 } },
|
|
2120
|
-
{ label: "SEV", props: { width: 4 } },
|
|
2121
|
-
{ label: "PACKAGE / FILE", props: { width: 16 } },
|
|
2122
|
-
{ label: "SUMMARY", props: { grow: true } },
|
|
2123
|
-
{ label: "AGE", props: { width: 8 } },
|
|
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 },
|
|
2124
2585
|
];
|
|
2125
2586
|
|
|
2126
2587
|
// SEV is the last thing to drop on this tab: it is the whole point of the pane
|
|
2127
2588
|
// and the only non-colour severity channel.
|
|
2128
2589
|
const SECURITY_HEADER_COMPACT = [
|
|
2129
|
-
{ label: "", props: { width: 3 } },
|
|
2130
|
-
{ label: "SEV", props: { width: 4 } },
|
|
2131
|
-
{ 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 } },
|
|
2132
2593
|
];
|
|
2133
2594
|
|
|
2134
2595
|
const SEVERITY_STYLE = {
|
|
@@ -2141,19 +2602,19 @@ const SEVERITY_STYLE = {
|
|
|
2141
2602
|
};
|
|
2142
2603
|
const SEVERITY_UNKNOWN = SEVERITY_STYLE.unknown;
|
|
2143
2604
|
|
|
2144
|
-
function SecurityRow({ item, now, compact, cursor }) {
|
|
2605
|
+
function SecurityRow({ item, now, compact, cursor, columns }) {
|
|
2145
2606
|
const sev = pick(SEVERITY_STYLE, item.severity, SEVERITY_UNKNOWN);
|
|
2146
2607
|
const cells = [
|
|
2147
|
-
e(Column, { key: "
|
|
2148
|
-
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),
|
|
2149
2610
|
];
|
|
2150
2611
|
if (!compact) {
|
|
2151
|
-
cells.push(e(Column, { key: "
|
|
2612
|
+
cells.push(e(Column, { key: "package", ...columnProps(columns, "package"), color: IDENTIFIER }, item.detail || item.kind));
|
|
2152
2613
|
}
|
|
2153
|
-
cells.push(e(Column, { key: "
|
|
2614
|
+
cells.push(e(Column, { key: "summary", ...columnProps(columns, "summary") }, item.title));
|
|
2154
2615
|
if (!compact) {
|
|
2155
2616
|
cells.push(
|
|
2156
|
-
e(Column, { key: "
|
|
2617
|
+
e(Column, { key: "age", ...columnProps(columns, "age"), dim: true }, formatAge(new Date(item.createdAt), now)),
|
|
2157
2618
|
);
|
|
2158
2619
|
}
|
|
2159
2620
|
return e(Box, { flexDirection: "row" }, ...cells);
|
|
@@ -2214,6 +2675,181 @@ const TABS = [
|
|
|
2214
2675
|
},
|
|
2215
2676
|
];
|
|
2216
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
|
+
|
|
2217
2853
|
// The narrowest width each tab's table can render without its fixed columns
|
|
2218
2854
|
// overflowing the frame. Derived from the header descriptors rather than
|
|
2219
2855
|
// hard-coded, so adding or resizing a column cannot silently invalidate it.
|
|
@@ -2226,6 +2862,227 @@ function minimumWidthFor(header) {
|
|
|
2226
2862
|
// + 2 border verticals, + 2 paddingX, + at least 4 columns for TITLE.
|
|
2227
2863
|
return fixed + 8;
|
|
2228
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
|
+
|
|
2229
3086
|
// Below this the full column set cannot render without its fixed columns
|
|
2230
3087
|
// overflowing the frame -- rows hard-wrap and ink switches to clearing and
|
|
2231
3088
|
// repainting the whole screen every frame. Worse, in the band just above the
|
|
@@ -2314,6 +3171,7 @@ const KEY_HINTS = [
|
|
|
2314
3171
|
{ label: "Move", keys: "↑↓" },
|
|
2315
3172
|
{ label: "Open", keys: "Ent" },
|
|
2316
3173
|
{ label: "Refresh", keys: "r" },
|
|
3174
|
+
{ label: "Width", keys: "w" },
|
|
2317
3175
|
{ label: "Quit", keys: "q" },
|
|
2318
3176
|
];
|
|
2319
3177
|
|
|
@@ -2340,6 +3198,26 @@ const REMOTE_SETUP_NONINTERACTIVE_LINES = [
|
|
|
2340
3198
|
// finishes. Wide enough for the spinner, a space and "Fetching".
|
|
2341
3199
|
const FETCHING_WIDTH = 12;
|
|
2342
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
|
+
|
|
2343
3221
|
// Compact drops the labels and keeps the keys, separated by a space:
|
|
2344
3222
|
// "↑↓ Ent r q". No constant for its width -- nothing branches on it, and the
|
|
2345
3223
|
// keys are short enough that it fits anywhere the frame itself does.
|
|
@@ -2356,7 +3234,37 @@ const FETCHING_WIDTH = 12;
|
|
|
2356
3234
|
// finding at a glance, so they get the accent colour and the words describing
|
|
2357
3235
|
// them stay dim. The accent is the panel-title cyan rather than the amber used
|
|
2358
3236
|
// for in-progress status, so amber means exactly one thing across the product.
|
|
2359
|
-
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
|
+
}
|
|
2360
3268
|
// Without raw mode none of the key handlers run, so advertising them would be
|
|
2361
3269
|
// telling the user something untrue about what the app can do. Ctrl+C still
|
|
2362
3270
|
// works there, because the tty delivers a real SIGINT.
|
|
@@ -2393,6 +3301,12 @@ function StatusBar({ fetching, spin, stale, interactive, cols, remoteSetup = fal
|
|
|
2393
3301
|
stale
|
|
2394
3302
|
? e(Box, { marginRight: 1, flexShrink: 0 }, e(Text, { color: ATTENTION }, stale))
|
|
2395
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,
|
|
2396
3310
|
...hints
|
|
2397
3311
|
.flatMap((hint, i) => [
|
|
2398
3312
|
i > 0 &&
|
|
@@ -2462,8 +3376,39 @@ function useTerminalSize(stdout) {
|
|
|
2462
3376
|
function App({ onCreateRemote = () => {} } = {}) {
|
|
2463
3377
|
const { stdout } = useStdout();
|
|
2464
3378
|
const { isRawModeSupported } = useStdin();
|
|
2465
|
-
const { exit } = useApp();
|
|
3379
|
+
const { exit, suspendTerminal } = useApp();
|
|
2466
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]);
|
|
2467
3412
|
// `null` means "never resolved" -- distinct from `[]`, which means "resolved
|
|
2468
3413
|
// and genuinely empty". The tab bar and the body render those differently.
|
|
2469
3414
|
const [data, setData] = useState({ actions: null, issues: null, prs: null, security: null });
|
|
@@ -2487,6 +3432,9 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
2487
3432
|
const [errors, setErrors] = useState({ actions: null, issues: null, prs: null, security: null });
|
|
2488
3433
|
const [failureContext, setFailureContext] = useState(null);
|
|
2489
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);
|
|
2490
3438
|
const [now, setNow] = useState(new Date());
|
|
2491
3439
|
const { rows, cols, useShortLabels } = useTerminalSize(stdout);
|
|
2492
3440
|
const [frame, setFrame] = useState(0);
|
|
@@ -2495,6 +3443,8 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
2495
3443
|
// renders byte-identical frames and ink still writes nothing.
|
|
2496
3444
|
const [selected, setSelected] = useState({});
|
|
2497
3445
|
const [offset, setOffset] = useState({});
|
|
3446
|
+
const [widthMode, setWidthMode] = useState(false);
|
|
3447
|
+
const [selectedWidthKeyByTab, setSelectedWidthKeyByTab] = useState({});
|
|
2498
3448
|
|
|
2499
3449
|
const tab = TABS[activeIndex];
|
|
2500
3450
|
const tabError = errors[tab.key];
|
|
@@ -2590,6 +3540,185 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
2590
3540
|
showHelpRef.current = showHelp;
|
|
2591
3541
|
const remoteSetupRef = useRef(false);
|
|
2592
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
|
+
}
|
|
2593
3722
|
|
|
2594
3723
|
function moveSelection(delta) {
|
|
2595
3724
|
const { items, key: currentKey, bodyRows: rows_, tabKey, offset: offsetRaw } = navRef.current;
|
|
@@ -2665,7 +3794,38 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
2665
3794
|
|
|
2666
3795
|
useInput(
|
|
2667
3796
|
(input, key) => {
|
|
2668
|
-
|
|
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();
|
|
2669
3829
|
exit();
|
|
2670
3830
|
} else if (showHelpRef.current) {
|
|
2671
3831
|
// Any key dismisses -- except quit, handled above, which must never be
|
|
@@ -2675,6 +3835,8 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
2675
3835
|
setShowHelp(false);
|
|
2676
3836
|
} else if (input === "?") {
|
|
2677
3837
|
setShowHelp(true);
|
|
3838
|
+
} else if (input === "w") {
|
|
3839
|
+
enterWidthMode();
|
|
2678
3840
|
} else if (key.downArrow || input === "j") {
|
|
2679
3841
|
moveSelection(1);
|
|
2680
3842
|
} else if (key.upArrow || input === "k") {
|
|
@@ -2684,7 +3846,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
2684
3846
|
} else if (key.pageUp) {
|
|
2685
3847
|
moveSelection(-pageStep);
|
|
2686
3848
|
} else if (key.return) {
|
|
2687
|
-
if (remoteSetupRef.current) onCreateRemote();
|
|
3849
|
+
if (remoteSetupRef.current) onCreateRemote(suspendTerminal);
|
|
2688
3850
|
else openSelected();
|
|
2689
3851
|
} else if (input === "r") {
|
|
2690
3852
|
// Goes through the same per-tab in-flight guard as the poll loop, so
|
|
@@ -2723,6 +3885,13 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
2723
3885
|
// ref or a setState function, both of which the rule treats as stable -- so
|
|
2724
3886
|
// this needs no suppression, and if one ever becomes necessary that is the
|
|
2725
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.
|
|
2726
3895
|
useEffect(() => {
|
|
2727
3896
|
let cancelled = false;
|
|
2728
3897
|
let ticks = 0;
|
|
@@ -2759,6 +3928,10 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
2759
3928
|
setLoading((l) => (l[key] ? l : { ...l, [key]: true }));
|
|
2760
3929
|
return run()
|
|
2761
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;
|
|
2762
3935
|
if (cancelled) return;
|
|
2763
3936
|
const { raw, parse, limit } = result;
|
|
2764
3937
|
// Identical payload: skip the parse *and* the state update. Returning
|
|
@@ -2808,6 +3981,11 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
2808
3981
|
}
|
|
2809
3982
|
})
|
|
2810
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;
|
|
2811
3989
|
if (cancelled || err?.name === "AbortError") return;
|
|
2812
3990
|
cleanRef.current[key] = false;
|
|
2813
3991
|
// Preserve both the verdict and the bounded raw error in state. The
|
|
@@ -2838,7 +4016,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
2838
4016
|
// exactly the moment the pane looks broken and they reach for it.
|
|
2839
4017
|
function fetchTab(key, { force = false } = {}) {
|
|
2840
4018
|
const signal = controller.signal;
|
|
2841
|
-
const descriptor =
|
|
4019
|
+
const descriptor = tabForKey(key);
|
|
2842
4020
|
if (!force && backoffActive(`tab:${key}`, performance.now())) return Promise.resolve();
|
|
2843
4021
|
if (force) clearBackoff(`tab:${key}`);
|
|
2844
4022
|
// Every tab carries its own fetcher on the registry, so adding a tab is a
|
|
@@ -2854,6 +4032,10 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
2854
4032
|
// so stop spawning subprocesses once any endpoint proves there is no
|
|
2855
4033
|
// remote. The interval may still wake, but this branch is otherwise idle.
|
|
2856
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);
|
|
2857
4039
|
// Only the tab you're looking at needs to keep up with REFRESH_MS. The
|
|
2858
4040
|
// other three exist to keep the tab-bar counts honest, which tolerates
|
|
2859
4041
|
// being a minute behind -- so they refresh every BACKGROUND_EVERY ticks
|
|
@@ -2874,11 +4056,64 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
2874
4056
|
);
|
|
2875
4057
|
}
|
|
2876
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
|
+
|
|
2877
4112
|
tick();
|
|
2878
|
-
|
|
4113
|
+
rearm(runtime.refreshMs);
|
|
2879
4114
|
return () => {
|
|
2880
4115
|
cancelled = true;
|
|
2881
|
-
clearInterval(
|
|
4116
|
+
clearInterval(intervalId);
|
|
2882
4117
|
if (contextCoordinatorRef.current === coordinator) contextCoordinatorRef.current = null;
|
|
2883
4118
|
// `cancelled` stops state updates from a promise that already resolved;
|
|
2884
4119
|
// the signal stops the subprocess itself, so quitting doesn't orphan up to
|
|
@@ -3062,8 +4297,14 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
3062
4297
|
// the cost -- different tabs showing different column counts at the same width
|
|
3063
4298
|
// -- is not something you can actually see. MIN_TABLE_WIDTH stays as the
|
|
3064
4299
|
// exported worst case, which is what the tests pin.
|
|
3065
|
-
const
|
|
3066
|
-
|
|
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;
|
|
3067
4308
|
|
|
3068
4309
|
// Below the compact set's own floor even the fixed columns overflow, which
|
|
3069
4310
|
// hard-wraps every row and drives ink into clearing and repainting the whole
|
|
@@ -3072,6 +4313,50 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
3072
4313
|
// sits after usableSize(), which substitutes DEFAULT_COLS for a 0 reported
|
|
3073
4314
|
// mid-resize, so a transient zero cannot be mistaken for a genuinely tiny pane.
|
|
3074
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
|
+
]);
|
|
3075
4360
|
|
|
3076
4361
|
return e(
|
|
3077
4362
|
Box,
|
|
@@ -3148,7 +4433,14 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
3148
4433
|
line,
|
|
3149
4434
|
),
|
|
3150
4435
|
),
|
|
3151
|
-
!showHelp &&
|
|
4436
|
+
!showHelp &&
|
|
4437
|
+
!tooNarrow &&
|
|
4438
|
+
!remoteSetup &&
|
|
4439
|
+
e(MemoHeaderCells, {
|
|
4440
|
+
cells: header,
|
|
4441
|
+
selectedWidthKey: resizeRef.current.active ? selectedWidthKey : null,
|
|
4442
|
+
headerRef,
|
|
4443
|
+
}),
|
|
3152
4444
|
...(tooNarrow || showHelp || remoteSetup ? [] : visibleItems).map((item) => {
|
|
3153
4445
|
const key = itemKey(item);
|
|
3154
4446
|
return e(
|
|
@@ -3158,6 +4450,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
3158
4450
|
item,
|
|
3159
4451
|
now,
|
|
3160
4452
|
compact,
|
|
4453
|
+
columns: header,
|
|
3161
4454
|
cursor: key === selectedKey,
|
|
3162
4455
|
// Only an *executing* Actions row reads this. It used to go to every
|
|
3163
4456
|
// visible row on the tab, so a prop that changes several times a
|
|
@@ -3208,9 +4501,13 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
3208
4501
|
fetching: Object.values(loading).some(Boolean),
|
|
3209
4502
|
spin: showSpinner ? spin : null,
|
|
3210
4503
|
stale: staleLabel,
|
|
4504
|
+
throttle: throttleMs ? `throttled ${Math.round(throttleMs / 1000)}s` : null,
|
|
3211
4505
|
interactive,
|
|
3212
4506
|
cols: frameCols,
|
|
3213
4507
|
remoteSetup,
|
|
4508
|
+
widthMode: resizeRef.current.active,
|
|
4509
|
+
widthColumn: selectedWidthColumn,
|
|
4510
|
+
widthSaveError,
|
|
3214
4511
|
}),
|
|
3215
4512
|
);
|
|
3216
4513
|
}
|
|
@@ -3224,16 +4521,58 @@ function enterAlternateScreen() {
|
|
|
3224
4521
|
process.stdout.write("\x1b[?1049h\x1b[2J\x1b[H");
|
|
3225
4522
|
}
|
|
3226
4523
|
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
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.
|
|
3233
4574
|
function restoreScreen() {
|
|
3234
|
-
|
|
3235
|
-
screenRestored = true;
|
|
3236
|
-
process.stdout.write("\x1b[?25h\x1b[?1049l");
|
|
4575
|
+
return terminalLifecycle.restoreScreen();
|
|
3237
4576
|
}
|
|
3238
4577
|
|
|
3239
4578
|
// A crash used to be indistinguishable from a clean quit. Ink catches render
|
|
@@ -3242,18 +4581,18 @@ function restoreScreen() {
|
|
|
3242
4581
|
// simply vanished and any wrapper saw success. Restore the primary buffer
|
|
3243
4582
|
// *first*, then write, or the fix reproduces the problem it is fixing.
|
|
3244
4583
|
//
|
|
3245
|
-
//
|
|
3246
|
-
//
|
|
3247
|
-
//
|
|
3248
|
-
//
|
|
3249
|
-
//
|
|
3250
|
-
// 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.
|
|
3251
4589
|
//
|
|
3252
4590
|
// Both messages go through redact(): a stack can carry a URL with inline
|
|
3253
4591
|
// credentials, and this output is what a user pastes into a bug report -- the
|
|
3254
4592
|
// same reasoning --doctor already applies to its own report.
|
|
3255
|
-
function installCrashHandlers() {
|
|
4593
|
+
function installCrashHandlers(unmountApp) {
|
|
3256
4594
|
const fail = (label) => (err) => {
|
|
4595
|
+
unmountApp();
|
|
3257
4596
|
restoreScreen();
|
|
3258
4597
|
try {
|
|
3259
4598
|
abortLiveRequests();
|
|
@@ -3269,8 +4608,16 @@ function installCrashHandlers() {
|
|
|
3269
4608
|
}
|
|
3270
4609
|
|
|
3271
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
|
+
};
|
|
3272
4619
|
disarmDevBuildLeak();
|
|
3273
|
-
installCrashHandlers();
|
|
4620
|
+
installCrashHandlers(unmountApp);
|
|
3274
4621
|
enterAlternateScreen();
|
|
3275
4622
|
process.on("exit", restoreScreen);
|
|
3276
4623
|
|
|
@@ -3284,40 +4631,71 @@ if (IS_MAIN) {
|
|
|
3284
4631
|
// root box is the terminal height. Windows is already documented as untested
|
|
3285
4632
|
// (README Limitations), and the pty harness covers the two platforms that are
|
|
3286
4633
|
// supported, so the flag is verified where it is claimed to work.
|
|
3287
|
-
let app;
|
|
3288
4634
|
let remoteSetupStarted = false;
|
|
3289
|
-
const createRemote = () => {
|
|
4635
|
+
const createRemote = (suspendTerminal) => {
|
|
3290
4636
|
if (remoteSetupStarted) return;
|
|
3291
4637
|
remoteSetupStarted = true;
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
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
|
+
});
|
|
3321
4699
|
};
|
|
3322
4700
|
app = render(e(App, { onCreateRemote: createRemote }), { incrementalRendering: true });
|
|
3323
4701
|
|
|
@@ -3347,12 +4725,7 @@ if (IS_MAIN) {
|
|
|
3347
4725
|
if (signalExitStarted) return;
|
|
3348
4726
|
signalExitStarted = true;
|
|
3349
4727
|
const finish = () => {
|
|
3350
|
-
|
|
3351
|
-
app.unmount();
|
|
3352
|
-
} catch {
|
|
3353
|
-
// Teardown is best effort. restoreScreen below, and the `exit` listener
|
|
3354
|
-
// above, both still run, so the terminal is handed back either way.
|
|
3355
|
-
}
|
|
4728
|
+
unmountApp();
|
|
3356
4729
|
restoreScreen();
|
|
3357
4730
|
process.exit(code);
|
|
3358
4731
|
};
|
|
@@ -3387,6 +4760,22 @@ export {
|
|
|
3387
4760
|
MIN_REFRESH_SECONDS,
|
|
3388
4761
|
MAX_REFRESH_SECONDS,
|
|
3389
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,
|
|
3390
4779
|
safe,
|
|
3391
4780
|
shortErr,
|
|
3392
4781
|
isUnavailable,
|
|
@@ -3411,17 +4800,44 @@ export {
|
|
|
3411
4800
|
usableSize,
|
|
3412
4801
|
severityRank,
|
|
3413
4802
|
pick,
|
|
4803
|
+
columnProps,
|
|
4804
|
+
adjustableWidthKeys,
|
|
4805
|
+
selectWidthKey,
|
|
4806
|
+
cycleWidthKey,
|
|
4807
|
+
resolveHeader,
|
|
4808
|
+
fitHeaderToFrame,
|
|
4809
|
+
adjustWidth,
|
|
4810
|
+
updateWidthPreference,
|
|
4811
|
+
resetWidthPreference,
|
|
4812
|
+
resetTabWidthPreferences,
|
|
3414
4813
|
minimumWidthFor,
|
|
4814
|
+
WIDTH_PREFERENCES_VERSION,
|
|
4815
|
+
widthPreferencesPath,
|
|
4816
|
+
parseWidthPreferences,
|
|
4817
|
+
serializeWidthPreferences,
|
|
4818
|
+
loadWidthPreferences,
|
|
4819
|
+
saveWidthPreferences,
|
|
4820
|
+
createWidthPreferenceWriter,
|
|
3415
4821
|
runStatusIcon,
|
|
3416
4822
|
RUN_STATUS_ICON,
|
|
3417
4823
|
SEVERITY_STYLE,
|
|
3418
4824
|
REVIEW_LABEL,
|
|
3419
4825
|
MIN_TABLE_WIDTH,
|
|
3420
4826
|
MIN_COMPACT_WIDTH,
|
|
4827
|
+
TABS,
|
|
3421
4828
|
OCT_NERD,
|
|
3422
4829
|
OCT_UNICODE,
|
|
3423
4830
|
KEY_TABLE,
|
|
3424
4831
|
KEY_HINTS,
|
|
4832
|
+
widthStatusText,
|
|
4833
|
+
headerGutterKey,
|
|
4834
|
+
HeaderCells,
|
|
4835
|
+
parseSgrMouse,
|
|
4836
|
+
dividerHandles,
|
|
4837
|
+
hitDivider,
|
|
4838
|
+
beginDividerDrag,
|
|
4839
|
+
draggedWidth,
|
|
4840
|
+
createTerminalLifecycle,
|
|
3425
4841
|
REMOTE_SETUP_HINTS,
|
|
3426
4842
|
REMOTE_SETUP_LINES,
|
|
3427
4843
|
REMOTE_SETUP_NONINTERACTIVE_LINES,
|