gh-glance 0.5.0 → 0.6.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 +43 -0
- package/README.md +59 -5
- package/index.mjs +468 -59
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,47 @@ All notable changes to this project are documented here.
|
|
|
5
5
|
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.6.0] - 2026-08-06
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **An interactive missing-remote onboarding prompt.** Starting gh-glance in a
|
|
15
|
+
local repository with no remotes now offers to hand off to the user-confirmed
|
|
16
|
+
interactive `gh repo create` flow, while keeping quit and explicit `--repo`
|
|
17
|
+
paths visible. Declining the prompt makes no repository change.
|
|
18
|
+
- **Failure-triggered account and repository context.** After an ambiguous
|
|
19
|
+
repository-access failure, gh-glance performs read-only active-account and
|
|
20
|
+
repository checks through `gh`, and `--doctor` now includes a `Repository
|
|
21
|
+
access` probe.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- **Clean no-login failures now explain the delegated authentication path.**
|
|
26
|
+
Previously these were unclassified raw `gh` errors. They now direct the user
|
|
27
|
+
to login or authorization evidence through their own `gh` configuration and
|
|
28
|
+
remain retryable.
|
|
29
|
+
- **Inaccessible repository errors now converge on honest wording.** Actions'
|
|
30
|
+
REST 404 and Issues' GraphQL repository-resolution failure described the same
|
|
31
|
+
inaccessible target through unrelated messages. List tabs now say “not found
|
|
32
|
+
or inaccessible” without claiming whether the target is missing or private.
|
|
33
|
+
|
|
34
|
+
## [0.5.1] - 2026-08-04
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
|
|
38
|
+
- **The blank-icons hint waits for a start that is actually stuck.** It shipped
|
|
39
|
+
in 0.5.0 on the loading line unconditionally, so every start put a line naming
|
|
40
|
+
an environment variable in front of everyone for the second or two the first
|
|
41
|
+
fetch takes -- a dim `loading actions…` reads as "working", the same line with
|
|
42
|
+
a remedy attached reads as a warning about a problem you do not have. It now
|
|
43
|
+
appears only once a first fetch has been running 3s, which is past the slow end
|
|
44
|
+
of what `gh run list` costs on the tab you land on (measured: 1.4-3.0s after
|
|
45
|
+
the first frame, against 0.6-1.1s for the other three tabs). An ordinary start
|
|
46
|
+
never shows it; a pane of blank boxes that is genuinely waiting still explains
|
|
47
|
+
itself.
|
|
48
|
+
|
|
8
49
|
## [0.5.0] - 2026-08-04
|
|
9
50
|
|
|
10
51
|
Findings from a cross-functional audit, filtered hard: everything that would
|
|
@@ -516,6 +557,8 @@ engineering, security, QA and UX. What follows is what changed as a result.
|
|
|
516
557
|
- The `main` field from `package.json`. It advertised the file as importable,
|
|
517
558
|
but importing it took over the terminal or exited the host process.
|
|
518
559
|
|
|
560
|
+
[0.6.0]: https://github.com/juan294/gh-glance/releases/tag/v0.6.0
|
|
561
|
+
[0.5.1]: https://github.com/juan294/gh-glance/releases/tag/v0.5.1
|
|
519
562
|
[0.5.0]: https://github.com/juan294/gh-glance/releases/tag/v0.5.0
|
|
520
563
|
[0.4.1]: https://github.com/juan294/gh-glance/releases/tag/v0.4.1
|
|
521
564
|
[0.4.0]: https://github.com/juan294/gh-glance/releases/tag/v0.4.0
|
package/README.md
CHANGED
|
@@ -121,6 +121,41 @@ the tarball back to the workflow run and commit that produced it. The
|
|
|
121
121
|
"Provenance" section on the [package
|
|
122
122
|
page](https://www.npmjs.com/package/gh-glance) shows the exact source.
|
|
123
123
|
|
|
124
|
+
### First run
|
|
125
|
+
|
|
126
|
+
`gh-glance` uses the credentials and active account already selected by your
|
|
127
|
+
local `gh` CLI. It does not request, inspect, or store the token itself. Check
|
|
128
|
+
that account before starting the dashboard:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
gh auth status
|
|
132
|
+
|
|
133
|
+
# Only when no account is active:
|
|
134
|
+
gh auth login
|
|
135
|
+
|
|
136
|
+
gh-glance
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Cloning over SSH or HTTPS and authenticating `gh` are separate credential
|
|
140
|
+
paths, so a successful clone does not prove that the active `gh` account can
|
|
141
|
+
access the repository through the API. For a non-default host, authenticate it
|
|
142
|
+
explicitly with `gh auth login --hostname <host>`.
|
|
143
|
+
|
|
144
|
+
If several accounts are authenticated on the same host, inspect and select the
|
|
145
|
+
one you intend to use:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
gh auth status
|
|
149
|
+
gh auth switch --hostname github.com --user <login>
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
`gh auth switch` is an explicit user command, not a gh-glance action. It changes
|
|
153
|
+
the active account globally for that `gh` configuration. If `gh auth switch
|
|
154
|
+
--help` is unavailable, update `gh` to use this optional multi-account workflow;
|
|
155
|
+
the core gh-glance minimum remains unchanged. To keep simultaneous panes on
|
|
156
|
+
different accounts, use separate `GH_CONFIG_DIR` values as described under
|
|
157
|
+
[No account pinning](#limitations).
|
|
158
|
+
|
|
124
159
|
### From a clone
|
|
125
160
|
|
|
126
161
|
For hacking on it, or to run an unreleased revision:
|
|
@@ -193,7 +228,7 @@ stdout isn't a terminal rather than streaming redraw frames into a pipe.
|
|
|
193
228
|
| `Tab` / `Shift+Tab` | Next / previous tab |
|
|
194
229
|
| `↑` / `↓` or `j` / `k` | Move the cursor between rows |
|
|
195
230
|
| `PgUp` / `PgDn` | Move a page at a time |
|
|
196
|
-
| `Enter` | Open the selected item
|
|
231
|
+
| `Enter` | Open the selected item, or accept an onboarding prompt |
|
|
197
232
|
| `r` | Refresh the current tab now |
|
|
198
233
|
| `?` | Show the keys without leaving the dashboard (any key closes it) |
|
|
199
234
|
| `q` / `Esc` / `Ctrl+C` | Quit |
|
|
@@ -205,6 +240,14 @@ itself after 60s with no cursor movement, so a pane left idle in the corner of
|
|
|
205
240
|
a screen doesn't sit there marked forever -- and the next arrow key picks up
|
|
206
241
|
from what is on screen rather than jumping back to the top of the list.
|
|
207
242
|
|
|
243
|
+
If the current local repository has no GitHub remote, gh-glance shows a setup
|
|
244
|
+
prompt instead of raw `gh` stderr. Press `Enter` to leave the dashboard and run
|
|
245
|
+
the interactive `gh repo create` flow, then choose **Push an existing local
|
|
246
|
+
repository**. Plain `gh repo create` is intentional: adding `--source` would
|
|
247
|
+
switch `gh` into non-interactive mode and require choosing a visibility in
|
|
248
|
+
advance. Press `q` or `Esc` to decline. To watch an existing repository without
|
|
249
|
+
attaching this folder, restart with `gh-glance --repo owner/name`.
|
|
250
|
+
|
|
208
251
|
When the repository was chosen explicitly, with `--repo` or `GH_REPO`, the panel
|
|
209
252
|
says so: `╭─ Actions · acme/widget ─`. That is what tells two side-by-side panes
|
|
210
253
|
apart. It is dropped before the tab name when the pane is too narrow for both.
|
|
@@ -234,7 +277,7 @@ Environment variables work too, and the flags take precedence:
|
|
|
234
277
|
|---|---|
|
|
235
278
|
| `GH_REPO=owner/name` | Watch a specific repository instead of the current directory's. A host-qualified `GH_REPO` does **not** route the security-alert endpoints -- use `GH_HOST` or `--repo host/owner/name` for that |
|
|
236
279
|
| `GH_HOST=<host>` | Send every call to a GitHub Enterprise or EMU host instead of `github.com`. Routes both the list commands and the alert endpoints |
|
|
237
|
-
| `GH_TOKEN`, `GH_ENTERPRISE_TOKEN`, `GH_CONFIG_DIR` | Not read by `gh-glance` -- passed through to `gh` untouched, along with the proxy variables. `gh-glance` handles no credentials of its own |
|
|
280
|
+
| `GH_TOKEN`, `GH_ENTERPRISE_TOKEN`, `GH_CONFIG_DIR` | Not read by `gh-glance` -- passed through to `gh` untouched, along with the proxy variables. `gh-glance` handles no credentials of its own. Separate `GH_CONFIG_DIR` values can isolate simultaneous panes on different accounts; see [No account pinning](#limitations) |
|
|
238
281
|
| `GH_GLANCE_ICONS=unicode` | Plain ASCII status icons, for terminals without a Nerd Font |
|
|
239
282
|
| `GH_GLANCE_NO_ANIMATION=1` | Freeze the spinner — no motion at all |
|
|
240
283
|
| `NO_COLOR=1` | Disable colour. Status stays readable: severity has its own column, a failing newest run puts a `!` on the Actions tab, and the active tab is bracketed. Note that run-state glyphs are not all distinct -- see the feature list above |
|
|
@@ -282,9 +325,15 @@ gh-glance --doctor > report.txt
|
|
|
282
325
|
Collects, in one plain-text block: the `gh-glance`, Node and `gh` versions;
|
|
283
326
|
which hosts `gh` is authenticated for; how the repository target resolved and
|
|
284
327
|
from where; your remaining REST and GraphQL budget plus what this configuration
|
|
285
|
-
will spend per hour; the relevant environment variables;
|
|
286
|
-
|
|
287
|
-
classified (`unavailable`,
|
|
328
|
+
will spend per hour; the relevant environment variables; a read-only
|
|
329
|
+
`Repository access` probe; and one probe per dashboard endpoint with the exact
|
|
330
|
+
argv it sent, its outcome, and how any error was classified (`unavailable`,
|
|
331
|
+
`rate-limited`, `auth-problem` or `other`).
|
|
332
|
+
|
|
333
|
+
The `Repository access` probe shows whether the target resolves for the active
|
|
334
|
+
`gh` credentials. A failed GitHub resolution response cannot distinguish a
|
|
335
|
+
nonexistent, renamed, or stale target from a private repository hidden from
|
|
336
|
+
that identity.
|
|
288
337
|
|
|
289
338
|
Add `--verbose` to get a log of every `gh` call it makes alongside the report.
|
|
290
339
|
|
|
@@ -401,6 +450,11 @@ in some terminals, which would shift every column to their right.
|
|
|
401
450
|
|---|---|
|
|
402
451
|
| `the gh CLI is not installed` | Install it from [cli.github.com](https://cli.github.com), then `gh auth login`. |
|
|
403
452
|
| `not inside a git repository` | Run it from a cloned GitHub repository, or set `GH_REPO=owner/name`. |
|
|
453
|
+
| `No GitHub remote found` | Press `Enter` to start `gh repo create`, then choose **Push an existing local repository**. Or press `q` and run `gh-glance --repo owner/name` to watch an existing repository without attaching this folder. |
|
|
454
|
+
| `GitHub login or authorization required` | Run `gh auth status`. With no account, run `gh auth login`; with an expired authorization, run `gh auth refresh`; then press `r`. |
|
|
455
|
+
| `Repository not found or inaccessible to the active gh account` | The active identity cannot resolve the target. Check `gh auth status`, `git remote -v` or the explicit `--repo`, and use `gh auth switch` only if the wrong account is active. |
|
|
456
|
+
| `GraphQL: Could not resolve to a Repository...` in older gh-glance versions | The response has the same ambiguity: a missing or renamed target, or a private repository not visible to the active account. Run `gh-glance --doctor`. |
|
|
457
|
+
| Actions says `not available for this repository` while Repository access is `ok` | The repository resolved, but that endpoint is unavailable; inspect the corresponding doctor block. |
|
|
404
458
|
| Status icons are blank boxes | Your terminal font is not a Nerd Font. Use `GH_GLANCE_ICONS=unicode`. |
|
|
405
459
|
| Security tab shows a "not enabled" note | Code scanning and secret scanning need GitHub Advanced Security. Dependabot alerts work independently. The note now appears only when the feature genuinely is unavailable: auth, SSO and network failures show the real error instead. |
|
|
406
460
|
| `none of the git remotes ... point to a known GitHub host` | `gh` is not authenticated for that host. Run `gh auth login --hostname <host>`. |
|
package/index.mjs
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
// disarmDevBuildLeak() for how that path is kept survivable rather than fatal.
|
|
23
23
|
process.env.NODE_ENV ??= "production";
|
|
24
24
|
|
|
25
|
-
import { execFile } from "node:child_process";
|
|
25
|
+
import { execFile, spawn } from "node:child_process";
|
|
26
26
|
import { readFileSync, realpathSync } from "node:fs";
|
|
27
27
|
import { fileURLToPath } from "node:url";
|
|
28
28
|
import { promisify } from "node:util";
|
|
@@ -53,6 +53,23 @@ const REFRESH_MS = 5000;
|
|
|
53
53
|
// last movement, not from tab switches or Enter) quietly drops it.
|
|
54
54
|
const SELECTION_IDLE_MS = 60_000;
|
|
55
55
|
|
|
56
|
+
// How long a first fetch has to still be unresolved before the loading line
|
|
57
|
+
// offers the Nerd Font escape hatch. The hint used to be unconditional, which
|
|
58
|
+
// put a line naming an environment variable in front of every user on every
|
|
59
|
+
// start to catch the minority whose terminal cannot draw the glyphs -- a dim
|
|
60
|
+
// "loading actions…" reads as "working", the same line with a remedy attached
|
|
61
|
+
// reads as a warning, and it was on screen for the whole first fetch every time.
|
|
62
|
+
//
|
|
63
|
+
// The threshold is set from the fetch that actually gates it, not from a round
|
|
64
|
+
// number: the hint hangs off the *visible* tab's loading line, and the tab you
|
|
65
|
+
// land on is Actions, whose `gh run list` is by far the slowest call here
|
|
66
|
+
// (measured against this repo: issues/PRs/alerts resolve 0.6-1.1s after the
|
|
67
|
+
// first frame, runs 1.4-3.0s). 1.5s would have fired on most ordinary starts --
|
|
68
|
+
// i.e. changed nothing. 3s sits past the slow end of that range, so a start that
|
|
69
|
+
// reaches it is genuinely stuck rather than merely fetching, which is exactly
|
|
70
|
+
// the moment the remedy is worth a line.
|
|
71
|
+
const ICON_HINT_AFTER_MS = 3000;
|
|
72
|
+
|
|
56
73
|
// `gh run list` costs roughly linearly in --limit (measured: ~1.2s at 20 runs,
|
|
57
74
|
// ~3.0s at 100, ~4.9s at 150), so asking for a fixed 150 to render ~35 visible
|
|
58
75
|
// rows was paying several seconds per refresh for rows nobody sees. Actions is
|
|
@@ -317,7 +334,11 @@ function errText(err) {
|
|
|
317
334
|
// DNS, a 502) is a real failure that used to be reported as a confident,
|
|
318
335
|
// plausible, and wrong claim that the feature was switched off.
|
|
319
336
|
function isUnavailable(err) {
|
|
320
|
-
|
|
337
|
+
const text = errText(err);
|
|
338
|
+
return (
|
|
339
|
+
/HTTP (403|404)/.test(text) ||
|
|
340
|
+
/Could not resolve to a Repository with the name .* \(repository\)/i.test(text)
|
|
341
|
+
);
|
|
321
342
|
}
|
|
322
343
|
|
|
323
344
|
function isRateLimited(err) {
|
|
@@ -342,12 +363,20 @@ function isRateLimited(err) {
|
|
|
342
363
|
// entirely in the positive. It stays clear of the genuine not-enabled messages,
|
|
343
364
|
// which talk about features rather than access.
|
|
344
365
|
const AUTH_MARKERS =
|
|
345
|
-
/SAML|single[- ]sign[- ]on|\bSSO\b|must grant|not authoriz|unauthoriz|access restriction|Bad credentials|requires authentication|re-?authoriz|token .*scope|missing .*scope|insufficient/i;
|
|
366
|
+
/SAML|single[- ]sign[- ]on|\bSSO\b|must grant|not authoriz|unauthoriz|access restriction|Bad credentials|requires authentication|re-?authoriz|token .*scope|missing .*scope|insufficient|not logged into any GitHub hosts|To get started with GitHub CLI|run: gh auth login|none of the git remotes[\s\S]*known GitHub host/i;
|
|
346
367
|
|
|
347
368
|
function isAuthProblem(err) {
|
|
348
369
|
return AUTH_MARKERS.test(errText(err));
|
|
349
370
|
}
|
|
350
371
|
|
|
372
|
+
// A local repository with no remote is an onboarding state, not an API or
|
|
373
|
+
// authentication failure. Both strings are emitted by current gh commands:
|
|
374
|
+
// list commands include the "failed to determine base repo" prefix, while
|
|
375
|
+
// `gh repo view` returns the shorter form.
|
|
376
|
+
function isMissingRemote(err) {
|
|
377
|
+
return /(?:failed to determine base repo:\s*)?no git remotes found/i.test(errText(err));
|
|
378
|
+
}
|
|
379
|
+
|
|
351
380
|
// The one place the three predicates are turned into a verdict. Both consumers
|
|
352
381
|
// go through it -- the fetcher, to choose a note and a backoff ladder, and
|
|
353
382
|
// `--doctor`, to report what gh-glance concluded -- so the report cannot claim
|
|
@@ -363,6 +392,7 @@ function isAuthProblem(err) {
|
|
|
363
392
|
// repository's configuration.
|
|
364
393
|
function classify(err) {
|
|
365
394
|
if (err == null) return "ok";
|
|
395
|
+
if (isMissingRemote(err)) return "no-remote";
|
|
366
396
|
if (isRateLimited(err)) return "rate-limited";
|
|
367
397
|
if (isAuthProblem(err)) return "auth-problem";
|
|
368
398
|
if (isUnavailable(err)) return "unavailable";
|
|
@@ -384,11 +414,77 @@ function classify(err) {
|
|
|
384
414
|
// positive that only picked a retry ladder was cheap, whereas one that also
|
|
385
415
|
// rewrites the on-screen text turns into a confidently wrong instruction.
|
|
386
416
|
const VERDICT_REMEDY = {
|
|
387
|
-
"
|
|
417
|
+
"no-remote":
|
|
418
|
+
"No GitHub remote found -- press Enter to create one, or use `gh-glance --repo owner/name`",
|
|
419
|
+
"auth-problem":
|
|
420
|
+
"GitHub login or authorization required -- run `gh auth status`, then `gh auth login` or `gh auth refresh`",
|
|
388
421
|
"rate-limited": "GitHub rate limit reached -- backing off, this clears on its own",
|
|
389
|
-
unavailable:
|
|
422
|
+
unavailable:
|
|
423
|
+
"Repository not found or inaccessible to the active `gh` account -- check `gh auth status` and the repository target",
|
|
390
424
|
};
|
|
391
425
|
|
|
426
|
+
function toTabError(err) {
|
|
427
|
+
return { kind: "fetch", verdict: classify(err), raw: shortErr(err) };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function textTabError(err) {
|
|
431
|
+
return { kind: "text", text: shortErr(err) };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function failureTargetHost({ runtimeHost, ghHost, ghRepo, accounts } = {}) {
|
|
435
|
+
let ghRepoHost = null;
|
|
436
|
+
if (ghRepo) {
|
|
437
|
+
try {
|
|
438
|
+
ghRepoHost = parseRepoTarget(ghRepo).host;
|
|
439
|
+
} catch {
|
|
440
|
+
// GH_REPO is external process state, not validated argv. An invalid value
|
|
441
|
+
// is not evidence about which host an unavailable repository targeted.
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const accountHosts = Array.isArray(accounts)
|
|
446
|
+
? accounts
|
|
447
|
+
.map((account) => (typeof account?.host === "string" ? safe(account.host) : ""))
|
|
448
|
+
.filter(Boolean)
|
|
449
|
+
: [];
|
|
450
|
+
const distinctHosts = new Map(accountHosts.map((host) => [host.toLowerCase(), host]));
|
|
451
|
+
const soleAccountHost = distinctHosts.size === 1 ? distinctHosts.values().next().value : null;
|
|
452
|
+
|
|
453
|
+
return runtimeHost || ghHost || ghRepoHost || soleAccountHost || null;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function unavailableRemedy(accounts, targetHost) {
|
|
457
|
+
if (!Array.isArray(accounts) || !targetHost) return VERDICT_REMEDY.unavailable;
|
|
458
|
+
const normalizedTarget = String(targetHost).toLowerCase();
|
|
459
|
+
let matching = null;
|
|
460
|
+
for (const account of accounts) {
|
|
461
|
+
if (typeof account?.host !== "string" || typeof account?.login !== "string") continue;
|
|
462
|
+
const host = safe(account.host);
|
|
463
|
+
const login = safe(account.login);
|
|
464
|
+
if (!host || !login || host.toLowerCase() !== normalizedTarget) continue;
|
|
465
|
+
if (matching !== null) return VERDICT_REMEDY.unavailable;
|
|
466
|
+
matching = { host, login };
|
|
467
|
+
}
|
|
468
|
+
if (matching === null) return VERDICT_REMEDY.unavailable;
|
|
469
|
+
|
|
470
|
+
const { host, login } = matching;
|
|
471
|
+
const candidate = `Repository not found or inaccessible to ${login}@${host} -- check the target or run \`gh auth switch\``;
|
|
472
|
+
return candidate.length <= MAX_ERR_LENGTH ? candidate : VERDICT_REMEDY.unavailable;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function formatTabError(error, failureContext = null) {
|
|
476
|
+
if (error == null) return null;
|
|
477
|
+
if (error.kind === "text") return error.text;
|
|
478
|
+
if (error.verdict === "other") return error.raw;
|
|
479
|
+
if (error.verdict === "unavailable" && failureContext?.repo?.ok) {
|
|
480
|
+
return "not available for this repository";
|
|
481
|
+
}
|
|
482
|
+
if (error.verdict === "unavailable") {
|
|
483
|
+
return unavailableRemedy(failureContext?.accounts, failureContext?.targetHost);
|
|
484
|
+
}
|
|
485
|
+
return pick(VERDICT_REMEDY, error.verdict, null) ?? error.raw;
|
|
486
|
+
}
|
|
487
|
+
|
|
392
488
|
// Some pty wrappers (and a terminal mid-resize) report a size of 0 or
|
|
393
489
|
// undefined. Taking that literally collapses the table to a single row (or
|
|
394
490
|
// draws a zero-width border), so fall back to sane defaults until real
|
|
@@ -461,6 +557,7 @@ function logGh(args, startedAt, outcome) {
|
|
|
461
557
|
// outside React entirely -- there is no component left to read a ref from. Kept
|
|
462
558
|
// in this section, above every consumer, so nothing has to reach downward for it.
|
|
463
559
|
let liveAbort = null;
|
|
560
|
+
let setupChild = null;
|
|
464
561
|
function registerLiveAbort(controller) {
|
|
465
562
|
liveAbort = controller;
|
|
466
563
|
}
|
|
@@ -469,6 +566,13 @@ function abortLiveRequests() {
|
|
|
469
566
|
liveAbort = null;
|
|
470
567
|
}
|
|
471
568
|
|
|
569
|
+
function forwardSignalToChild(child, signal) {
|
|
570
|
+
// child.killed means only that kill() was called successfully; it becomes
|
|
571
|
+
// true before the process exits and therefore cannot gate SIGKILL escalation.
|
|
572
|
+
if (!child || child.exitCode !== null || child.signalCode !== null) return false;
|
|
573
|
+
return child.kill(signal);
|
|
574
|
+
}
|
|
575
|
+
|
|
472
576
|
async function runGh(args, { signal } = {}) {
|
|
473
577
|
const startedAt = Date.now();
|
|
474
578
|
try {
|
|
@@ -525,6 +629,146 @@ function apiPath(path) {
|
|
|
525
629
|
// inline, because `--doctor` reports these vectors and a second copy of them
|
|
526
630
|
// would be a report that drifts away from what the dashboard actually sends --
|
|
527
631
|
// which is the failure mode the whole diagnostics command exists to rule out.
|
|
632
|
+
function repoContextArgs() {
|
|
633
|
+
const target = runtime.repo ? qualifiedRepo() : null;
|
|
634
|
+
return [
|
|
635
|
+
"repo",
|
|
636
|
+
"view",
|
|
637
|
+
...(target ? [target] : []),
|
|
638
|
+
"--json",
|
|
639
|
+
"nameWithOwner,url,viewerPermission",
|
|
640
|
+
];
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function authContextArgs() {
|
|
644
|
+
return [
|
|
645
|
+
"auth",
|
|
646
|
+
"status",
|
|
647
|
+
"--active",
|
|
648
|
+
"--json",
|
|
649
|
+
"hosts",
|
|
650
|
+
"--jq",
|
|
651
|
+
".hosts | to_entries | map(.key as $host | .value[] | select(.active == true) | {host: $host, login: .login})",
|
|
652
|
+
];
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function parseRepoContext(raw) {
|
|
656
|
+
try {
|
|
657
|
+
const value = JSON.parse(raw);
|
|
658
|
+
if (
|
|
659
|
+
value == null ||
|
|
660
|
+
Array.isArray(value) ||
|
|
661
|
+
typeof value !== "object" ||
|
|
662
|
+
typeof value.nameWithOwner !== "string" ||
|
|
663
|
+
typeof value.url !== "string" ||
|
|
664
|
+
typeof value.viewerPermission !== "string"
|
|
665
|
+
) {
|
|
666
|
+
return null;
|
|
667
|
+
}
|
|
668
|
+
return {
|
|
669
|
+
ok: true,
|
|
670
|
+
nameWithOwner: safe(value.nameWithOwner),
|
|
671
|
+
url: safe(value.url),
|
|
672
|
+
viewerPermission: safe(value.viewerPermission),
|
|
673
|
+
};
|
|
674
|
+
} catch {
|
|
675
|
+
return null;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function parseAuthContext(raw) {
|
|
680
|
+
try {
|
|
681
|
+
const value = JSON.parse(raw);
|
|
682
|
+
if (!Array.isArray(value)) return null;
|
|
683
|
+
return value
|
|
684
|
+
.filter(
|
|
685
|
+
(row) =>
|
|
686
|
+
row != null &&
|
|
687
|
+
!Array.isArray(row) &&
|
|
688
|
+
typeof row === "object" &&
|
|
689
|
+
typeof row.host === "string" &&
|
|
690
|
+
typeof row.login === "string",
|
|
691
|
+
)
|
|
692
|
+
.map((row) => ({ host: safe(row.host), login: safe(row.login) }));
|
|
693
|
+
} catch {
|
|
694
|
+
return null;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function failedContext(err) {
|
|
699
|
+
return { ok: false, verdict: classify(err), raw: shortErr(err) };
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function missingFailureContext() {
|
|
703
|
+
return {
|
|
704
|
+
repo: { ok: false, verdict: "other", raw: "Repository context unavailable" },
|
|
705
|
+
accounts: null,
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function buildFailureContext(repoSettlement, authSettlement) {
|
|
710
|
+
const parsedRepo =
|
|
711
|
+
repoSettlement?.status === "fulfilled" ? parseRepoContext(repoSettlement.value) : null;
|
|
712
|
+
const repo =
|
|
713
|
+
parsedRepo ??
|
|
714
|
+
(repoSettlement?.status === "rejected"
|
|
715
|
+
? failedContext(repoSettlement.reason)
|
|
716
|
+
: missingFailureContext().repo);
|
|
717
|
+
const accounts =
|
|
718
|
+
authSettlement?.status === "fulfilled" ? parseAuthContext(authSettlement.value) : null;
|
|
719
|
+
return { repo, accounts };
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
async function resolveFailureContext(signal) {
|
|
723
|
+
const [repo, auth] = await Promise.allSettled([
|
|
724
|
+
runGh(repoContextArgs(), { signal }),
|
|
725
|
+
runGh(authContextArgs(), { signal }),
|
|
726
|
+
]);
|
|
727
|
+
return buildFailureContext(repo, auth);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function createFailureContextCoordinator({ resolve, commit, fallback }) {
|
|
731
|
+
let epoch = 0;
|
|
732
|
+
let value = null;
|
|
733
|
+
let inFlight = null;
|
|
734
|
+
|
|
735
|
+
function ensure(signal) {
|
|
736
|
+
const captured = epoch;
|
|
737
|
+
if (value !== null) return Promise.resolve(true);
|
|
738
|
+
if (inFlight?.epoch === captured) return inFlight.promise;
|
|
739
|
+
|
|
740
|
+
const promise = Promise.resolve()
|
|
741
|
+
.then(() => resolve(signal))
|
|
742
|
+
.then(
|
|
743
|
+
(result) => {
|
|
744
|
+
if (epoch !== captured) return false;
|
|
745
|
+
commit(result);
|
|
746
|
+
value = result;
|
|
747
|
+
return true;
|
|
748
|
+
},
|
|
749
|
+
() => {
|
|
750
|
+
if (epoch !== captured) return false;
|
|
751
|
+
commit(fallback);
|
|
752
|
+
value = fallback;
|
|
753
|
+
return false;
|
|
754
|
+
},
|
|
755
|
+
)
|
|
756
|
+
.finally(() => {
|
|
757
|
+
if (inFlight?.epoch === captured) inFlight = null;
|
|
758
|
+
});
|
|
759
|
+
inFlight = { epoch: captured, promise };
|
|
760
|
+
return promise;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function invalidate() {
|
|
764
|
+
epoch += 1;
|
|
765
|
+
value = null;
|
|
766
|
+
commit(null);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
return { ensure, invalidate };
|
|
770
|
+
}
|
|
771
|
+
|
|
528
772
|
function actionsArgs(limit) {
|
|
529
773
|
return [
|
|
530
774
|
"run",
|
|
@@ -714,6 +958,7 @@ function backoffActive(key, now) {
|
|
|
714
958
|
// comes back the moment it does. "rate-limited" used to be absent for the same
|
|
715
959
|
// stated reason, which had it backwards -- see RATE_LIMIT_RETRY_MS.
|
|
716
960
|
const FAILURE_LADDER = {
|
|
961
|
+
"no-remote": BACKOFF_STEPS_MS,
|
|
717
962
|
unavailable: BACKOFF_STEPS_MS,
|
|
718
963
|
"auth-problem": AUTH_RETRY_MS,
|
|
719
964
|
"rate-limited": RATE_LIMIT_RETRY_MS,
|
|
@@ -1097,6 +1342,7 @@ async function runDoctor() {
|
|
|
1097
1342
|
alertBackoff.clear();
|
|
1098
1343
|
|
|
1099
1344
|
const probes = [
|
|
1345
|
+
["Repository access", repoContextArgs()],
|
|
1100
1346
|
["Actions (run list)", actionsArgs(MIN_RUN_LIMIT)],
|
|
1101
1347
|
["Issues (issue list)", issuesArgs()],
|
|
1102
1348
|
["Pull requests (pr list)", prsArgs()],
|
|
@@ -1104,7 +1350,7 @@ async function runDoctor() {
|
|
|
1104
1350
|
];
|
|
1105
1351
|
|
|
1106
1352
|
// allSettled, and every probe already resolves rather than rejects, so one
|
|
1107
|
-
// slow endpoint cannot suppress the other
|
|
1353
|
+
// slow endpoint cannot suppress the other six. runGh's own GH_TIMEOUT_MS
|
|
1108
1354
|
// bounds each one.
|
|
1109
1355
|
const [ghVersion, authStatus, remote, budget, results] = await Promise.all([
|
|
1110
1356
|
captureGh(["--version"]),
|
|
@@ -1326,7 +1572,7 @@ const KEY_TABLE = [
|
|
|
1326
1572
|
["Tab / Shift+Tab", "Next / previous tab"],
|
|
1327
1573
|
["Up / Down, j / k", "Move the cursor between rows"],
|
|
1328
1574
|
["PgUp / PgDn", "Move a page at a time"],
|
|
1329
|
-
["Enter", "Open the selected item
|
|
1575
|
+
["Enter", "Open the selected item or accept a prompt"],
|
|
1330
1576
|
["r", "Refresh the current tab now"],
|
|
1331
1577
|
["?", "Show the keys (any key closes it)"],
|
|
1332
1578
|
["q / Esc / Ctrl+C", "Quit"],
|
|
@@ -2064,17 +2310,29 @@ const KEY_HINTS = [
|
|
|
2064
2310
|
{ label: "Quit", keys: "q" },
|
|
2065
2311
|
];
|
|
2066
2312
|
|
|
2313
|
+
const REMOTE_SETUP_HINTS = [
|
|
2314
|
+
{ label: "Create remote", keys: "Ent" },
|
|
2315
|
+
{ label: "Quit", keys: "q" },
|
|
2316
|
+
];
|
|
2317
|
+
|
|
2318
|
+
const REMOTE_SETUP_LINES = [
|
|
2319
|
+
"No GitHub remote found",
|
|
2320
|
+
"gh-glance needs a GitHub repository to show this dashboard.",
|
|
2321
|
+
"Enter Start `gh repo create` (choose Push an existing local repository)",
|
|
2322
|
+
"q/Esc Quit, or restart with `gh-glance --repo owner/name`",
|
|
2323
|
+
];
|
|
2324
|
+
|
|
2325
|
+
const REMOTE_SETUP_NONINTERACTIVE_LINES = [
|
|
2326
|
+
"No GitHub remote found",
|
|
2327
|
+
"Run `gh repo create` in an interactive terminal.",
|
|
2328
|
+
"Choose Push an existing local repository.",
|
|
2329
|
+
"Or use `gh-glance --repo owner/name`; Ctrl+C quits.",
|
|
2330
|
+
];
|
|
2331
|
+
|
|
2067
2332
|
// Reserved so the hints don't shift sideways every time a refresh starts and
|
|
2068
2333
|
// finishes. Wide enough for the spinner, a space and "Fetching".
|
|
2069
2334
|
const FETCHING_WIDTH = 12;
|
|
2070
2335
|
|
|
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
2336
|
// Compact drops the labels and keeps the keys, separated by a space:
|
|
2079
2337
|
// "↑↓ Ent r q". No constant for its width -- nothing branches on it, and the
|
|
2080
2338
|
// keys are short enough that it fits anywhere the frame itself does.
|
|
@@ -2087,22 +2345,24 @@ const HINTS_FULL_WIDTH =
|
|
|
2087
2345
|
// bar read "Move: | Open: | Refresh |Quit:…" -- the arrows gone, Refresh
|
|
2088
2346
|
// missing its key, and Quit, the last entry, first to be cut. That is the one
|
|
2089
2347
|
// 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
|
-
|
|
2092
2348
|
// Two tones rather than one flat gray: the keys you press are the part worth
|
|
2093
2349
|
// finding at a glance, so they get the accent colour and the words describing
|
|
2094
2350
|
// them stay dim. The accent is the panel-title cyan rather than the amber used
|
|
2095
2351
|
// for in-progress status, so amber means exactly one thing across the product.
|
|
2096
|
-
function StatusBar({ fetching, spin, stale, interactive, cols }) {
|
|
2352
|
+
function StatusBar({ fetching, spin, stale, interactive, cols, remoteSetup = false }) {
|
|
2097
2353
|
// Without raw mode none of the key handlers run, so advertising them would be
|
|
2098
2354
|
// telling the user something untrue about what the app can do. Ctrl+C still
|
|
2099
2355
|
// works there, because the tty delivers a real SIGINT.
|
|
2100
2356
|
const hints = interactive
|
|
2101
|
-
?
|
|
2357
|
+
? remoteSetup
|
|
2358
|
+
? REMOTE_SETUP_HINTS
|
|
2359
|
+
: KEY_HINTS
|
|
2102
2360
|
: [{ label: "Quit", keys: "^C" }];
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2361
|
+
const hintsFullWidth =
|
|
2362
|
+
hints.reduce((sum, hint) => sum + hint.label.length + 2 + [...hint.keys].length, 0) +
|
|
2363
|
+
(hints.length - 1) * 3;
|
|
2364
|
+
// Measured against the active full set even when a compact one is rendered.
|
|
2365
|
+
const compact = interactive && cols < FETCHING_WIDTH + hintsFullWidth;
|
|
2106
2366
|
return e(
|
|
2107
2367
|
Box,
|
|
2108
2368
|
{ flexDirection: "row" },
|
|
@@ -2116,7 +2376,7 @@ function StatusBar({ fetching, spin, stale, interactive, cols }) {
|
|
|
2116
2376
|
e(
|
|
2117
2377
|
Text,
|
|
2118
2378
|
{ color: fetching ? TITLE_COLOR : undefined, dimColor: !fetching },
|
|
2119
|
-
`${fetching && spin ? spin : SPINNER[0]} Fetching`,
|
|
2379
|
+
remoteSetup ? `${SPINNER[0]} Setup` : `${fetching && spin ? spin : SPINNER[0]} Fetching`,
|
|
2120
2380
|
),
|
|
2121
2381
|
),
|
|
2122
2382
|
// No reserved width here, unlike the fetching slot above: this only
|
|
@@ -2186,7 +2446,7 @@ function useTerminalSize(stdout) {
|
|
|
2186
2446
|
|
|
2187
2447
|
// ---------- App ----------
|
|
2188
2448
|
|
|
2189
|
-
function App() {
|
|
2449
|
+
function App({ onCreateRemote = () => {} } = {}) {
|
|
2190
2450
|
const { stdout } = useStdout();
|
|
2191
2451
|
const { isRawModeSupported } = useStdin();
|
|
2192
2452
|
const { exit } = useApp();
|
|
@@ -2207,7 +2467,12 @@ function App() {
|
|
|
2207
2467
|
// The `?` overlay. Renders only on a keypress, so consecutive idle frames are
|
|
2208
2468
|
// still byte-identical and the redraw suppression is untouched.
|
|
2209
2469
|
const [showHelp, setShowHelp] = useState(false);
|
|
2470
|
+
// Set once, ICON_HINT_AFTER_MS into a first load that is still running. Never
|
|
2471
|
+
// reset: it only gates a line that a resolved tab stops rendering anyway, and
|
|
2472
|
+
// clearing it would cost a second state write for no visible difference.
|
|
2473
|
+
const [iconHintDue, setIconHintDue] = useState(false);
|
|
2210
2474
|
const [errors, setErrors] = useState({ actions: null, issues: null, prs: null, security: null });
|
|
2475
|
+
const [failureContext, setFailureContext] = useState(null);
|
|
2211
2476
|
const [loading, setLoading] = useState({ actions: true, issues: true, prs: true, security: true });
|
|
2212
2477
|
const [now, setNow] = useState(new Date());
|
|
2213
2478
|
const { rows, cols, useShortLabels } = useTerminalSize(stdout);
|
|
@@ -2219,6 +2484,14 @@ function App() {
|
|
|
2219
2484
|
const [offset, setOffset] = useState({});
|
|
2220
2485
|
|
|
2221
2486
|
const tab = TABS[activeIndex];
|
|
2487
|
+
const tabError = errors[tab.key];
|
|
2488
|
+
// Once any endpoint proves the folder has no remote, the whole dashboard is
|
|
2489
|
+
// in setup mode. Keeping this tab-local made a quick tab switch replace the
|
|
2490
|
+
// onboarding prompt with a second raw fetch failure while another command
|
|
2491
|
+
// was still settling.
|
|
2492
|
+
const remoteSetup = Object.values(errors).some(
|
|
2493
|
+
(error) => error?.kind === "fetch" && error.verdict === "no-remote",
|
|
2494
|
+
);
|
|
2222
2495
|
// The not-enabled notes are collapsed into a single line. Each unavailable
|
|
2223
2496
|
// alert source used to contribute a full-width row above the column header,
|
|
2224
2497
|
// and on any repo without Advanced Security that is two permanent lines --
|
|
@@ -2241,7 +2514,8 @@ function App() {
|
|
|
2241
2514
|
// Counts the lines actually rendered, not the notes collected -- getting this
|
|
2242
2515
|
// wrong by one row is what makes ink repaint the whole frame.
|
|
2243
2516
|
const extraLines =
|
|
2244
|
-
(
|
|
2517
|
+
(tabError && !remoteSetup ? 1 : 0) +
|
|
2518
|
+
(tab.key === "security" && !remoteSetup ? securityLines.length : 0);
|
|
2245
2519
|
// Reserve lines for: the tab bar and the divider under it (2), the panel's
|
|
2246
2520
|
// top and bottom edges (2), the column header and its separator (2), and
|
|
2247
2521
|
// the status line (1), plus a 1-line safety margin. Slack is absorbed by
|
|
@@ -2279,6 +2553,7 @@ function App() {
|
|
|
2279
2553
|
// persistently failing tab would report itself fresh forever.
|
|
2280
2554
|
const lastOkRef = useRef({});
|
|
2281
2555
|
const fetchTabRef = useRef(null);
|
|
2556
|
+
const contextCoordinatorRef = useRef(null);
|
|
2282
2557
|
|
|
2283
2558
|
const interactive = Boolean(isRawModeSupported);
|
|
2284
2559
|
|
|
@@ -2300,6 +2575,8 @@ function App() {
|
|
|
2300
2575
|
// closure must see the current value without being rebuilt on every toggle.
|
|
2301
2576
|
const showHelpRef = useRef(false);
|
|
2302
2577
|
showHelpRef.current = showHelp;
|
|
2578
|
+
const remoteSetupRef = useRef(false);
|
|
2579
|
+
remoteSetupRef.current = remoteSetup;
|
|
2303
2580
|
|
|
2304
2581
|
function moveSelection(delta) {
|
|
2305
2582
|
const { items, key: currentKey, bodyRows: rows_, tabKey, offset: offsetRaw } = navRef.current;
|
|
@@ -2366,7 +2643,7 @@ function App() {
|
|
|
2366
2643
|
// unhandled rejection.
|
|
2367
2644
|
openInBrowser(tabKey, item)
|
|
2368
2645
|
.catch((err) => {
|
|
2369
|
-
setErrors((x) => ({ ...x, [tabKey]:
|
|
2646
|
+
setErrors((x) => ({ ...x, [tabKey]: textTabError(err) }));
|
|
2370
2647
|
})
|
|
2371
2648
|
.finally(() => {
|
|
2372
2649
|
delete openingRef.current[guard];
|
|
@@ -2394,13 +2671,15 @@ function App() {
|
|
|
2394
2671
|
} else if (key.pageUp) {
|
|
2395
2672
|
moveSelection(-pageStep);
|
|
2396
2673
|
} else if (key.return) {
|
|
2397
|
-
|
|
2674
|
+
if (remoteSetupRef.current) onCreateRemote();
|
|
2675
|
+
else openSelected();
|
|
2398
2676
|
} else if (input === "r") {
|
|
2399
2677
|
// Goes through the same per-tab in-flight guard as the poll loop, so
|
|
2400
2678
|
// holding the key down cannot stack concurrent subprocesses. `force`
|
|
2401
2679
|
// bypasses (and clears) any failure backoff: this key is the user saying
|
|
2402
2680
|
// "try again now", and a refresh that silently declined to refresh would
|
|
2403
2681
|
// be worse than no key at all.
|
|
2682
|
+
contextCoordinatorRef.current?.invalidate();
|
|
2404
2683
|
fetchTabRef.current?.(TABS[activeIndexRef.current].key, { force: true });
|
|
2405
2684
|
} else if (input >= "1" && input <= String(TABS.length)) {
|
|
2406
2685
|
setActiveIndex(Number(input) - 1);
|
|
@@ -2436,6 +2715,23 @@ function App() {
|
|
|
2436
2715
|
let ticks = 0;
|
|
2437
2716
|
const controller = new AbortController();
|
|
2438
2717
|
registerLiveAbort(controller);
|
|
2718
|
+
const withTargetHost = (context) => ({
|
|
2719
|
+
...context,
|
|
2720
|
+
targetHost: failureTargetHost({
|
|
2721
|
+
runtimeHost: runtime.host,
|
|
2722
|
+
ghHost: process.env.GH_HOST,
|
|
2723
|
+
ghRepo: process.env.GH_REPO,
|
|
2724
|
+
accounts: context.accounts,
|
|
2725
|
+
}),
|
|
2726
|
+
});
|
|
2727
|
+
const coordinator = createFailureContextCoordinator({
|
|
2728
|
+
resolve: async (signal) => withTargetHost(await resolveFailureContext(signal)),
|
|
2729
|
+
commit: (context) => {
|
|
2730
|
+
if (!cancelled) setFailureContext(context);
|
|
2731
|
+
},
|
|
2732
|
+
fallback: withTargetHost(missingFailureContext()),
|
|
2733
|
+
});
|
|
2734
|
+
contextCoordinatorRef.current = coordinator;
|
|
2439
2735
|
|
|
2440
2736
|
// Each tab commits its own result the moment it lands instead of waiting on
|
|
2441
2737
|
// a Promise.allSettled barrier. Actions is by far the slowest fetch, so
|
|
@@ -2501,13 +2797,14 @@ function App() {
|
|
|
2501
2797
|
.catch((err) => {
|
|
2502
2798
|
if (cancelled || err?.name === "AbortError") return;
|
|
2503
2799
|
cleanRef.current[key] = false;
|
|
2504
|
-
//
|
|
2505
|
-
//
|
|
2506
|
-
//
|
|
2507
|
-
//
|
|
2508
|
-
|
|
2509
|
-
const verdict =
|
|
2510
|
-
setErrors((x) => ({ ...x, [key]:
|
|
2800
|
+
// Preserve both the verdict and the bounded raw error in state. The
|
|
2801
|
+
// renderer translates recognized verdicts at draw time, which lets a
|
|
2802
|
+
// later repository/account context refine the one-line remedy without
|
|
2803
|
+
// throwing away the original evidence.
|
|
2804
|
+
const failure = toTabError(err);
|
|
2805
|
+
const verdict = failure.verdict;
|
|
2806
|
+
setErrors((x) => ({ ...x, [key]: failure }));
|
|
2807
|
+
if (verdict === "unavailable") coordinator.ensure(controller.signal);
|
|
2511
2808
|
// ...and back off, which the list tabs never did at all. A tab wedged
|
|
2512
2809
|
// on an expired token used to re-spawn `gh` every tick forever -- 720
|
|
2513
2810
|
// subprocesses an hour, indefinitely, against a token that is already
|
|
@@ -2539,6 +2836,11 @@ function App() {
|
|
|
2539
2836
|
fetchTabRef.current = fetchTab;
|
|
2540
2837
|
|
|
2541
2838
|
async function tick() {
|
|
2839
|
+
// Setup mode owns the whole screen and can only finish by handing off to
|
|
2840
|
+
// gh or exiting. Hidden tab retries cannot make that state more useful,
|
|
2841
|
+
// so stop spawning subprocesses once any endpoint proves there is no
|
|
2842
|
+
// remote. The interval may still wake, but this branch is otherwise idle.
|
|
2843
|
+
if (remoteSetupRef.current) return;
|
|
2542
2844
|
// Only the tab you're looking at needs to keep up with REFRESH_MS. The
|
|
2543
2845
|
// other three exist to keep the tab-bar counts honest, which tolerates
|
|
2544
2846
|
// being a minute behind -- so they refresh every BACKGROUND_EVERY ticks
|
|
@@ -2564,9 +2866,10 @@ function App() {
|
|
|
2564
2866
|
return () => {
|
|
2565
2867
|
cancelled = true;
|
|
2566
2868
|
clearInterval(id);
|
|
2869
|
+
if (contextCoordinatorRef.current === coordinator) contextCoordinatorRef.current = null;
|
|
2567
2870
|
// `cancelled` stops state updates from a promise that already resolved;
|
|
2568
2871
|
// the signal stops the subprocess itself, so quitting doesn't orphan up to
|
|
2569
|
-
//
|
|
2872
|
+
// eight `gh` children mid-request. They cover different windows and both are
|
|
2570
2873
|
// needed.
|
|
2571
2874
|
controller.abort();
|
|
2572
2875
|
};
|
|
@@ -2600,7 +2903,7 @@ function App() {
|
|
|
2600
2903
|
TABS.map((t) => [t.key, data[t.key] == null && !errors[t.key]]),
|
|
2601
2904
|
);
|
|
2602
2905
|
const anyFirstLoad = Object.values(firstLoad).some(Boolean);
|
|
2603
|
-
const showSpinner = ANIMATE && (anyFirstLoad || hasRunningVisible);
|
|
2906
|
+
const showSpinner = !remoteSetup && ANIMATE && (anyFirstLoad || hasRunningVisible);
|
|
2604
2907
|
useEffect(() => {
|
|
2605
2908
|
if (!showSpinner) {
|
|
2606
2909
|
// Park on a fixed frame rather than freezing wherever the animation
|
|
@@ -2613,8 +2916,20 @@ function App() {
|
|
|
2613
2916
|
return () => clearInterval(id);
|
|
2614
2917
|
}, [showSpinner]);
|
|
2615
2918
|
|
|
2919
|
+
// Armed on the same condition the loading line renders on, so a start that
|
|
2920
|
+
// resolves inside ICON_HINT_AFTER_MS clears the timer on the way past and
|
|
2921
|
+
// never writes state at all -- the fast path stays exactly as many renders as
|
|
2922
|
+
// it was. `anyFirstLoad` excludes tabs that failed (see firstLoad above), so a
|
|
2923
|
+
// wedged tab cannot hold this armed: an error line already says what happened,
|
|
2924
|
+
// and a font hint underneath it would be answering a question nobody asked.
|
|
2925
|
+
useEffect(() => {
|
|
2926
|
+
if (!anyFirstLoad || iconHintDue) return;
|
|
2927
|
+
const timer = setTimeout(() => setIconHintDue(true), ICON_HINT_AFTER_MS);
|
|
2928
|
+
return () => clearTimeout(timer);
|
|
2929
|
+
}, [anyFirstLoad, iconHintDue]);
|
|
2930
|
+
|
|
2616
2931
|
const items = data[tab.key];
|
|
2617
|
-
const
|
|
2932
|
+
const displayError = formatTabError(tabError, failureContext);
|
|
2618
2933
|
const spin = SPINNER[frame % SPINNER.length];
|
|
2619
2934
|
|
|
2620
2935
|
const counts = Object.fromEntries(
|
|
@@ -2652,7 +2967,9 @@ function App() {
|
|
|
2652
2967
|
];
|
|
2653
2968
|
}),
|
|
2654
2969
|
);
|
|
2655
|
-
const failed = Object.fromEntries(
|
|
2970
|
+
const failed = Object.fromEntries(
|
|
2971
|
+
TABS.map((t) => [t.key, Boolean(errors[t.key]) && errors[t.key]?.verdict !== "no-remote"]),
|
|
2972
|
+
);
|
|
2656
2973
|
|
|
2657
2974
|
// Data can be arbitrarily old without anything on screen saying so: a failing
|
|
2658
2975
|
// poll only surfaces on the tab you have selected, and after a laptop sleep
|
|
@@ -2789,15 +3106,37 @@ function App() {
|
|
|
2789
3106
|
!showHelp &&
|
|
2790
3107
|
tooNarrow &&
|
|
2791
3108
|
e(Text, { dimColor: true, wrap: "truncate-end" }, "too narrow"),
|
|
2792
|
-
!showHelp && !tooNarrow && error && e(Text, { color: ERROR_TEXT, wrap: "truncate-end" }, error),
|
|
2793
3109
|
!showHelp &&
|
|
2794
3110
|
!tooNarrow &&
|
|
3111
|
+
!remoteSetup &&
|
|
3112
|
+
displayError &&
|
|
3113
|
+
e(Text, { color: ERROR_TEXT, wrap: "truncate-end" }, displayError),
|
|
3114
|
+
!showHelp &&
|
|
3115
|
+
!tooNarrow &&
|
|
3116
|
+
!remoteSetup &&
|
|
2795
3117
|
tab.key === "security" &&
|
|
2796
3118
|
securityLines.map((note, i) =>
|
|
2797
3119
|
e(Text, { key: i, dimColor: true, wrap: "truncate-end" }, note),
|
|
2798
3120
|
),
|
|
2799
|
-
!showHelp &&
|
|
2800
|
-
|
|
3121
|
+
!showHelp &&
|
|
3122
|
+
!tooNarrow &&
|
|
3123
|
+
remoteSetup &&
|
|
3124
|
+
(interactive ? REMOTE_SETUP_LINES : REMOTE_SETUP_NONINTERACTIVE_LINES).map(
|
|
3125
|
+
(line, index) =>
|
|
3126
|
+
e(
|
|
3127
|
+
Text,
|
|
3128
|
+
{
|
|
3129
|
+
key: `remote-setup-${index}`,
|
|
3130
|
+
color: index === 0 ? TITLE_COLOR : undefined,
|
|
3131
|
+
bold: index === 0,
|
|
3132
|
+
dimColor: index === 1,
|
|
3133
|
+
wrap: "truncate-end",
|
|
3134
|
+
},
|
|
3135
|
+
line,
|
|
3136
|
+
),
|
|
3137
|
+
),
|
|
3138
|
+
!showHelp && !tooNarrow && !remoteSetup && e(HeaderCells, { cells: header }),
|
|
3139
|
+
...(tooNarrow || showHelp || remoteSetup ? [] : visibleItems).map((item) => {
|
|
2801
3140
|
const key = itemKey(item);
|
|
2802
3141
|
return e(
|
|
2803
3142
|
RowBoundary,
|
|
@@ -2827,19 +3166,21 @@ function App() {
|
|
|
2827
3166
|
// repository rather than the absence of an answer.
|
|
2828
3167
|
!showHelp &&
|
|
2829
3168
|
!tooNarrow &&
|
|
3169
|
+
!remoteSetup &&
|
|
2830
3170
|
visibleItems.length === 0 &&
|
|
2831
|
-
!(
|
|
3171
|
+
!(tabError && items == null) &&
|
|
2832
3172
|
e(
|
|
2833
3173
|
Text,
|
|
2834
3174
|
{ dimColor: true },
|
|
2835
3175
|
firstLoad[tab.key]
|
|
2836
3176
|
? `${showSpinner ? spin : SPINNER[0]} loading ${tab.label.toLowerCase()}…` +
|
|
2837
|
-
//
|
|
2838
|
-
//
|
|
2839
|
-
//
|
|
2840
|
-
//
|
|
2841
|
-
// then there is
|
|
2842
|
-
|
|
3177
|
+
// Appended only once a first fetch has been running for
|
|
3178
|
+
// ICON_HINT_AFTER_MS, so an ordinary start never shows it: the
|
|
3179
|
+
// remedy is for someone looking at a pane of blank boxes, and by
|
|
3180
|
+
// then they have been looking long enough to want an explanation.
|
|
3181
|
+
// Suppressed once icons are already ASCII, since then there is
|
|
3182
|
+
// nothing to fix.
|
|
3183
|
+
(USING_NERD_ICONS && iconHintDue ? " (icons blank? GH_GLANCE_ICONS=unicode)" : "")
|
|
2843
3184
|
: tab.key === "security" && securityNotes.length === ALERT_SOURCES.length
|
|
2844
3185
|
? "no alert sources available"
|
|
2845
3186
|
: `no ${tab.countLabel}`,
|
|
@@ -2856,6 +3197,7 @@ function App() {
|
|
|
2856
3197
|
stale: staleLabel,
|
|
2857
3198
|
interactive,
|
|
2858
3199
|
cols: frameCols,
|
|
3200
|
+
remoteSetup,
|
|
2859
3201
|
}),
|
|
2860
3202
|
);
|
|
2861
3203
|
}
|
|
@@ -2929,7 +3271,42 @@ if (IS_MAIN) {
|
|
|
2929
3271
|
// root box is the terminal height. Windows is already documented as untested
|
|
2930
3272
|
// (README Limitations), and the pty harness covers the two platforms that are
|
|
2931
3273
|
// supported, so the flag is verified where it is claimed to work.
|
|
2932
|
-
|
|
3274
|
+
let app;
|
|
3275
|
+
let remoteSetupStarted = false;
|
|
3276
|
+
const createRemote = () => {
|
|
3277
|
+
if (remoteSetupStarted) return;
|
|
3278
|
+
remoteSetupStarted = true;
|
|
3279
|
+
try {
|
|
3280
|
+
app?.unmount();
|
|
3281
|
+
} catch {
|
|
3282
|
+
// The interactive gh flow matters more than a best-effort final repaint.
|
|
3283
|
+
}
|
|
3284
|
+
restoreScreen();
|
|
3285
|
+
abortLiveRequests();
|
|
3286
|
+
|
|
3287
|
+
// Plain `gh repo create` is the interactive form. Supplying --source here
|
|
3288
|
+
// would switch gh to non-interactive mode and require gh-glance to choose a
|
|
3289
|
+
// visibility on the user's behalf, which this consent boundary must never
|
|
3290
|
+
// do. The prompt tells the user which interactive path matches this folder.
|
|
3291
|
+
const child = spawn("gh", ["repo", "create"], {
|
|
3292
|
+
stdio: "inherit",
|
|
3293
|
+
env: process.env,
|
|
3294
|
+
});
|
|
3295
|
+
setupChild = child;
|
|
3296
|
+
child.once("error", (err) => {
|
|
3297
|
+
if (setupChild === child) setupChild = null;
|
|
3298
|
+
console.error(`gh-glance: could not start repository setup: ${redact(shortErr(err))}`);
|
|
3299
|
+
process.exitCode = 1;
|
|
3300
|
+
});
|
|
3301
|
+
child.once("exit", (code, signal) => {
|
|
3302
|
+
if (setupChild === child) setupChild = null;
|
|
3303
|
+
process.exitCode = signal ? 1 : (code ?? 1);
|
|
3304
|
+
if (code === 0) {
|
|
3305
|
+
console.log("gh repo create finished. Run gh-glance again when this folder has a remote.");
|
|
3306
|
+
}
|
|
3307
|
+
});
|
|
3308
|
+
};
|
|
3309
|
+
app = render(e(App, { onCreateRemote: createRemote }), { incrementalRendering: true });
|
|
2933
3310
|
|
|
2934
3311
|
// 128 + signal number, so a supervisor or `timeout` can tell an interrupted
|
|
2935
3312
|
// run from a clean one. These fire on external `kill` and when raw mode is
|
|
@@ -2952,19 +3329,38 @@ if (IS_MAIN) {
|
|
|
2952
3329
|
// exiting immediately afterwards keeps the prompt-exit guarantee: waiting for
|
|
2953
3330
|
// the event loop to drain would let a hung `gh` turn Ctrl+C into an apparent
|
|
2954
3331
|
// hang.
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
3332
|
+
let signalExitStarted = false;
|
|
3333
|
+
const bySignal = (code, signal) => () => {
|
|
3334
|
+
if (signalExitStarted) return;
|
|
3335
|
+
signalExitStarted = true;
|
|
3336
|
+
const finish = () => {
|
|
3337
|
+
try {
|
|
3338
|
+
app.unmount();
|
|
3339
|
+
} catch {
|
|
3340
|
+
// Teardown is best effort. restoreScreen below, and the `exit` listener
|
|
3341
|
+
// above, both still run, so the terminal is handed back either way.
|
|
3342
|
+
}
|
|
3343
|
+
restoreScreen();
|
|
3344
|
+
process.exit(code);
|
|
3345
|
+
};
|
|
3346
|
+
|
|
3347
|
+
const child = setupChild;
|
|
3348
|
+
if (forwardSignalToChild(child, signal)) {
|
|
3349
|
+
const force = setTimeout(() => {
|
|
3350
|
+
forwardSignalToChild(child, "SIGKILL");
|
|
3351
|
+
finish();
|
|
3352
|
+
}, 1000);
|
|
3353
|
+
child.once("exit", () => {
|
|
3354
|
+
clearTimeout(force);
|
|
3355
|
+
finish();
|
|
3356
|
+
});
|
|
3357
|
+
return;
|
|
2961
3358
|
}
|
|
2962
|
-
|
|
2963
|
-
process.exit(code);
|
|
3359
|
+
finish();
|
|
2964
3360
|
};
|
|
2965
|
-
process.on("SIGINT", bySignal(130));
|
|
2966
|
-
process.on("SIGTERM", bySignal(143));
|
|
2967
|
-
process.on("SIGHUP", bySignal(129));
|
|
3361
|
+
process.on("SIGINT", bySignal(130, "SIGINT"));
|
|
3362
|
+
process.on("SIGTERM", bySignal(143, "SIGTERM"));
|
|
3363
|
+
process.on("SIGHUP", bySignal(129, "SIGHUP"));
|
|
2968
3364
|
}
|
|
2969
3365
|
|
|
2970
3366
|
// Exported for unit tests. The dashboard itself is still one file; these are
|
|
@@ -2983,6 +3379,16 @@ export {
|
|
|
2983
3379
|
isUnavailable,
|
|
2984
3380
|
isRateLimited,
|
|
2985
3381
|
isAuthProblem,
|
|
3382
|
+
isMissingRemote,
|
|
3383
|
+
forwardSignalToChild,
|
|
3384
|
+
toTabError,
|
|
3385
|
+
formatTabError,
|
|
3386
|
+
parseRepoContext,
|
|
3387
|
+
parseAuthContext,
|
|
3388
|
+
buildFailureContext,
|
|
3389
|
+
failureTargetHost,
|
|
3390
|
+
unavailableRemedy,
|
|
3391
|
+
createFailureContextCoordinator,
|
|
2986
3392
|
AUTH_RETRY_MS,
|
|
2987
3393
|
BACKOFF_STEPS_MS,
|
|
2988
3394
|
redact,
|
|
@@ -3003,6 +3409,9 @@ export {
|
|
|
3003
3409
|
OCT_UNICODE,
|
|
3004
3410
|
KEY_TABLE,
|
|
3005
3411
|
KEY_HINTS,
|
|
3412
|
+
REMOTE_SETUP_HINTS,
|
|
3413
|
+
REMOTE_SETUP_LINES,
|
|
3414
|
+
REMOTE_SETUP_NONINTERACTIVE_LINES,
|
|
3006
3415
|
VERDICT_REMEDY,
|
|
3007
3416
|
RATE_LIMIT_RETRY_MS,
|
|
3008
3417
|
FAILURE_LADDER,
|
package/package.json
CHANGED