gh-glance 0.4.1 → 0.5.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 +188 -0
- package/README.md +85 -39
- package/index.mjs +606 -112
- package/package.json +2 -1
package/index.mjs
CHANGED
|
@@ -74,7 +74,15 @@ const LIST_LIMIT = 150;
|
|
|
74
74
|
// page. The previous --paginate walked the repo's entire alert history --
|
|
75
75
|
// mostly closed alerts -- and then discarded them client-side.
|
|
76
76
|
const ALERT_PER_PAGE = 100;
|
|
77
|
-
|
|
77
|
+
// Ordering is pinned rather than left to each endpoint's default. Severity
|
|
78
|
+
// sorting happens client-side over whatever this returns, so on a repo with more
|
|
79
|
+
// than a page of open alerts the *page boundary* decides what can be ranked at
|
|
80
|
+
// all -- a critical alert sitting 150th by some endpoint's arbitrary default was
|
|
81
|
+
// simply never fetched, and the pane showed "100+" with a screen of moderates.
|
|
82
|
+
// Newest-first at least makes the cut deterministic and explainable, and it
|
|
83
|
+
// survives GitHub changing a default underneath us. All three endpoints accept
|
|
84
|
+
// these parameters.
|
|
85
|
+
const ALERT_QUERY = `?state=open&per_page=${ALERT_PER_PAGE}&sort=created&direction=desc`;
|
|
78
86
|
|
|
79
87
|
// Inactive tabs only feed the tab-bar counts, so they refresh every Nth tick
|
|
80
88
|
// rather than every tick. Raised from 4 to 12 (a 60s cycle at the default
|
|
@@ -116,6 +124,16 @@ const BACKOFF_STEPS_MS = [60_000, 300_000, 1_800_000, 3_600_000];
|
|
|
116
124
|
// night costs -- two probes a minute per endpoint rather than one per tick.
|
|
117
125
|
const AUTH_RETRY_MS = [30_000];
|
|
118
126
|
|
|
127
|
+
// A rate limit is the third shape: it clears on GitHub's schedule, usually
|
|
128
|
+
// within the hour, and continuing to hammer is actively counterproductive --
|
|
129
|
+
// the secondary limiter keys on sustained request rate against a token that is
|
|
130
|
+
// already limited, so re-asking every tick can turn a self-clearing primary
|
|
131
|
+
// limit into a longer block, and the block applies to the *token*, not to this
|
|
132
|
+
// tool. A wedged pane could therefore degrade `git push` and everything else.
|
|
133
|
+
// One minute, flat: long enough to stop amplifying, short enough that the pane
|
|
134
|
+
// is current again well before a reset the user never notices.
|
|
135
|
+
const RATE_LIMIT_RETRY_MS = [60_000];
|
|
136
|
+
|
|
119
137
|
// Past this, the active tab's data is old enough to say so. Deliberately
|
|
120
138
|
// coarse: `now` only advances on minute boundaries when nothing is in progress,
|
|
121
139
|
// so a minute-granular staleness label costs zero extra redraws, whereas a
|
|
@@ -149,7 +167,14 @@ const TAB_KEYS = ["actions", "issues", "prs", "security"];
|
|
|
149
167
|
// dots per frame, reading as a filled blob that matches the circle icons'
|
|
150
168
|
// visual weight while staying in the same width-1 braille block.
|
|
151
169
|
const SPINNER = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"];
|
|
152
|
-
|
|
170
|
+
// 200ms, not 100: the spinner is the single largest CPU term in the app, because
|
|
171
|
+
// every frame makes ink rebuild and diff the whole output string. Measured on a
|
|
172
|
+
// 40-row pane: 100ms costs 7.8% of a core and 9.8 MB/hr of terminal writes,
|
|
173
|
+
// 200ms costs 3.9% and 5.2 MB/hr, against a 0.33% idle floor. The motion is
|
|
174
|
+
// load-bearing -- it is the only thing separating an executing run from a queued
|
|
175
|
+
// one (see RUN_RUNNING_STATIC) -- so it may be slowed but never stopped. Eight
|
|
176
|
+
// frames at 200ms is a 1.6s cycle, still well inside one refresh.
|
|
177
|
+
const SPINNER_MS = 200;
|
|
153
178
|
|
|
154
179
|
// Motion opt-out. This pane is designed to sit in peripheral vision for hours,
|
|
155
180
|
// which is exactly where repetitive motion is most costly for users with
|
|
@@ -176,15 +201,27 @@ const ANIMATE = !process.env.GH_GLANCE_NO_ANIMATION;
|
|
|
176
201
|
// on the composed output stream, which would strip ink's own SGR codes, the
|
|
177
202
|
// box-drawing in PanelEdge, and the alternate-screen escapes, i.e. the whole UI.
|
|
178
203
|
//
|
|
179
|
-
// Strip C0, DEL and
|
|
204
|
+
// Strip C0, DEL, C1 -- and the explicit bidi overrides/isolates. Emoji (including ZWJ sequences and variation
|
|
180
205
|
// selectors), CJK and other wide characters, combining marks and RTL text must
|
|
181
206
|
// survive untouched -- ink's width arithmetic depends on measuring them
|
|
182
207
|
// correctly, and the app's own Nerd Font glyphs live in the private use area, so
|
|
183
208
|
// anything shaped like "strip non-ASCII" would erase every status icon on
|
|
184
209
|
// screen. Control runs collapse to a single space rather than being deleted, so
|
|
185
210
|
// "a\nb" reads as "a b" rather than "ab".
|
|
211
|
+
//
|
|
212
|
+
// U+202A-U+202E and U+2066-U+2069 (LRE/RLE/PDF/LRO/RLO and the isolates) are
|
|
213
|
+
// stripped in a *separate* pass because they must be deleted rather than
|
|
214
|
+
// collapsed to a space: ink measures them as zero columns, so replacing one with
|
|
215
|
+
// a space would add a visible column and shift every cell to its right -- the
|
|
216
|
+
// exact desync this file works to avoid. Without this, one RLO in an issue title
|
|
217
|
+
// makes the rest of that cell render reversed on any terminal with bidi
|
|
218
|
+
// reordering, so the row displays something other than its data. Deliberately
|
|
219
|
+
// NOT extended to U+200E/U+200F or to general category Cf: those are how
|
|
220
|
+
// legitimate mixed-direction Arabic and Hebrew titles render correctly, and
|
|
221
|
+
// preserving real RTL text is a stated property of this function.
|
|
186
222
|
// eslint-disable-next-line no-control-regex -- matching control characters is the entire purpose
|
|
187
223
|
const CONTROL_CHARS = /[--]+/g;
|
|
224
|
+
const BIDI_OVERRIDES = /[--]/g;
|
|
188
225
|
|
|
189
226
|
// Titles are rendered at whatever length GitHub returns, and ink memoizes
|
|
190
227
|
// wrapped text in a module-level cache it never evicts -- so unbounded remote
|
|
@@ -196,8 +233,14 @@ const CONTROL_CHARS = /[--]+/g;
|
|
|
196
233
|
const MAX_FIELD_LENGTH = 300;
|
|
197
234
|
|
|
198
235
|
function safe(value) {
|
|
199
|
-
|
|
200
|
-
|
|
236
|
+
// Coerce first, sanitize always. Returning a non-string early skipped both the
|
|
237
|
+
// control-character strip and the length clamp, so an array or an object with a
|
|
238
|
+
// toString() came through untouched -- `safe(["[2Jx"])` returned the escape
|
|
239
|
+
// verbatim. GitHub returns strings for every field this is called on today, so
|
|
240
|
+
// the guarantee was being provided by the upstream schema rather than by the
|
|
241
|
+
// function that claims to provide it.
|
|
242
|
+
const value_ = typeof value === "string" ? value : value == null ? "" : String(value);
|
|
243
|
+
const cleaned = value_.replace(BIDI_OVERRIDES, "").replace(CONTROL_CHARS, " ").trim();
|
|
201
244
|
const points = Array.from(cleaned);
|
|
202
245
|
return points.length > MAX_FIELD_LENGTH ? points.slice(0, MAX_FIELD_LENGTH).join("") : cleaned;
|
|
203
246
|
}
|
|
@@ -326,6 +369,26 @@ function classify(err) {
|
|
|
326
369
|
return "other";
|
|
327
370
|
}
|
|
328
371
|
|
|
372
|
+
// What to put on screen for a verdict, in the voice the preflight already uses:
|
|
373
|
+
// say what to do, not what the subprocess printed. The three tabs built on list
|
|
374
|
+
// commands used to render raw `gh` stderr, so the failures people actually hit --
|
|
375
|
+
// expired auth, a rate limit, a dropped network -- arrived as an untranslated
|
|
376
|
+
// fragment of somebody else's CLI with no statement of what to do about it,
|
|
377
|
+
// while the alert path one tab over had been classifying and translating all
|
|
378
|
+
// along.
|
|
379
|
+
//
|
|
380
|
+
// `other` is deliberately absent. It is the unclassified bucket, and the raw
|
|
381
|
+
// message is the most useful thing available for it -- inventing a remedy for a
|
|
382
|
+
// failure nobody recognised would be worse than showing what happened. That
|
|
383
|
+
// matters more than it looks: AUTH_MARKERS is deliberately broad, and a false
|
|
384
|
+
// positive that only picked a retry ladder was cheap, whereas one that also
|
|
385
|
+
// rewrites the on-screen text turns into a confidently wrong instruction.
|
|
386
|
+
const VERDICT_REMEDY = {
|
|
387
|
+
"auth-problem": "GitHub authorization failed -- try `gh auth login` or `gh auth refresh`",
|
|
388
|
+
"rate-limited": "GitHub rate limit reached -- backing off, this clears on its own",
|
|
389
|
+
unavailable: "not available for this repository",
|
|
390
|
+
};
|
|
391
|
+
|
|
329
392
|
// Some pty wrappers (and a terminal mid-resize) report a size of 0 or
|
|
330
393
|
// undefined. Taking that literally collapses the table to a single row (or
|
|
331
394
|
// draws a zero-width border), so fall back to sane defaults until real
|
|
@@ -358,19 +421,54 @@ const GH_ENV_OVERRIDES = {
|
|
|
358
421
|
GH_PAGER: "cat",
|
|
359
422
|
};
|
|
360
423
|
|
|
424
|
+
// The single redaction boundary for everything this process prints outside the
|
|
425
|
+
// dashboard: the --verbose log, the crash handler's stack, and the --doctor
|
|
426
|
+
// report. Declared here, above all three, rather than beside runDoctor() -- it
|
|
427
|
+
// used to sit in the Diagnostics section and be reached backwards by hoisting.
|
|
428
|
+
//
|
|
429
|
+
// Token shapes and URL userinfo only. gh error messages quote the URL they
|
|
430
|
+
// failed on, so that is a real path for a credential to arrive in output a user
|
|
431
|
+
// is invited to paste into a bug report.
|
|
432
|
+
function redact(text) {
|
|
433
|
+
return String(text)
|
|
434
|
+
.replace(/gh[pousr]_[A-Za-z0-9]{16,}/g, "<redacted-token>")
|
|
435
|
+
.replace(/github_pat_[A-Za-z0-9_]{16,}/g, "<redacted-token>")
|
|
436
|
+
.replace(/\/\/[^/\s:@]+:[^/\s@]+@/g, "//<redacted>@");
|
|
437
|
+
}
|
|
438
|
+
|
|
361
439
|
// Verbose output goes to stderr and never to stdout: stdout is ink's frame
|
|
362
440
|
// stream, and writing anything else into it corrupts the diff and the
|
|
363
441
|
// alternate-screen state. The argv block refuses --verbose while stderr is still
|
|
364
442
|
// a terminal, so these lines always land in a file rather than on top of the
|
|
365
443
|
// dashboard.
|
|
444
|
+
//
|
|
445
|
+
// The outcome is redacted for the same reason --doctor redacts its report: on a
|
|
446
|
+
// failure it carries gh's own stderr, and gh error messages quote the URL they
|
|
447
|
+
// failed on -- which is a real path for a token or a proxy credential to arrive
|
|
448
|
+
// here. The README tells users to run `--verbose 2>gh-glance.log` and attach the
|
|
449
|
+
// result to a bug report, so this is one of the three artifacts that leave the
|
|
450
|
+
// machine, and it was the only one with no redaction boundary.
|
|
366
451
|
function logGh(args, startedAt, outcome) {
|
|
367
452
|
if (!runtime.verbose) return;
|
|
368
453
|
const ms = Date.now() - startedAt;
|
|
369
454
|
process.stderr.write(
|
|
370
|
-
`${new Date().toISOString()} gh ${args.join(" ")} -- ${outcome} in ${ms}ms\n`,
|
|
455
|
+
`${new Date().toISOString()} gh ${args.join(" ")} -- ${redact(outcome)} in ${ms}ms\n`,
|
|
371
456
|
);
|
|
372
457
|
}
|
|
373
458
|
|
|
459
|
+
// The poll loop's AbortController, published here so the crash handlers can
|
|
460
|
+
// reach it. A module-level handle rather than a ref because the crash path runs
|
|
461
|
+
// outside React entirely -- there is no component left to read a ref from. Kept
|
|
462
|
+
// in this section, above every consumer, so nothing has to reach downward for it.
|
|
463
|
+
let liveAbort = null;
|
|
464
|
+
function registerLiveAbort(controller) {
|
|
465
|
+
liveAbort = controller;
|
|
466
|
+
}
|
|
467
|
+
function abortLiveRequests() {
|
|
468
|
+
liveAbort?.abort();
|
|
469
|
+
liveAbort = null;
|
|
470
|
+
}
|
|
471
|
+
|
|
374
472
|
async function runGh(args, { signal } = {}) {
|
|
375
473
|
const startedAt = Date.now();
|
|
376
474
|
try {
|
|
@@ -611,11 +709,15 @@ function backoffActive(key, now) {
|
|
|
611
709
|
return Boolean(state) && now < state.until;
|
|
612
710
|
}
|
|
613
711
|
|
|
614
|
-
// Which ladder each verdict takes. "
|
|
615
|
-
//
|
|
616
|
-
//
|
|
617
|
-
//
|
|
618
|
-
const FAILURE_LADDER = {
|
|
712
|
+
// Which ladder each verdict takes. "other" stays absent on purpose: a network
|
|
713
|
+
// drop clears when the network does, and re-asking every tick is how the pane
|
|
714
|
+
// comes back the moment it does. "rate-limited" used to be absent for the same
|
|
715
|
+
// stated reason, which had it backwards -- see RATE_LIMIT_RETRY_MS.
|
|
716
|
+
const FAILURE_LADDER = {
|
|
717
|
+
unavailable: BACKOFF_STEPS_MS,
|
|
718
|
+
"auth-problem": AUTH_RETRY_MS,
|
|
719
|
+
"rate-limited": RATE_LIMIT_RETRY_MS,
|
|
720
|
+
};
|
|
619
721
|
|
|
620
722
|
// The ladder is a parameter rather than a second near-identical function,
|
|
621
723
|
// which is the drift the ALERT_SOURCES comment below warns about: two copies of
|
|
@@ -632,8 +734,11 @@ function clearBackoff(key) {
|
|
|
632
734
|
|
|
633
735
|
async function fetchAlertSource(source, signal, now) {
|
|
634
736
|
if (backoffActive(source.key, now)) {
|
|
635
|
-
const note = alertBackoff.get(source.key)
|
|
636
|
-
|
|
737
|
+
const { note, verdict } = alertBackoff.get(source.key);
|
|
738
|
+
// The verdict is replayed alongside the note. Replaying only the note left
|
|
739
|
+
// the tab unable to tell "Dependabot is switched off here" from "we cannot
|
|
740
|
+
// see Dependabot" for the whole length of a backoff window.
|
|
741
|
+
return { raw: `backoff:${note}`, parse: () => ({ alerts: [], note, verdict, truncated: false }) };
|
|
637
742
|
}
|
|
638
743
|
try {
|
|
639
744
|
const raw = await runGh(alertArgs(source), { signal });
|
|
@@ -645,6 +750,7 @@ async function fetchAlertSource(source, signal, now) {
|
|
|
645
750
|
return {
|
|
646
751
|
alerts: rows.map(source.map),
|
|
647
752
|
note: null,
|
|
753
|
+
verdict: "ok",
|
|
648
754
|
truncated: rows.length >= ALERT_PER_PAGE,
|
|
649
755
|
};
|
|
650
756
|
},
|
|
@@ -659,9 +765,12 @@ async function fetchAlertSource(source, signal, now) {
|
|
|
659
765
|
const steps = pick(FAILURE_LADDER, verdict, null);
|
|
660
766
|
if (steps) {
|
|
661
767
|
recordFailure(source.key, now, steps);
|
|
662
|
-
alertBackoff.get(source.key)
|
|
768
|
+
Object.assign(alertBackoff.get(source.key), { note, verdict });
|
|
663
769
|
}
|
|
664
|
-
return {
|
|
770
|
+
return {
|
|
771
|
+
raw: `unavailable:${note}`,
|
|
772
|
+
parse: () => ({ alerts: [], note, verdict, truncated: false }),
|
|
773
|
+
};
|
|
665
774
|
}
|
|
666
775
|
}
|
|
667
776
|
|
|
@@ -678,7 +787,14 @@ function severityRank(severity) {
|
|
|
678
787
|
}
|
|
679
788
|
|
|
680
789
|
async function fetchSecurity(signal) {
|
|
681
|
-
|
|
790
|
+
// Monotonic, not wall-clock. These deadlines measure *elapsed* time, and
|
|
791
|
+
// Date.now() can jump: a laptop resume or an NTP correction stepping the clock
|
|
792
|
+
// backwards would hold an alert source in backoff for up to an hour of apparent
|
|
793
|
+
// time that never passed -- on a security surface, which is exactly what the
|
|
794
|
+
// capped ladder exists to prevent. Staleness deliberately stays on Date.now()
|
|
795
|
+
// for the mirror-image reason: a sleep gap is the thing it reports, and
|
|
796
|
+
// performance.now() does not advance across suspend.
|
|
797
|
+
const now = performance.now();
|
|
682
798
|
const parts = await Promise.all(ALERT_SOURCES.map((s) => fetchAlertSource(s, signal, now)));
|
|
683
799
|
return {
|
|
684
800
|
// Joined with a NUL so a change in any one of the three shows up as a
|
|
@@ -697,6 +813,15 @@ async function fetchSecurity(signal) {
|
|
|
697
813
|
return {
|
|
698
814
|
alerts,
|
|
699
815
|
notes: parsed.map((p) => p.note).filter(Boolean),
|
|
816
|
+
// "Blind" is not the same as "switched off", and the tab bar could not
|
|
817
|
+
// tell them apart: fetchAlertSource never rejects, so a repo whose three
|
|
818
|
+
// endpoints all 403'd on an expired SAML session rendered
|
|
819
|
+
// `4:Security (0)` -- byte-identical to a genuinely clean repo, on the
|
|
820
|
+
// one surface where a false all-clear is the worst possible answer.
|
|
821
|
+
// `unavailable` is excluded deliberately: that verdict IS an answer about
|
|
822
|
+
// the repository, and most repos have Advanced Security switched off, so
|
|
823
|
+
// treating it as blindness would mark almost everyone permanently unsure.
|
|
824
|
+
blind: parsed.some((p) => p.verdict != null && p.verdict !== "ok" && p.verdict !== "unavailable"),
|
|
700
825
|
truncated: parsed.some((p) => p.truncated),
|
|
701
826
|
};
|
|
702
827
|
},
|
|
@@ -779,19 +904,9 @@ async function preflight() {
|
|
|
779
904
|
// makes it a disclosure surface before it is anything else. So redaction is not
|
|
780
905
|
// a review note applied per call site -- it is applied once, at the single point
|
|
781
906
|
// where the report is assembled, the same way safe() is the one boundary for
|
|
782
|
-
// untrusted remote strings rather than six.
|
|
783
|
-
//
|
|
784
|
-
//
|
|
785
|
-
// github_pat_). URL userinfo covers a proxy variable, a git remote with an
|
|
786
|
-
// embedded credential, and anything else that carries `user:password@` -- gh
|
|
787
|
-
// error messages quote the URL they failed on, so that is a real path for one
|
|
788
|
-
// to arrive here.
|
|
789
|
-
function redact(text) {
|
|
790
|
-
return String(text)
|
|
791
|
-
.replace(/gh[pousr]_[A-Za-z0-9]{16,}/g, "<redacted-token>")
|
|
792
|
-
.replace(/github_pat_[A-Za-z0-9_]{16,}/g, "<redacted-token>")
|
|
793
|
-
.replace(/\/\/[^/\s:@]+:[^/\s@]+@/g, "//<redacted>@");
|
|
794
|
-
}
|
|
907
|
+
// untrusted remote strings rather than six. redact() itself now lives up in the
|
|
908
|
+
// gh subprocess section, because the --verbose log and the crash handler need it
|
|
909
|
+
// too and nothing should reach backwards for it.
|
|
795
910
|
|
|
796
911
|
// Values that are the thing being diagnosed, so they are printed as-is.
|
|
797
912
|
const DOCTOR_ENV_PLAIN = [
|
|
@@ -805,10 +920,9 @@ const DOCTOR_ENV_PLAIN = [
|
|
|
805
920
|
"NODE_ENV",
|
|
806
921
|
];
|
|
807
922
|
const DOCTOR_ENV_PROXY = ["HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY"];
|
|
808
|
-
//
|
|
809
|
-
//
|
|
810
|
-
//
|
|
811
|
-
const SECRET_NAME = /_(TOKEN|SECRET|PASSWORD|KEY)$/i;
|
|
923
|
+
// Listed so they always get a line even when unset -- the absence of GH_TOKEN is
|
|
924
|
+
// itself a diagnosis. Their values are never printed; see envValue(), where
|
|
925
|
+
// presence-only is the default for everything outside DOCTOR_ENV_PLAIN.
|
|
812
926
|
const DOCTOR_ENV_SECRET = ["GH_TOKEN", "GH_ENTERPRISE_TOKEN", "GITHUB_TOKEN"];
|
|
813
927
|
|
|
814
928
|
const NOT_SET = "not set";
|
|
@@ -839,12 +953,21 @@ function doctorEnvNames() {
|
|
|
839
953
|
return [...named, ...extra];
|
|
840
954
|
}
|
|
841
955
|
|
|
956
|
+
// Presence-only is the DEFAULT; printing a value is the opt-in, and the opt-in
|
|
957
|
+
// list is curated above. This was the other way round -- print unless the *name*
|
|
958
|
+
// ended in _TOKEN/_SECRET/_PASSWORD/_KEY -- which meant the discovery loop above
|
|
959
|
+
// harvested variables nobody had reviewed and printed them in full. Verified
|
|
960
|
+
// leaks under the old rule: GH_APP_PEM (a whole RSA private key), GITHUB_OAUTH,
|
|
961
|
+
// GITHUB_PAT, GH_COOKIE, GH_CREDENTIALS. redact() only catches GitHub *token*
|
|
962
|
+
// shapes, so anything else sailed through. This report is advertised as safe to
|
|
963
|
+
// paste into a bug report, so the failure mode has to be a less useful line, not
|
|
964
|
+
// a disclosed credential.
|
|
842
965
|
function envValue(name) {
|
|
843
966
|
const value = process.env[name];
|
|
844
|
-
if (SECRET_NAME.test(name)) return value ? "set" : NOT_SET;
|
|
845
967
|
if (!value) return NOT_SET;
|
|
846
968
|
if (DOCTOR_ENV_PROXY.includes(name)) return proxySummary(value);
|
|
847
|
-
return value;
|
|
969
|
+
if (DOCTOR_ENV_PLAIN.includes(name)) return value;
|
|
970
|
+
return "set";
|
|
848
971
|
}
|
|
849
972
|
|
|
850
973
|
// gh writes some of this to stdout and some to stderr depending on version and
|
|
@@ -916,6 +1039,58 @@ function targetSource() {
|
|
|
916
1039
|
return "git remote (or none, if this is not a checkout)";
|
|
917
1040
|
}
|
|
918
1041
|
|
|
1042
|
+
// How much of the hourly API budget is left, and roughly how fast this
|
|
1043
|
+
// configuration spends it. The steady-state cost is not small and was invisible:
|
|
1044
|
+
// at the default 5s refresh with the Security tab open it is around 2,200 REST
|
|
1045
|
+
// requests an hour -- about 44% of a personal token's 5,000 -- and `--refresh 2`
|
|
1046
|
+
// projects past the limit outright, so it exhausts inside the hour, every hour.
|
|
1047
|
+
// The budget is shared with everything else the token does, so the first symptom
|
|
1048
|
+
// is usually "GitHub is broken" somewhere else entirely.
|
|
1049
|
+
//
|
|
1050
|
+
// `gh api rate_limit` is documented as not counting against the limit, and it
|
|
1051
|
+
// measures as free (verified: delta 0), so this is safe to run on a diagnostic
|
|
1052
|
+
// path. GHES tenants can be configured with a different ceiling, which is
|
|
1053
|
+
// exactly why this reports the server's own numbers rather than asserting 5,000.
|
|
1054
|
+
async function rateBudget() {
|
|
1055
|
+
try {
|
|
1056
|
+
const raw = await runGh(["api", "rate_limit"]);
|
|
1057
|
+
const { resources } = JSON.parse(raw);
|
|
1058
|
+
// formatAge() is for timestamps in the past and returns "-" for a future one,
|
|
1059
|
+
// so the reset is rendered as a forward interval instead.
|
|
1060
|
+
const fmt = (r) => {
|
|
1061
|
+
if (!r) return "(absent)";
|
|
1062
|
+
const inMs = r.reset * 1000 - Date.now();
|
|
1063
|
+
const resets = inMs > 0 ? `resets in ${formatDuration(inMs)}` : "reset due";
|
|
1064
|
+
return `${r.remaining}/${r.limit} left, ${resets}`;
|
|
1065
|
+
};
|
|
1066
|
+
return { core: fmt(resources?.core), graphql: fmt(resources?.graphql) };
|
|
1067
|
+
} catch (err) {
|
|
1068
|
+
return { core: `unavailable (${shortErr(err)})`, graphql: "unavailable" };
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// Requests per hour this configuration will spend once it settles, derived from
|
|
1073
|
+
// the same constants the poll loop uses rather than from a number written down
|
|
1074
|
+
// once and left to rot. The active tab refreshes every tick; the other three
|
|
1075
|
+
// every BACKGROUND_EVERY ticks. Security is three REST calls per fetch, Actions
|
|
1076
|
+
// one, and Issues/PRs go to GraphQL instead (two POSTs each, because --search
|
|
1077
|
+
// routes through the search connection).
|
|
1078
|
+
function projectedHourlyCost(activeKey) {
|
|
1079
|
+
const perHour = 3_600_000 / runtime.refreshMs;
|
|
1080
|
+
const restCalls = { actions: 1, issues: 0, prs: 0, security: ALERT_SOURCES.length };
|
|
1081
|
+
const graphqlCalls = { actions: 0, issues: 2, prs: 2, security: 0 };
|
|
1082
|
+
let rest = 0;
|
|
1083
|
+
let graphql = 0;
|
|
1084
|
+
// TAB_KEYS rather than TABS: this runs on the --doctor path, which deliberately
|
|
1085
|
+
// returns before the render tree is reached, and TABS is declared down there.
|
|
1086
|
+
for (const key of TAB_KEYS) {
|
|
1087
|
+
const ticks = key === activeKey ? perHour : perHour / BACKGROUND_EVERY;
|
|
1088
|
+
rest += ticks * restCalls[key];
|
|
1089
|
+
graphql += ticks * graphqlCalls[key];
|
|
1090
|
+
}
|
|
1091
|
+
return { rest: Math.round(rest), graphql: Math.round(graphql) };
|
|
1092
|
+
}
|
|
1093
|
+
|
|
919
1094
|
async function runDoctor() {
|
|
920
1095
|
// A live backoff would make an alert probe silently skip and report nothing,
|
|
921
1096
|
// which is the opposite of what a diagnostic run is for.
|
|
@@ -931,10 +1106,11 @@ async function runDoctor() {
|
|
|
931
1106
|
// allSettled, and every probe already resolves rather than rejects, so one
|
|
932
1107
|
// slow endpoint cannot suppress the other five. runGh's own GH_TIMEOUT_MS
|
|
933
1108
|
// bounds each one.
|
|
934
|
-
const [ghVersion, authStatus, remote, results] = await Promise.all([
|
|
1109
|
+
const [ghVersion, authStatus, remote, budget, results] = await Promise.all([
|
|
935
1110
|
captureGh(["--version"]),
|
|
936
1111
|
captureGh(["auth", "status"]),
|
|
937
1112
|
gitRemote(),
|
|
1113
|
+
rateBudget(),
|
|
938
1114
|
Promise.all(probes.map(([name, args]) => probe(name, args))),
|
|
939
1115
|
]);
|
|
940
1116
|
|
|
@@ -954,6 +1130,18 @@ async function runDoctor() {
|
|
|
954
1130
|
field("slug", runtime.repo ?? "(inferred from the working directory)"),
|
|
955
1131
|
field("git remote", remote),
|
|
956
1132
|
"",
|
|
1133
|
+
...section("API budget"),
|
|
1134
|
+
field("REST core", budget.core),
|
|
1135
|
+
field("GraphQL", budget.graphql),
|
|
1136
|
+
field(
|
|
1137
|
+
"this config spends",
|
|
1138
|
+
(() => {
|
|
1139
|
+
const active = TAB_KEYS[runtime.initialTabIndex] ?? TAB_KEYS[0];
|
|
1140
|
+
const { rest, graphql } = projectedHourlyCost(active);
|
|
1141
|
+
return `~${rest} REST + ~${graphql} GraphQL per hour (refresh ${runtime.refreshMs / 1000}s, "${active}" active)`;
|
|
1142
|
+
})(),
|
|
1143
|
+
),
|
|
1144
|
+
"",
|
|
957
1145
|
...section("Environment"),
|
|
958
1146
|
...doctorEnvNames().map((name) => field(name, envValue(name))),
|
|
959
1147
|
"",
|
|
@@ -988,7 +1176,19 @@ async function runDoctor() {
|
|
|
988
1176
|
// path would be a request-forgery primitive against arbitrary endpoints -- so it
|
|
989
1177
|
// is validated once, here at the boundary, against exactly what GitHub allows in
|
|
990
1178
|
// an owner or repository name.
|
|
991
|
-
|
|
1179
|
+
//
|
|
1180
|
+
// The name half requires at least one character that is not a dot. Without that
|
|
1181
|
+
// it accepted `owner/..`, which `apiPath()` spliced into
|
|
1182
|
+
// `repos/owner/../dependabot/alerts` -- and gh forwards the dot segment
|
|
1183
|
+
// unnormalized, so GitHub resolves it server-side to a *different endpoint*
|
|
1184
|
+
// (verified: it lands on Get-a-repository). The value is operator-supplied, so
|
|
1185
|
+
// this was never a cross-user vulnerability, but it broke the one invariant the
|
|
1186
|
+
// comment above claims. A trailing `.git` is rejected for the same reason: it is
|
|
1187
|
+
// not a name GitHub issues, so accepting it can only mean someone pasted a clone
|
|
1188
|
+
// URL's tail. Names that merely *contain* or *start with* a dot stay valid --
|
|
1189
|
+
// `owner/.github` and `owner/docs.example.com` are both real.
|
|
1190
|
+
const REPO_PATTERN =
|
|
1191
|
+
/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?\/(?=[A-Za-z0-9._-]*[A-Za-z0-9_-])(?!.*\.git$)[A-Za-z0-9._-]+$/;
|
|
992
1192
|
|
|
993
1193
|
// A dot is mandatory, and that is the safety property rather than a nicety.
|
|
994
1194
|
// Without it "owner/name/extra" -- already in the hostile-input list -- would
|
|
@@ -1055,8 +1255,13 @@ function parseArgs(argv) {
|
|
|
1055
1255
|
return argv[i];
|
|
1056
1256
|
};
|
|
1057
1257
|
|
|
1258
|
+
// No `-v`. It used to mean --version, which is the conventional reading, but
|
|
1259
|
+
// this CLI also has --verbose -- so `gh-glance -v 2>log`, which is what you
|
|
1260
|
+
// type when you want the log, printed a version string and exited 0. That is
|
|
1261
|
+
// the one argv path that failed quietly in a surface built to fail loudly.
|
|
1262
|
+
// Unknown now, so it exits 2 and points at --help.
|
|
1058
1263
|
if (arg === "--help" || arg === "-h") opts.help = true;
|
|
1059
|
-
else if (arg === "--version"
|
|
1264
|
+
else if (arg === "--version") opts.showVersion = true;
|
|
1060
1265
|
else if (arg === "--verbose") opts.verbose = true;
|
|
1061
1266
|
else if (arg === "--doctor") opts.doctor = true;
|
|
1062
1267
|
else if (arg === "--repo" || arg === "-R" || arg.startsWith("--repo=")) {
|
|
@@ -1111,6 +1316,24 @@ function validateArgs(opts, tabKeys) {
|
|
|
1111
1316
|
|
|
1112
1317
|
const { version } = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8"));
|
|
1113
1318
|
|
|
1319
|
+
// The one key table. --help renders it, the `?` overlay renders it, and the
|
|
1320
|
+
// status bar's KEY_HINTS is the deliberately short subset of it -- so a binding
|
|
1321
|
+
// added in one place cannot go missing from the others, which is the same rule
|
|
1322
|
+
// this file already applies to argv vectors and to classify().
|
|
1323
|
+
const KEY_TABLE = [
|
|
1324
|
+
["1 2 3 4", "Actions / Issues / Pull requests / Security"],
|
|
1325
|
+
["Left / Right", "Previous / next tab"],
|
|
1326
|
+
["Tab / Shift+Tab", "Next / previous tab"],
|
|
1327
|
+
["Up / Down, j / k", "Move the cursor between rows"],
|
|
1328
|
+
["PgUp / PgDn", "Move a page at a time"],
|
|
1329
|
+
["Enter", "Open the selected item in your browser"],
|
|
1330
|
+
["r", "Refresh the current tab now"],
|
|
1331
|
+
["?", "Show the keys (any key closes it)"],
|
|
1332
|
+
["q / Esc / Ctrl+C", "Quit"],
|
|
1333
|
+
];
|
|
1334
|
+
const KEY_COL = Math.max(...KEY_TABLE.map(([k]) => k.length)) + 3;
|
|
1335
|
+
const keyTableLines = () => KEY_TABLE.map(([k, d]) => `${k.padEnd(KEY_COL)}${d}`);
|
|
1336
|
+
|
|
1114
1337
|
const HELP = `gh-glance ${version} -- a live-refreshing GitHub dashboard for a narrow terminal pane.
|
|
1115
1338
|
|
|
1116
1339
|
Usage:
|
|
@@ -1156,14 +1379,9 @@ Status icons are GitHub Octicons and need a Nerd Font. Without one, set
|
|
|
1156
1379
|
GH_GLANCE_ICONS=unicode for plain-unicode equivalents.
|
|
1157
1380
|
|
|
1158
1381
|
Keys:
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
Up / Down, j / k Move the cursor between rows
|
|
1163
|
-
PgUp / PgDn Move a page at a time
|
|
1164
|
-
Enter Open the selected item in your browser
|
|
1165
|
-
r Refresh the current tab now
|
|
1166
|
-
q / Esc / Ctrl+C Quit
|
|
1382
|
+
${keyTableLines()
|
|
1383
|
+
.map((line) => ` ${line}`)
|
|
1384
|
+
.join("\n")}
|
|
1167
1385
|
|
|
1168
1386
|
The cursor clears itself after 60s with no movement.
|
|
1169
1387
|
|
|
@@ -1175,6 +1393,7 @@ Environment:
|
|
|
1175
1393
|
GH_GLANCE_ICONS=unicode Plain-unicode status icons (no Nerd Font needed)
|
|
1176
1394
|
GH_GLANCE_NO_ANIMATION=1 Freeze the spinner (no motion)
|
|
1177
1395
|
NO_COLOR=1 Disable colour (status stays readable)
|
|
1396
|
+
INK_SCREEN_READER=true Linear, unthrottled rendering (unverified -- see README)
|
|
1178
1397
|
`;
|
|
1179
1398
|
|
|
1180
1399
|
// argv and the TTY guard run *before* react and ink are imported. Loading ink
|
|
@@ -1196,6 +1415,20 @@ if (IS_MAIN) {
|
|
|
1196
1415
|
process.exit(2);
|
|
1197
1416
|
}
|
|
1198
1417
|
|
|
1418
|
+
// One place argv becomes runtime state. It used to be two -- the --doctor
|
|
1419
|
+
// branch set repo and host, the dashboard path set all five -- and they had
|
|
1420
|
+
// already drifted: `--doctor --verbose` was accepted and silently dropped, so
|
|
1421
|
+
// the one flag that shows what was actually sent to gh did nothing on the one
|
|
1422
|
+
// command built to explain what gh-glance is doing. Same for --refresh and
|
|
1423
|
+
// --tab. Applying everything up front cannot corrupt the doctor path, which
|
|
1424
|
+
// never renders with ink; the --verbose TTY refusal below stays *below* the
|
|
1425
|
+
// doctor branch on purpose, since that combination is useful and harmless.
|
|
1426
|
+
runtime.repo = opts.repo;
|
|
1427
|
+
runtime.host = opts.host;
|
|
1428
|
+
runtime.verbose = opts.verbose;
|
|
1429
|
+
if (opts.refreshMs !== null) runtime.refreshMs = opts.refreshMs;
|
|
1430
|
+
if (opts.tabKey !== null) runtime.initialTabIndex = TAB_KEYS.indexOf(opts.tabKey);
|
|
1431
|
+
|
|
1199
1432
|
// Checked after parsing rather than before, so `gh-glance --repo` with no
|
|
1200
1433
|
// value reports the missing value rather than silently printing help.
|
|
1201
1434
|
if (opts.help) {
|
|
@@ -1211,11 +1444,8 @@ if (IS_MAIN) {
|
|
|
1211
1444
|
// here on purpose -- ahead of the non-TTY refusal, because the whole point is
|
|
1212
1445
|
// `gh-glance --doctor > report.txt`, and ahead of preflight(), because a
|
|
1213
1446
|
// missing gh and a cwd outside a repository are exactly the conditions worth
|
|
1214
|
-
// reporting rather than exiting 3 over.
|
|
1215
|
-
// the probes exercise the real one.
|
|
1447
|
+
// reporting rather than exiting 3 over.
|
|
1216
1448
|
if (opts.doctor) {
|
|
1217
|
-
runtime.repo = opts.repo;
|
|
1218
|
-
runtime.host = opts.host;
|
|
1219
1449
|
console.log(await runDoctor());
|
|
1220
1450
|
process.exit(0);
|
|
1221
1451
|
}
|
|
@@ -1233,12 +1463,6 @@ if (IS_MAIN) {
|
|
|
1233
1463
|
process.exit(2);
|
|
1234
1464
|
}
|
|
1235
1465
|
|
|
1236
|
-
runtime.repo = opts.repo;
|
|
1237
|
-
runtime.host = opts.host;
|
|
1238
|
-
runtime.verbose = opts.verbose;
|
|
1239
|
-
if (opts.refreshMs !== null) runtime.refreshMs = opts.refreshMs;
|
|
1240
|
-
if (opts.tabKey !== null) runtime.initialTabIndex = TAB_KEYS.indexOf(opts.tabKey);
|
|
1241
|
-
|
|
1242
1466
|
// This is a full-screen live dashboard, not a reporting command -- piping it
|
|
1243
1467
|
// somewhere would emit an endless stream of redraw frames, so fail fast with
|
|
1244
1468
|
// an explanation instead. Exit code 1 is asserted by CI.
|
|
@@ -1319,7 +1543,15 @@ const OCT_UNICODE = {
|
|
|
1319
1543
|
shield: "s",
|
|
1320
1544
|
};
|
|
1321
1545
|
|
|
1322
|
-
|
|
1546
|
+
// Whether the private-use glyphs are in play. There is no way to ask a terminal
|
|
1547
|
+
// whether it can draw them -- the only honest probe is writing one and reading
|
|
1548
|
+
// the cursor back, which hangs on terminals that never answer -- so instead the
|
|
1549
|
+
// app says, once, where the escape hatch is. Without a Nerd Font every icon is a
|
|
1550
|
+
// blank box, which reads as "this program is broken" rather than "install a
|
|
1551
|
+
// font", and the remedy currently lives only in --help and the README: both of
|
|
1552
|
+
// which require quitting the full-screen app you are trying to evaluate.
|
|
1553
|
+
const USING_NERD_ICONS = process.env.GH_GLANCE_ICONS !== "unicode";
|
|
1554
|
+
const OCT = USING_NERD_ICONS ? OCT_NERD : OCT_UNICODE;
|
|
1323
1555
|
|
|
1324
1556
|
// ---------- Shared layout primitives ----------
|
|
1325
1557
|
|
|
@@ -1482,7 +1714,7 @@ const RUN_PENDING_ICON = { icon: OCT.dotFill, color: ATTENTION, label: "queued"
|
|
|
1482
1714
|
const RUN_RUNNING_STATIC = { icon: OCT.alertFill, color: ATTENTION, label: "running" };
|
|
1483
1715
|
|
|
1484
1716
|
// Remote strings index these tables, and a plain object literal answers
|
|
1485
|
-
// inherited keys -- `
|
|
1717
|
+
// inherited keys -- `SEVERITY_STYLE["constructor"]` returned a function, which
|
|
1486
1718
|
// reaches ink's colour prop and throws, killing the render. `??` cannot catch
|
|
1487
1719
|
// that, because an inherited value is not nullish. Code-scanning severity comes
|
|
1488
1720
|
// from uploaded SARIF, so it is genuinely attacker-influenced.
|
|
@@ -1759,7 +1991,7 @@ const MIN_COMPACT_WIDTH = Math.max(...TABS.map((t) => minimumWidthFor(t.compactH
|
|
|
1759
1991
|
const TAB_LABEL_FULL_WIDTH = 78;
|
|
1760
1992
|
const TAB_LABEL_HYSTERESIS = 4;
|
|
1761
1993
|
|
|
1762
|
-
function TabBar({ activeIndex, counts, firstLoad, failed, spin, useShort }) {
|
|
1994
|
+
function TabBar({ activeIndex, counts, brokenCI, firstLoad, failed, spin, useShort }) {
|
|
1763
1995
|
return e(
|
|
1764
1996
|
Box,
|
|
1765
1997
|
{ flexDirection: "row" },
|
|
@@ -1768,7 +2000,12 @@ function TabBar({ activeIndex, counts, firstLoad, failed, spin, useShort }) {
|
|
|
1768
2000
|
const count = counts[tab.key];
|
|
1769
2001
|
// A tab that has never resolved shows the spinner where its count will
|
|
1770
2002
|
// go, so the first load reads as "working" rather than "empty".
|
|
1771
|
-
const suffix =
|
|
2003
|
+
const suffix =
|
|
2004
|
+
count == null
|
|
2005
|
+
? firstLoad[tab.key]
|
|
2006
|
+
? ` ${spin}`
|
|
2007
|
+
: ""
|
|
2008
|
+
: ` (${count}${brokenCI[tab.key] ? "!" : ""})`;
|
|
1772
2009
|
const name = useShort ? tab.short : tab.label;
|
|
1773
2010
|
// Bold and inverse are both stripped at chalk level 0 (NO_COLOR, or a
|
|
1774
2011
|
// dumb terminal), which left no indication at all of which tab was
|
|
@@ -1806,10 +2043,16 @@ function TabBar({ activeIndex, counts, firstLoad, failed, spin, useShort }) {
|
|
|
1806
2043
|
// two columns in its width model, and a status bar built from them overflowed
|
|
1807
2044
|
// an 80-column terminal by six columns once selection added a hint. Same trap
|
|
1808
2045
|
// the unicode icon table already documents -- prefer strictly narrow ASCII
|
|
1809
|
-
// over pretty-but-ambiguous. The arrow pair is a deliberate,
|
|
1810
|
-
//
|
|
1811
|
-
//
|
|
1812
|
-
//
|
|
2046
|
+
// over pretty-but-ambiguous. The arrow pair is a deliberate exception, and it
|
|
2047
|
+
// is NOT covered by a width assertion -- an earlier version of this comment
|
|
2048
|
+
// claimed `npm run test:pty` would catch a double-width rendering, and that was
|
|
2049
|
+
// wrong twice over. Each hint is wrap: "truncate-end", so the failure mode is
|
|
2050
|
+
// silent text loss rather than overflow, which no width check can see; and at 80
|
|
2051
|
+
// columns the panel border is 79 cells against a 54-cell status bar, so the bar
|
|
2052
|
+
// is not what sets the maximum anyway -- the arrows would have to add 25 columns
|
|
2053
|
+
// to move it, not 2. What actually bounds the risk is that there are two of
|
|
2054
|
+
// them and the compact breakpoint below keeps the whole set inside the frame.
|
|
2055
|
+
// Anything wider added here needs its own check, not this comment's assurance.
|
|
1813
2056
|
//
|
|
1814
2057
|
// Tab switching is not listed: the tab bar already renders "1:Actions", so the
|
|
1815
2058
|
// digits document themselves, and the arrow keys are in --help. The hints that
|
|
@@ -1825,17 +2068,41 @@ const KEY_HINTS = [
|
|
|
1825
2068
|
// finishes. Wide enough for the spinner, a space and "Fetching".
|
|
1826
2069
|
const FETCHING_WIDTH = 12;
|
|
1827
2070
|
|
|
2071
|
+
// Width the full hint set needs: every "Label: keys" plus a " | " between each.
|
|
2072
|
+
// Derived from KEY_HINTS rather than written down, for the same reason
|
|
2073
|
+
// minimumWidthFor() derives the table breakpoint from its header descriptors --
|
|
2074
|
+
// a copy change must not be able to invalidate the number.
|
|
2075
|
+
const HINTS_FULL_WIDTH =
|
|
2076
|
+
KEY_HINTS.reduce((sum, h) => sum + h.label.length + 2 + [...h.keys].length, 0) +
|
|
2077
|
+
(KEY_HINTS.length - 1) * 3;
|
|
2078
|
+
// Compact drops the labels and keeps the keys, separated by a space:
|
|
2079
|
+
// "↑↓ Ent r q". No constant for its width -- nothing branches on it, and the
|
|
2080
|
+
// keys are short enough that it fits anywhere the frame itself does.
|
|
2081
|
+
//
|
|
2082
|
+
// The band where the full set fits. Below it the bar was the one part of the
|
|
2083
|
+
// layout with no width awareness -- the table swaps to a compact header, the tab
|
|
2084
|
+
// bar swaps to short labels, the panel edges drop their labels, and the status
|
|
2085
|
+
// bar just let ink truncate. Because each hint is wrap: "truncate-end", the
|
|
2086
|
+
// failure was silent text loss rather than overflow: at 45 columns the rendered
|
|
2087
|
+
// bar read "Move: | Open: | Refresh |Quit:…" -- the arrows gone, Refresh
|
|
2088
|
+
// missing its key, and Quit, the last entry, first to be cut. That is the one
|
|
2089
|
+
// hint a confused first-time user needs, in a full-screen alternate-screen app.
|
|
2090
|
+
const STATUS_FULL_WIDTH = FETCHING_WIDTH + HINTS_FULL_WIDTH;
|
|
2091
|
+
|
|
1828
2092
|
// Two tones rather than one flat gray: the keys you press are the part worth
|
|
1829
2093
|
// finding at a glance, so they get the accent colour and the words describing
|
|
1830
2094
|
// them stay dim. The accent is the panel-title cyan rather than the amber used
|
|
1831
2095
|
// for in-progress status, so amber means exactly one thing across the product.
|
|
1832
|
-
function StatusBar({ fetching, spin, stale, interactive }) {
|
|
2096
|
+
function StatusBar({ fetching, spin, stale, interactive, cols }) {
|
|
1833
2097
|
// Without raw mode none of the key handlers run, so advertising them would be
|
|
1834
2098
|
// telling the user something untrue about what the app can do. Ctrl+C still
|
|
1835
2099
|
// works there, because the tty delivers a real SIGINT.
|
|
1836
2100
|
const hints = interactive
|
|
1837
2101
|
? KEY_HINTS
|
|
1838
2102
|
: [{ label: "Quit", keys: "^C" }];
|
|
2103
|
+
// Measured against the *full* set even when a compact one is rendered, so the
|
|
2104
|
+
// breakpoint does not move as the bar's own contents change.
|
|
2105
|
+
const compact = interactive && cols < STATUS_FULL_WIDTH;
|
|
1839
2106
|
return e(
|
|
1840
2107
|
Box,
|
|
1841
2108
|
{ flexDirection: "row" },
|
|
@@ -1861,11 +2128,12 @@ function StatusBar({ fetching, spin, stale, interactive }) {
|
|
|
1861
2128
|
: null,
|
|
1862
2129
|
...hints
|
|
1863
2130
|
.flatMap((hint, i) => [
|
|
1864
|
-
i > 0 &&
|
|
2131
|
+
i > 0 &&
|
|
2132
|
+
e(Text, { key: `sep${i}`, color: BORDER_COLOR }, compact ? " " : " | "),
|
|
1865
2133
|
e(
|
|
1866
2134
|
Text,
|
|
1867
2135
|
{ key: hint.label, wrap: "truncate-end" },
|
|
1868
|
-
e(Text, { dimColor: true }, `${hint.label}: `),
|
|
2136
|
+
compact ? null : e(Text, { dimColor: true }, `${hint.label}: `),
|
|
1869
2137
|
e(Text, { color: TITLE_COLOR, bold: true }, hint.keys),
|
|
1870
2138
|
),
|
|
1871
2139
|
])
|
|
@@ -1933,6 +2201,12 @@ function App() {
|
|
|
1933
2201
|
security: null,
|
|
1934
2202
|
});
|
|
1935
2203
|
const [securityNotes, setSecurityNotes] = useState([]);
|
|
2204
|
+
// Whether the Security tab is currently unable to see its endpoints, as
|
|
2205
|
+
// opposed to seeing that they are switched off. Drives the count marker.
|
|
2206
|
+
const [securityBlind, setSecurityBlind] = useState(false);
|
|
2207
|
+
// The `?` overlay. Renders only on a keypress, so consecutive idle frames are
|
|
2208
|
+
// still byte-identical and the redraw suppression is untouched.
|
|
2209
|
+
const [showHelp, setShowHelp] = useState(false);
|
|
1936
2210
|
const [errors, setErrors] = useState({ actions: null, issues: null, prs: null, security: null });
|
|
1937
2211
|
const [loading, setLoading] = useState({ actions: true, issues: true, prs: true, security: true });
|
|
1938
2212
|
const [now, setNow] = useState(new Date());
|
|
@@ -1945,7 +2219,29 @@ function App() {
|
|
|
1945
2219
|
const [offset, setOffset] = useState({});
|
|
1946
2220
|
|
|
1947
2221
|
const tab = TABS[activeIndex];
|
|
1948
|
-
|
|
2222
|
+
// The not-enabled notes are collapsed into a single line. Each unavailable
|
|
2223
|
+
// alert source used to contribute a full-width row above the column header,
|
|
2224
|
+
// and on any repo without Advanced Security that is two permanent lines --
|
|
2225
|
+
// roughly 10% of a twenty-row pane, forever, restating a fact that will never
|
|
2226
|
+
// change, on the tab whose job is making real alerts stand out. Failures that
|
|
2227
|
+
// are NOT "not enabled" keep their own lines: those are actionable and
|
|
2228
|
+
// transient, and they are the ones worth the space. Derived here at render
|
|
2229
|
+
// time rather than cached alongside the notes, because the unchanged-payload
|
|
2230
|
+
// short-circuit can skip parse() entirely and a cached string would go stale.
|
|
2231
|
+
const NOT_ENABLED = /not enabled/i;
|
|
2232
|
+
const disabledNotes = securityNotes.filter((n) => NOT_ENABLED.test(n));
|
|
2233
|
+
const otherNotes = securityNotes.filter((n) => !NOT_ENABLED.test(n));
|
|
2234
|
+
const securityLines =
|
|
2235
|
+
disabledNotes.length > 1
|
|
2236
|
+
? [
|
|
2237
|
+
`${disabledNotes.map((n) => n.split(":")[0]).join(", ")}: not enabled here`,
|
|
2238
|
+
...otherNotes,
|
|
2239
|
+
]
|
|
2240
|
+
: securityNotes;
|
|
2241
|
+
// Counts the lines actually rendered, not the notes collected -- getting this
|
|
2242
|
+
// wrong by one row is what makes ink repaint the whole frame.
|
|
2243
|
+
const extraLines =
|
|
2244
|
+
(errors[tab.key] ? 1 : 0) + (tab.key === "security" ? securityLines.length : 0);
|
|
1949
2245
|
// Reserve lines for: the tab bar and the divider under it (2), the panel's
|
|
1950
2246
|
// top and bottom edges (2), the column header and its separator (2), and
|
|
1951
2247
|
// the status line (1), plus a 1-line safety margin. Slack is absorbed by
|
|
@@ -1977,8 +2273,12 @@ function App() {
|
|
|
1977
2273
|
const inFlightRef = useRef({});
|
|
1978
2274
|
const rawRef = useRef({});
|
|
1979
2275
|
const cleanRef = useRef({});
|
|
2276
|
+
// Last *successful* poll per tab, wall-clock. Wall-clock on purpose: a laptop
|
|
2277
|
+
// sleeping is exactly the gap this is meant to report, and a monotonic clock
|
|
2278
|
+
// does not advance across suspend. Never written on the failure path, or a
|
|
2279
|
+
// persistently failing tab would report itself fresh forever.
|
|
2280
|
+
const lastOkRef = useRef({});
|
|
1980
2281
|
const fetchTabRef = useRef(null);
|
|
1981
|
-
const abortRef = useRef(null);
|
|
1982
2282
|
|
|
1983
2283
|
const interactive = Boolean(isRawModeSupported);
|
|
1984
2284
|
|
|
@@ -1986,28 +2286,40 @@ function App() {
|
|
|
1986
2286
|
// slice, so the movement handlers read through a ref -- the same pattern the
|
|
1987
2287
|
// poll loop uses for runLimitRef and activeIndexRef, and for the same reason:
|
|
1988
2288
|
// the closure must see current values without being rebuilt on every change.
|
|
1989
|
-
const navRef = useRef({ items: [], key: null, bodyRows: 1, tabKey: "actions" });
|
|
2289
|
+
const navRef = useRef({ items: [], key: null, bodyRows: 1, tabKey: "actions", offset: 0 });
|
|
1990
2290
|
navRef.current = {
|
|
1991
2291
|
items: data[tab.key] ?? [],
|
|
1992
2292
|
key: selected[tab.key] ?? null,
|
|
1993
2293
|
bodyRows,
|
|
1994
2294
|
tabKey: tab.key,
|
|
2295
|
+
offset: offset[tab.key] ?? 0,
|
|
1995
2296
|
};
|
|
1996
2297
|
const pageStep = Math.max(1, bodyRows - 1);
|
|
1997
2298
|
|
|
1998
|
-
|
|
2299
|
+
// Read through a ref for the same reason the poll loop does: the useInput
|
|
2300
|
+
// closure must see the current value without being rebuilt on every toggle.
|
|
2301
|
+
const showHelpRef = useRef(false);
|
|
2302
|
+
showHelpRef.current = showHelp;
|
|
1999
2303
|
|
|
2000
2304
|
function moveSelection(delta) {
|
|
2001
|
-
const { items, key: currentKey, bodyRows: rows_, tabKey } = navRef.current;
|
|
2305
|
+
const { items, key: currentKey, bodyRows: rows_, tabKey, offset: offsetRaw } = navRef.current;
|
|
2002
2306
|
if (items.length === 0) return;
|
|
2003
2307
|
const current = currentKey == null ? -1 : items.findIndex((i) => itemKey(i) === currentKey);
|
|
2004
|
-
|
|
2005
|
-
//
|
|
2308
|
+
const maxStart = Math.max(0, items.length - rows_);
|
|
2309
|
+
// Re-clamped here for the same reason the render path re-clamps: the payload
|
|
2310
|
+
// can shrink between ticks, so a stored offset can point past the end.
|
|
2311
|
+
const start = Math.min(Math.max(0, offsetRaw), maxStart);
|
|
2312
|
+
// From "nothing selected", seed from what is on screen -- down takes the
|
|
2313
|
+
// first visible row, up the last. It used to take row 0 / the final row of
|
|
2314
|
+
// the whole list, which meant the 60s idle clear silently cost you your
|
|
2315
|
+
// place: scroll to row 80 of 150, read for a minute, press down, and you
|
|
2316
|
+
// were back at row 1 with no scroll animation to notice. For an unscrolled
|
|
2317
|
+
// list that fits the pane this is the same behaviour as before.
|
|
2006
2318
|
const next =
|
|
2007
2319
|
current === -1
|
|
2008
2320
|
? delta > 0
|
|
2009
|
-
?
|
|
2010
|
-
: items.length - 1
|
|
2321
|
+
? start
|
|
2322
|
+
: Math.min(items.length - 1, start + rows_ - 1)
|
|
2011
2323
|
: Math.min(items.length - 1, Math.max(0, current + delta));
|
|
2012
2324
|
setSelected((s) => ({ ...s, [tabKey]: itemKey(items[next]) }));
|
|
2013
2325
|
// Keep the cursor on screen. Only the offset needed to reveal it changes,
|
|
@@ -2033,24 +2345,46 @@ function App() {
|
|
|
2033
2345
|
return () => clearTimeout(timer);
|
|
2034
2346
|
}, [selected]);
|
|
2035
2347
|
|
|
2348
|
+
// Guarded per item, not globally: holding Enter down produces terminal key
|
|
2349
|
+
// repeat at ~30/s, and every event used to spawn another `gh <kind> view --web`
|
|
2350
|
+
// and open another browser tab. The `r` key next to it already guards this way
|
|
2351
|
+
// through the poll loop's in-flight map; Enter simply never did. Keyed by item
|
|
2352
|
+
// rather than by tab so that moving to a different row and opening it
|
|
2353
|
+
// immediately still works -- selection does not move on Enter, so a second
|
|
2354
|
+
// press within the window is always a duplicate of the first.
|
|
2355
|
+
const openingRef = useRef({});
|
|
2356
|
+
|
|
2036
2357
|
function openSelected() {
|
|
2037
2358
|
const { items, key: currentKey, tabKey } = navRef.current;
|
|
2038
2359
|
const item = items.find((i) => itemKey(i) === currentKey);
|
|
2039
2360
|
if (!item) return;
|
|
2040
|
-
const
|
|
2041
|
-
|
|
2361
|
+
const guard = `${tabKey}:${itemKey(item)}`;
|
|
2362
|
+
if (openingRef.current[guard]) return;
|
|
2363
|
+
openingRef.current[guard] = true;
|
|
2042
2364
|
// Fire and forget: a browser launch must not block the render loop, and a
|
|
2043
2365
|
// failure surfaces through the tab's normal error line rather than as an
|
|
2044
2366
|
// unhandled rejection.
|
|
2045
|
-
openInBrowser(tabKey, item
|
|
2046
|
-
|
|
2047
|
-
|
|
2367
|
+
openInBrowser(tabKey, item)
|
|
2368
|
+
.catch((err) => {
|
|
2369
|
+
setErrors((x) => ({ ...x, [tabKey]: shortErr(err) }));
|
|
2370
|
+
})
|
|
2371
|
+
.finally(() => {
|
|
2372
|
+
delete openingRef.current[guard];
|
|
2373
|
+
});
|
|
2048
2374
|
}
|
|
2049
2375
|
|
|
2050
2376
|
useInput(
|
|
2051
2377
|
(input, key) => {
|
|
2052
2378
|
if (input === "q" || key.escape) {
|
|
2053
2379
|
exit();
|
|
2380
|
+
} else if (showHelpRef.current) {
|
|
2381
|
+
// Any key dismisses -- except quit, handled above, which must never be
|
|
2382
|
+
// swallowed by a modal in a full-screen app. Deliberately does not fall
|
|
2383
|
+
// through to the binding the key would normally trigger: closing the
|
|
2384
|
+
// overlay is the whole intent of that press.
|
|
2385
|
+
setShowHelp(false);
|
|
2386
|
+
} else if (input === "?") {
|
|
2387
|
+
setShowHelp(true);
|
|
2054
2388
|
} else if (key.downArrow || input === "j") {
|
|
2055
2389
|
moveSelection(1);
|
|
2056
2390
|
} else if (key.upArrow || input === "k") {
|
|
@@ -2063,8 +2397,11 @@ function App() {
|
|
|
2063
2397
|
openSelected();
|
|
2064
2398
|
} else if (input === "r") {
|
|
2065
2399
|
// Goes through the same per-tab in-flight guard as the poll loop, so
|
|
2066
|
-
// holding the key down cannot stack concurrent subprocesses.
|
|
2067
|
-
|
|
2400
|
+
// holding the key down cannot stack concurrent subprocesses. `force`
|
|
2401
|
+
// bypasses (and clears) any failure backoff: this key is the user saying
|
|
2402
|
+
// "try again now", and a refresh that silently declined to refresh would
|
|
2403
|
+
// be worse than no key at all.
|
|
2404
|
+
fetchTabRef.current?.(TABS[activeIndexRef.current].key, { force: true });
|
|
2068
2405
|
} else if (input >= "1" && input <= String(TABS.length)) {
|
|
2069
2406
|
setActiveIndex(Number(input) - 1);
|
|
2070
2407
|
} else if (key.tab && key.shift) {
|
|
@@ -2098,7 +2435,7 @@ function App() {
|
|
|
2098
2435
|
let cancelled = false;
|
|
2099
2436
|
let ticks = 0;
|
|
2100
2437
|
const controller = new AbortController();
|
|
2101
|
-
|
|
2438
|
+
registerLiveAbort(controller);
|
|
2102
2439
|
|
|
2103
2440
|
// Each tab commits its own result the moment it lands instead of waiting on
|
|
2104
2441
|
// a Promise.allSettled barrier. Actions is by far the slowest fetch, so
|
|
@@ -2124,6 +2461,19 @@ function App() {
|
|
|
2124
2461
|
// at the top of this handler and the early return fired before parse()
|
|
2125
2462
|
// could be retried, leaving the tab permanently showing "no runs" with
|
|
2126
2463
|
// no error at all. Both now happen only after a successful parse.
|
|
2464
|
+
// Freshness is recorded on every *successful poll*, including the
|
|
2465
|
+
// identical-payload path below, and deliberately not in `meta`. It used
|
|
2466
|
+
// to ride on meta.at, which is only written past the early return -- so
|
|
2467
|
+
// on a quiet repo, where every payload is byte-identical by design, the
|
|
2468
|
+
// timestamp froze at the last time data changed and the status bar
|
|
2469
|
+
// accrued a growing "stale 2h13m" while every poll was succeeding on
|
|
2470
|
+
// schedule. The indicator fired loudest in the one state that is
|
|
2471
|
+
// completely healthy. A ref rather than state because writing state here
|
|
2472
|
+
// would allocate a new object every tick and permanently defeat the
|
|
2473
|
+
// React bail-out this early return exists to preserve.
|
|
2474
|
+
lastOkRef.current[key] = Date.now();
|
|
2475
|
+
// Clear on the first success or a single failure latches the ladder.
|
|
2476
|
+
clearBackoff(`tab:${key}`);
|
|
2127
2477
|
if (rawRef.current[key] === raw && cleanRef.current[key]) {
|
|
2128
2478
|
setErrors((x) => (x[key] === null ? x : { ...x, [key]: null }));
|
|
2129
2479
|
return;
|
|
@@ -2144,11 +2494,27 @@ function App() {
|
|
|
2144
2494
|
},
|
|
2145
2495
|
}));
|
|
2146
2496
|
if (value.notes) setSecurityNotes(value.notes);
|
|
2497
|
+
if (key === "security") {
|
|
2498
|
+
setSecurityBlind((b) => (b === Boolean(value.blind) ? b : Boolean(value.blind)));
|
|
2499
|
+
}
|
|
2147
2500
|
})
|
|
2148
2501
|
.catch((err) => {
|
|
2149
2502
|
if (cancelled || err?.name === "AbortError") return;
|
|
2150
2503
|
cleanRef.current[key] = false;
|
|
2151
|
-
|
|
2504
|
+
// Classified, not dumped. The three list tabs used to put raw `gh`
|
|
2505
|
+
// stderr on screen while the alert path one tab over translated the
|
|
2506
|
+
// same verdicts into something actionable. VERDICT_REMEDY replaces the
|
|
2507
|
+
// message rather than prefixing it, because the error slot is exactly
|
|
2508
|
+
// one line and a prefix would truncate away the diagnostic half.
|
|
2509
|
+
const verdict = classify(err);
|
|
2510
|
+
setErrors((x) => ({ ...x, [key]: pick(VERDICT_REMEDY, verdict, null) ?? shortErr(err) }));
|
|
2511
|
+
// ...and back off, which the list tabs never did at all. A tab wedged
|
|
2512
|
+
// on an expired token used to re-spawn `gh` every tick forever -- 720
|
|
2513
|
+
// subprocesses an hour, indefinitely, against a token that is already
|
|
2514
|
+
// refusing. "other" has no ladder on purpose: a network drop should
|
|
2515
|
+
// recover on the very next tick once the network is back.
|
|
2516
|
+
const steps = pick(FAILURE_LADDER, verdict, null);
|
|
2517
|
+
if (steps) recordFailure(`tab:${key}`, performance.now(), steps);
|
|
2152
2518
|
})
|
|
2153
2519
|
.finally(() => {
|
|
2154
2520
|
inFlightRef.current[key] = false;
|
|
@@ -2156,9 +2522,15 @@ function App() {
|
|
|
2156
2522
|
});
|
|
2157
2523
|
}
|
|
2158
2524
|
|
|
2159
|
-
|
|
2525
|
+
// `force` is what the `r` key passes, and it is the whole reason the backoff
|
|
2526
|
+
// above is safe to add. Without it, pressing refresh on a backed-off tab
|
|
2527
|
+
// would silently do nothing -- taking away the one control the user has at
|
|
2528
|
+
// exactly the moment the pane looks broken and they reach for it.
|
|
2529
|
+
function fetchTab(key, { force = false } = {}) {
|
|
2160
2530
|
const signal = controller.signal;
|
|
2161
2531
|
const descriptor = TABS.find((t) => t.key === key);
|
|
2532
|
+
if (!force && backoffActive(`tab:${key}`, performance.now())) return Promise.resolve();
|
|
2533
|
+
if (force) clearBackoff(`tab:${key}`);
|
|
2162
2534
|
// Every tab carries its own fetcher on the registry, so adding a tab is a
|
|
2163
2535
|
// TABS entry plus a fetcher rather than an entry plus an edit to a chain
|
|
2164
2536
|
// in a different part of the file.
|
|
@@ -2174,7 +2546,10 @@ function App() {
|
|
|
2174
2546
|
const active = TABS[activeIndexRef.current].key;
|
|
2175
2547
|
const due = ticks % BACKGROUND_EVERY === 0 ? TABS.map((t) => t.key) : [active];
|
|
2176
2548
|
ticks += 1;
|
|
2177
|
-
|
|
2549
|
+
// Mapped explicitly rather than passing fetchTab by reference: Array.map
|
|
2550
|
+
// hands the callback an index as its second argument, which would land in
|
|
2551
|
+
// the options bag and make every background tab look force-refreshed.
|
|
2552
|
+
await Promise.allSettled(due.map((key) => fetchTab(key)));
|
|
2178
2553
|
if (cancelled) return;
|
|
2179
2554
|
// Advancing `now` is what forces a redraw, so only do it when it can
|
|
2180
2555
|
// actually change what's on screen: durations of in-progress runs tick
|
|
@@ -2212,7 +2587,18 @@ function App() {
|
|
|
2212
2587
|
// idle hour, and the exact opposite of the redraw suppression above. The
|
|
2213
2588
|
// first load genuinely is worth animating, because an empty pane with no
|
|
2214
2589
|
// motion reads as broken; after that, only a run actually executing is.
|
|
2215
|
-
|
|
2590
|
+
// "Never resolved" and "never *succeeded*" are different states, and conflating
|
|
2591
|
+
// them pinned the spinner on forever. setData is only ever called on success,
|
|
2592
|
+
// so a tab whose fetch keeps failing -- offline laptop, VPN down, expired auth,
|
|
2593
|
+
// Actions disabled on the repo -- kept data === null, kept firstLoad true, and
|
|
2594
|
+
// kept the 100ms interval running for the life of the process while the body
|
|
2595
|
+
// rendered "loading actions..." directly above the error explaining it had
|
|
2596
|
+
// failed. Measured at 7.8% of a core and 9.8 MB/hr of terminal writes,
|
|
2597
|
+
// indefinitely, on the single most ordinary failure there is. Motion now means
|
|
2598
|
+
// "still working"; the error line means "not working"; nothing claims both.
|
|
2599
|
+
const firstLoad = Object.fromEntries(
|
|
2600
|
+
TABS.map((t) => [t.key, data[t.key] == null && !errors[t.key]]),
|
|
2601
|
+
);
|
|
2216
2602
|
const anyFirstLoad = Object.values(firstLoad).some(Boolean);
|
|
2217
2603
|
const showSpinner = ANIMATE && (anyFirstLoad || hasRunningVisible);
|
|
2218
2604
|
useEffect(() => {
|
|
@@ -2240,15 +2626,30 @@ function App() {
|
|
|
2240
2626
|
// the count stop moving through a genuine change on any repo big enough
|
|
2241
2627
|
// for this tool to matter.
|
|
2242
2628
|
const suffix = meta[t.key]?.truncated ? "+" : "";
|
|
2243
|
-
//
|
|
2244
|
-
//
|
|
2245
|
-
|
|
2629
|
+
// A blind Security tab reports "?" rather than a number it cannot stand
|
|
2630
|
+
// behind. Zero alerts and zero visibility look identical otherwise.
|
|
2631
|
+
if (t.key === "security" && securityBlind) return [t.key, "?"];
|
|
2632
|
+
return [t.key, `${list.length}${suffix}`];
|
|
2633
|
+
}),
|
|
2634
|
+
);
|
|
2635
|
+
// Kept out of `counts` because the two have different audiences. In the tab bar
|
|
2636
|
+
// `!` sits next to a label and means "the newest run failed"; interpolated into
|
|
2637
|
+
// the frame's bottom edge it produced "4 of 4!", where there is nothing for it
|
|
2638
|
+
// to attach to and it reads as emphasis or a typo. `+` composes fine in both
|
|
2639
|
+
// places, so only this one had to move. Newest first, so the head of the list
|
|
2640
|
+
// is the run that decides whether CI is currently red.
|
|
2641
|
+
const brokenCI = Object.fromEntries(
|
|
2642
|
+
TABS.map((t) => {
|
|
2643
|
+
const list = data[t.key];
|
|
2644
|
+
return [
|
|
2645
|
+
t.key,
|
|
2246
2646
|
t.key === "actions" &&
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2647
|
+
Array.isArray(list) &&
|
|
2648
|
+
list.length > 0 &&
|
|
2649
|
+
list[0].status === "completed" &&
|
|
2650
|
+
list[0].conclusion !== "success" &&
|
|
2651
|
+
list[0].conclusion !== "skipped",
|
|
2652
|
+
];
|
|
2252
2653
|
}),
|
|
2253
2654
|
);
|
|
2254
2655
|
const failed = Object.fromEntries(TABS.map((t) => [t.key, Boolean(errors[t.key])]));
|
|
@@ -2257,7 +2658,11 @@ function App() {
|
|
|
2257
2658
|
// poll only surfaces on the tab you have selected, and after a laptop sleep
|
|
2258
2659
|
// every pane is plausible and wrong. Threshold-gated and minute-granular on
|
|
2259
2660
|
// purpose -- see STALE_AFTER_MS.
|
|
2260
|
-
|
|
2661
|
+
// Read from the ref written on every successful poll, not from meta.at, which
|
|
2662
|
+
// only moves when the *payload* changes -- see lastOkRef. Reading a ref during
|
|
2663
|
+
// render lags by one render, which is harmless here because `now` advances on
|
|
2664
|
+
// its own and the label is minute-granular by design.
|
|
2665
|
+
const lastOk = lastOkRef.current[tab.key];
|
|
2261
2666
|
const staleFor = lastOk == null ? null : now.getTime() - lastOk;
|
|
2262
2667
|
const staleThreshold = Math.max(STALE_AFTER_MS, runtime.refreshMs * 6);
|
|
2263
2668
|
const staleLabel =
|
|
@@ -2297,27 +2702,61 @@ function App() {
|
|
|
2297
2702
|
// terminal is doing the clipping.
|
|
2298
2703
|
const frameCols = Math.max(1, cols - 1);
|
|
2299
2704
|
|
|
2705
|
+
// Name the repository in the top edge when it was chosen explicitly, because
|
|
2706
|
+
// then it is not the one you would guess from the working directory. Nothing
|
|
2707
|
+
// on screen said which repo a pane was watching, and the documented workflow is
|
|
2708
|
+
// several panes side by side plus `--repo` from anywhere -- so telling them
|
|
2709
|
+
// apart meant quitting and running --doctor. Only the explicit cases are shown:
|
|
2710
|
+
// resolving the *inferred* repo would cost a subprocess call at startup for a
|
|
2711
|
+
// label that, by definition, names the directory you are already sitting in.
|
|
2712
|
+
//
|
|
2713
|
+
// GH_REPO is read straight from the environment and never went through
|
|
2714
|
+
// parseRepoTarget, so unlike runtime.repo it is unvalidated -- safe() before it
|
|
2715
|
+
// is drawn, on the same rule as every other string this app does not own.
|
|
2716
|
+
// Dropped before the tab name rather than with it: PanelEdge drops its whole
|
|
2717
|
+
// label when it cannot seat one, so a single concatenated string would take the
|
|
2718
|
+
// tab name down with the target at ordinary widths.
|
|
2719
|
+
const target = runtime.repo ?? safe(process.env.GH_REPO ?? "");
|
|
2720
|
+
const withTarget = target ? `${tab.label} · ${target}` : tab.label;
|
|
2721
|
+
const topLabel = frameCols - 3 - withTarget.length >= 0 ? withTarget : tab.label;
|
|
2722
|
+
|
|
2300
2723
|
// Fixed columns deliberately do not shrink, so BRANCH and TIME stay readable
|
|
2301
2724
|
// at ordinary widths. Narrow panes drop columns instead, which keeps the
|
|
2302
2725
|
// frame, the tab bar and the quit hint on screen -- the previous behaviour
|
|
2303
2726
|
// pushed all three off the bottom. Measured against frameCols, the width the
|
|
2304
2727
|
// frame itself actually gets, not the raw terminal width.
|
|
2305
|
-
|
|
2728
|
+
// Per tab, not one global flag. MIN_TABLE_WIDTH is a max across all four, so
|
|
2729
|
+
// the widest tab was deciding for the narrowest: Pull requests needs 61
|
|
2730
|
+
// columns and Security only 44, which meant Security dropped two columns a
|
|
2731
|
+
// full 17 columns before it had to. Only one tab is on screen at a time, so
|
|
2732
|
+
// the cost -- different tabs showing different column counts at the same width
|
|
2733
|
+
// -- is not something you can actually see. MIN_TABLE_WIDTH stays as the
|
|
2734
|
+
// exported worst case, which is what the tests pin.
|
|
2735
|
+
const compact = frameCols < minimumWidthFor(tab.header);
|
|
2306
2736
|
const header = compact ? tab.compactHeader : tab.header;
|
|
2307
2737
|
|
|
2738
|
+
// Below the compact set's own floor even the fixed columns overflow, which
|
|
2739
|
+
// hard-wraps every row and drives ink into clearing and repainting the whole
|
|
2740
|
+
// screen each frame -- the one failure mode this file is most engineered to
|
|
2741
|
+
// avoid, and it was reachable simply by dragging a sidebar narrow. The guard
|
|
2742
|
+
// sits after usableSize(), which substitutes DEFAULT_COLS for a 0 reported
|
|
2743
|
+
// mid-resize, so a transient zero cannot be mistaken for a genuinely tiny pane.
|
|
2744
|
+
const tooNarrow = frameCols < MIN_COMPACT_WIDTH;
|
|
2745
|
+
|
|
2308
2746
|
return e(
|
|
2309
2747
|
Box,
|
|
2310
2748
|
{ flexDirection: "column", width: frameCols, height: Math.min(rows, usableSize(stdout?.rows, rows)) },
|
|
2311
2749
|
e(TabBar, {
|
|
2312
2750
|
activeIndex,
|
|
2313
2751
|
counts,
|
|
2752
|
+
brokenCI,
|
|
2314
2753
|
firstLoad,
|
|
2315
2754
|
failed,
|
|
2316
2755
|
spin,
|
|
2317
2756
|
useShort: useShortLabels,
|
|
2318
2757
|
}),
|
|
2319
2758
|
e(Divider, { width: frameCols }),
|
|
2320
|
-
e(PanelEdge, { width: frameCols, top: true, label:
|
|
2759
|
+
e(PanelEdge, { width: frameCols, top: true, label: topLabel, labelColor: TITLE_COLOR }),
|
|
2321
2760
|
e(
|
|
2322
2761
|
Box,
|
|
2323
2762
|
{
|
|
@@ -2334,13 +2773,31 @@ function App() {
|
|
|
2334
2773
|
// truncate-end is what makes the one-line reservation in `extraLines`
|
|
2335
2774
|
// true by construction. Predicting the wrapped height instead would mean
|
|
2336
2775
|
// duplicating ink's width model, and would still be wrong on resize.
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2776
|
+
// Below the compact floor, say so instead of rendering a table that cannot
|
|
2777
|
+
// fit. The frame, the tab bar and the quit hint stay, so widening the pane
|
|
2778
|
+
// recovers immediately -- this is a render-time branch and touches no state.
|
|
2779
|
+
// Short enough to survive at the widths it actually appears at -- a message
|
|
2780
|
+
// about the pane being too narrow that is itself truncated would be a joke.
|
|
2781
|
+
// Clamped to bodyRows so it can never push the frame past `rows` and
|
|
2782
|
+
// trigger the full repaint the height reservation exists to prevent, and
|
|
2783
|
+
// truncate-end so it degrades rather than wraps in a narrow pane. Every
|
|
2784
|
+
// glyph in KEY_TABLE is plain ASCII, which is what keeps it safe here.
|
|
2785
|
+
showHelp &&
|
|
2786
|
+
keyTableLines()
|
|
2787
|
+
.slice(0, bodyRows)
|
|
2788
|
+
.map((line, i) => e(Text, { key: `help${i}`, wrap: "truncate-end" }, line)),
|
|
2789
|
+
!showHelp &&
|
|
2790
|
+
tooNarrow &&
|
|
2791
|
+
e(Text, { dimColor: true, wrap: "truncate-end" }, "too narrow"),
|
|
2792
|
+
!showHelp && !tooNarrow && error && e(Text, { color: ERROR_TEXT, wrap: "truncate-end" }, error),
|
|
2793
|
+
!showHelp &&
|
|
2794
|
+
!tooNarrow &&
|
|
2795
|
+
tab.key === "security" &&
|
|
2796
|
+
securityLines.map((note, i) =>
|
|
2340
2797
|
e(Text, { key: i, dimColor: true, wrap: "truncate-end" }, note),
|
|
2341
2798
|
),
|
|
2342
|
-
e(HeaderCells, { cells: header }),
|
|
2343
|
-
...visibleItems.map((item) => {
|
|
2799
|
+
!showHelp && !tooNarrow && e(HeaderCells, { cells: header }),
|
|
2800
|
+
...(tooNarrow || showHelp ? [] : visibleItems).map((item) => {
|
|
2344
2801
|
const key = itemKey(item);
|
|
2345
2802
|
return e(
|
|
2346
2803
|
RowBoundary,
|
|
@@ -2350,10 +2807,13 @@ function App() {
|
|
|
2350
2807
|
now,
|
|
2351
2808
|
compact,
|
|
2352
2809
|
cursor: key === selectedKey,
|
|
2353
|
-
// Only
|
|
2354
|
-
//
|
|
2355
|
-
// memoisation below for
|
|
2356
|
-
|
|
2810
|
+
// Only an *executing* Actions row reads this. It used to go to every
|
|
2811
|
+
// visible row on the tab, so a prop that changes several times a
|
|
2812
|
+
// second defeated the memoisation below for all of them rather than
|
|
2813
|
+
// for the one or two actually spinning -- measured at 7,960 row
|
|
2814
|
+
// renders over 20s against 238 with this condition.
|
|
2815
|
+
spin:
|
|
2816
|
+
tab.key === "actions" && showSpinner && item.status === "in_progress" ? spin : null,
|
|
2357
2817
|
}),
|
|
2358
2818
|
);
|
|
2359
2819
|
}),
|
|
@@ -2361,13 +2821,25 @@ function App() {
|
|
|
2361
2821
|
// which is the difference between a dashboard that looks hung and one
|
|
2362
2822
|
// that looks correct. Driven by "never resolved" rather than by the
|
|
2363
2823
|
// loading flag, which toggles on every tick and made a settled-empty tab
|
|
2364
|
-
// swap its message every five seconds.
|
|
2365
|
-
|
|
2824
|
+
// swap its message every five seconds. Suppressed entirely when a tab has
|
|
2825
|
+
// an error and has never resolved: the error line directly above already
|
|
2826
|
+
// says what happened, and "no runs" underneath it reads as a fact about the
|
|
2827
|
+
// repository rather than the absence of an answer.
|
|
2828
|
+
!showHelp &&
|
|
2829
|
+
!tooNarrow &&
|
|
2830
|
+
visibleItems.length === 0 &&
|
|
2831
|
+
!(error && items == null) &&
|
|
2366
2832
|
e(
|
|
2367
2833
|
Text,
|
|
2368
2834
|
{ dimColor: true },
|
|
2369
2835
|
firstLoad[tab.key]
|
|
2370
|
-
? `${showSpinner ? spin : SPINNER[0]} loading ${tab.label.toLowerCase()}…`
|
|
2836
|
+
? `${showSpinner ? spin : SPINNER[0]} loading ${tab.label.toLowerCase()}…` +
|
|
2837
|
+
// Shown only while the first fetch is in flight, so it costs a
|
|
2838
|
+
// second of one line at startup and nothing after -- and it lands
|
|
2839
|
+
// exactly when someone is staring at a fresh pane deciding whether
|
|
2840
|
+
// the thing works. Suppressed once icons are already ASCII, since
|
|
2841
|
+
// then there is nothing to fix.
|
|
2842
|
+
(USING_NERD_ICONS ? " (icons blank? GH_GLANCE_ICONS=unicode)" : "")
|
|
2371
2843
|
: tab.key === "security" && securityNotes.length === ALERT_SOURCES.length
|
|
2372
2844
|
? "no alert sources available"
|
|
2373
2845
|
: `no ${tab.countLabel}`,
|
|
@@ -2383,6 +2855,7 @@ function App() {
|
|
|
2383
2855
|
spin: showSpinner ? spin : null,
|
|
2384
2856
|
stale: staleLabel,
|
|
2385
2857
|
interactive,
|
|
2858
|
+
cols: frameCols,
|
|
2386
2859
|
}),
|
|
2387
2860
|
);
|
|
2388
2861
|
}
|
|
@@ -2413,11 +2886,27 @@ function restoreScreen() {
|
|
|
2413
2886
|
// then discarded -- and nothing ever set a non-zero exit code, so the dashboard
|
|
2414
2887
|
// simply vanished and any wrapper saw success. Restore the primary buffer
|
|
2415
2888
|
// *first*, then write, or the fix reproduces the problem it is fixing.
|
|
2889
|
+
//
|
|
2890
|
+
// Aborting in-flight `gh` children is the poll effect's job on every other exit
|
|
2891
|
+
// path, but its cleanup only runs through ink's unmount -- which an explicit
|
|
2892
|
+
// process.exit() skips. Without this, a crash orphaned up to six subprocesses
|
|
2893
|
+
// for the length of GH_TIMEOUT_MS. The abort runs *after* restoreScreen() and is
|
|
2894
|
+
// wrapped, because an exception thrown here would replace the stack trace this
|
|
2895
|
+
// handler exists to print with an unrelated one.
|
|
2896
|
+
//
|
|
2897
|
+
// Both messages go through redact(): a stack can carry a URL with inline
|
|
2898
|
+
// credentials, and this output is what a user pastes into a bug report -- the
|
|
2899
|
+
// same reasoning --doctor already applies to its own report.
|
|
2416
2900
|
function installCrashHandlers() {
|
|
2417
2901
|
const fail = (label) => (err) => {
|
|
2418
2902
|
restoreScreen();
|
|
2903
|
+
try {
|
|
2904
|
+
abortLiveRequests();
|
|
2905
|
+
} catch {
|
|
2906
|
+
// Nothing useful to do about a failure here, and the stack below matters more.
|
|
2907
|
+
}
|
|
2419
2908
|
console.error(`gh-glance: ${label}`);
|
|
2420
|
-
console.error(err instanceof Error ? (err.stack ?? err.message) : String(err));
|
|
2909
|
+
console.error(redact(err instanceof Error ? (err.stack ?? err.message) : String(err)));
|
|
2421
2910
|
process.exit(1);
|
|
2422
2911
|
};
|
|
2423
2912
|
process.on("uncaughtException", fail("crashed"));
|
|
@@ -2512,4 +3001,9 @@ export {
|
|
|
2512
3001
|
MIN_COMPACT_WIDTH,
|
|
2513
3002
|
OCT_NERD,
|
|
2514
3003
|
OCT_UNICODE,
|
|
3004
|
+
KEY_TABLE,
|
|
3005
|
+
KEY_HINTS,
|
|
3006
|
+
VERDICT_REMEDY,
|
|
3007
|
+
RATE_LIMIT_RETRY_MS,
|
|
3008
|
+
FAILURE_LADDER,
|
|
2515
3009
|
};
|