gh-glance 0.5.1 → 0.6.1
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 +45 -0
- package/README.md +59 -5
- package/index.mjs +441 -53
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,49 @@ 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.1] - 2026-08-06
|
|
11
|
+
|
|
12
|
+
### Security
|
|
13
|
+
|
|
14
|
+
- **The GraphQL repository-resolution check no longer risks catastrophic
|
|
15
|
+
backtracking.** CodeQL flagged `isUnavailable()`'s `.*`-joined pattern as
|
|
16
|
+
worst-case superlinear over the repository name `gh` echoes back verbatim in
|
|
17
|
+
its error text -- attacker-influenced input reaching a vulnerable regex. It
|
|
18
|
+
is now two fixed-substring checks with the same matching behaviour and no
|
|
19
|
+
backtracking risk.
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- **The status bar shows gh-glance's own version**, right-aligned in the
|
|
24
|
+
lower-right corner the way lazygit's footer does. It is dropped on narrow
|
|
25
|
+
terminals rather than left to compete with the `Quit` hint for space.
|
|
26
|
+
|
|
27
|
+
## [0.6.0] - 2026-08-06
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- **An interactive missing-remote onboarding prompt.** Starting gh-glance in a
|
|
32
|
+
local repository with no remotes now offers to hand off to the user-confirmed
|
|
33
|
+
interactive `gh repo create` flow, while keeping quit and explicit `--repo`
|
|
34
|
+
paths visible. Declining the prompt makes no repository change.
|
|
35
|
+
- **Failure-triggered account and repository context.** After an ambiguous
|
|
36
|
+
repository-access failure, gh-glance performs read-only active-account and
|
|
37
|
+
repository checks through `gh`, and `--doctor` now includes a `Repository
|
|
38
|
+
access` probe.
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
|
|
42
|
+
- **Clean no-login failures now explain the delegated authentication path.**
|
|
43
|
+
Previously these were unclassified raw `gh` errors. They now direct the user
|
|
44
|
+
to login or authorization evidence through their own `gh` configuration and
|
|
45
|
+
remain retryable.
|
|
46
|
+
- **Inaccessible repository errors now converge on honest wording.** Actions'
|
|
47
|
+
REST 404 and Issues' GraphQL repository-resolution failure described the same
|
|
48
|
+
inaccessible target through unrelated messages. List tabs now say “not found
|
|
49
|
+
or inaccessible” without claiming whether the target is missing or private.
|
|
50
|
+
|
|
8
51
|
## [0.5.1] - 2026-08-04
|
|
9
52
|
|
|
10
53
|
### Changed
|
|
@@ -531,6 +574,8 @@ engineering, security, QA and UX. What follows is what changed as a result.
|
|
|
531
574
|
- The `main` field from `package.json`. It advertised the file as importable,
|
|
532
575
|
but importing it took over the terminal or exited the host process.
|
|
533
576
|
|
|
577
|
+
[0.6.1]: https://github.com/juan294/gh-glance/releases/tag/v0.6.1
|
|
578
|
+
[0.6.0]: https://github.com/juan294/gh-glance/releases/tag/v0.6.0
|
|
534
579
|
[0.5.1]: https://github.com/juan294/gh-glance/releases/tag/v0.5.1
|
|
535
580
|
[0.5.0]: https://github.com/juan294/gh-glance/releases/tag/v0.5.0
|
|
536
581
|
[0.4.1]: https://github.com/juan294/gh-glance/releases/tag/v0.4.1
|
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";
|
|
@@ -333,8 +333,19 @@ function errText(err) {
|
|
|
333
333
|
// "you can't see this here" -- everything else (auth expiry, rate limiting,
|
|
334
334
|
// DNS, a 502) is a real failure that used to be reported as a confident,
|
|
335
335
|
// plausible, and wrong claim that the feature was switched off.
|
|
336
|
+
// The GraphQL variant is matched as two fixed substrings rather than one
|
|
337
|
+
// `.*`-joined regex: CodeQL (js/polynomial-redos) flags a wildcard spanning
|
|
338
|
+
// attacker-influenced text -- a repository name gh echoes back verbatim --
|
|
339
|
+
// as worst-case superlinear. Two `includes` calls are index-scan cheap
|
|
340
|
+
// regardless of what sits between the markers and carry no backtracking risk.
|
|
336
341
|
function isUnavailable(err) {
|
|
337
|
-
|
|
342
|
+
const text = errText(err);
|
|
343
|
+
const lower = text.toLowerCase();
|
|
344
|
+
return (
|
|
345
|
+
/HTTP (403|404)/.test(text) ||
|
|
346
|
+
(lower.includes("could not resolve to a repository with the name") &&
|
|
347
|
+
lower.includes("(repository)"))
|
|
348
|
+
);
|
|
338
349
|
}
|
|
339
350
|
|
|
340
351
|
function isRateLimited(err) {
|
|
@@ -359,12 +370,20 @@ function isRateLimited(err) {
|
|
|
359
370
|
// entirely in the positive. It stays clear of the genuine not-enabled messages,
|
|
360
371
|
// which talk about features rather than access.
|
|
361
372
|
const AUTH_MARKERS =
|
|
362
|
-
/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;
|
|
373
|
+
/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;
|
|
363
374
|
|
|
364
375
|
function isAuthProblem(err) {
|
|
365
376
|
return AUTH_MARKERS.test(errText(err));
|
|
366
377
|
}
|
|
367
378
|
|
|
379
|
+
// A local repository with no remote is an onboarding state, not an API or
|
|
380
|
+
// authentication failure. Both strings are emitted by current gh commands:
|
|
381
|
+
// list commands include the "failed to determine base repo" prefix, while
|
|
382
|
+
// `gh repo view` returns the shorter form.
|
|
383
|
+
function isMissingRemote(err) {
|
|
384
|
+
return /(?:failed to determine base repo:\s*)?no git remotes found/i.test(errText(err));
|
|
385
|
+
}
|
|
386
|
+
|
|
368
387
|
// The one place the three predicates are turned into a verdict. Both consumers
|
|
369
388
|
// go through it -- the fetcher, to choose a note and a backoff ladder, and
|
|
370
389
|
// `--doctor`, to report what gh-glance concluded -- so the report cannot claim
|
|
@@ -380,6 +399,7 @@ function isAuthProblem(err) {
|
|
|
380
399
|
// repository's configuration.
|
|
381
400
|
function classify(err) {
|
|
382
401
|
if (err == null) return "ok";
|
|
402
|
+
if (isMissingRemote(err)) return "no-remote";
|
|
383
403
|
if (isRateLimited(err)) return "rate-limited";
|
|
384
404
|
if (isAuthProblem(err)) return "auth-problem";
|
|
385
405
|
if (isUnavailable(err)) return "unavailable";
|
|
@@ -401,11 +421,77 @@ function classify(err) {
|
|
|
401
421
|
// positive that only picked a retry ladder was cheap, whereas one that also
|
|
402
422
|
// rewrites the on-screen text turns into a confidently wrong instruction.
|
|
403
423
|
const VERDICT_REMEDY = {
|
|
404
|
-
"
|
|
424
|
+
"no-remote":
|
|
425
|
+
"No GitHub remote found -- press Enter to create one, or use `gh-glance --repo owner/name`",
|
|
426
|
+
"auth-problem":
|
|
427
|
+
"GitHub login or authorization required -- run `gh auth status`, then `gh auth login` or `gh auth refresh`",
|
|
405
428
|
"rate-limited": "GitHub rate limit reached -- backing off, this clears on its own",
|
|
406
|
-
unavailable:
|
|
429
|
+
unavailable:
|
|
430
|
+
"Repository not found or inaccessible to the active `gh` account -- check `gh auth status` and the repository target",
|
|
407
431
|
};
|
|
408
432
|
|
|
433
|
+
function toTabError(err) {
|
|
434
|
+
return { kind: "fetch", verdict: classify(err), raw: shortErr(err) };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function textTabError(err) {
|
|
438
|
+
return { kind: "text", text: shortErr(err) };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function failureTargetHost({ runtimeHost, ghHost, ghRepo, accounts } = {}) {
|
|
442
|
+
let ghRepoHost = null;
|
|
443
|
+
if (ghRepo) {
|
|
444
|
+
try {
|
|
445
|
+
ghRepoHost = parseRepoTarget(ghRepo).host;
|
|
446
|
+
} catch {
|
|
447
|
+
// GH_REPO is external process state, not validated argv. An invalid value
|
|
448
|
+
// is not evidence about which host an unavailable repository targeted.
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const accountHosts = Array.isArray(accounts)
|
|
453
|
+
? accounts
|
|
454
|
+
.map((account) => (typeof account?.host === "string" ? safe(account.host) : ""))
|
|
455
|
+
.filter(Boolean)
|
|
456
|
+
: [];
|
|
457
|
+
const distinctHosts = new Map(accountHosts.map((host) => [host.toLowerCase(), host]));
|
|
458
|
+
const soleAccountHost = distinctHosts.size === 1 ? distinctHosts.values().next().value : null;
|
|
459
|
+
|
|
460
|
+
return runtimeHost || ghHost || ghRepoHost || soleAccountHost || null;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function unavailableRemedy(accounts, targetHost) {
|
|
464
|
+
if (!Array.isArray(accounts) || !targetHost) return VERDICT_REMEDY.unavailable;
|
|
465
|
+
const normalizedTarget = String(targetHost).toLowerCase();
|
|
466
|
+
let matching = null;
|
|
467
|
+
for (const account of accounts) {
|
|
468
|
+
if (typeof account?.host !== "string" || typeof account?.login !== "string") continue;
|
|
469
|
+
const host = safe(account.host);
|
|
470
|
+
const login = safe(account.login);
|
|
471
|
+
if (!host || !login || host.toLowerCase() !== normalizedTarget) continue;
|
|
472
|
+
if (matching !== null) return VERDICT_REMEDY.unavailable;
|
|
473
|
+
matching = { host, login };
|
|
474
|
+
}
|
|
475
|
+
if (matching === null) return VERDICT_REMEDY.unavailable;
|
|
476
|
+
|
|
477
|
+
const { host, login } = matching;
|
|
478
|
+
const candidate = `Repository not found or inaccessible to ${login}@${host} -- check the target or run \`gh auth switch\``;
|
|
479
|
+
return candidate.length <= MAX_ERR_LENGTH ? candidate : VERDICT_REMEDY.unavailable;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function formatTabError(error, failureContext = null) {
|
|
483
|
+
if (error == null) return null;
|
|
484
|
+
if (error.kind === "text") return error.text;
|
|
485
|
+
if (error.verdict === "other") return error.raw;
|
|
486
|
+
if (error.verdict === "unavailable" && failureContext?.repo?.ok) {
|
|
487
|
+
return "not available for this repository";
|
|
488
|
+
}
|
|
489
|
+
if (error.verdict === "unavailable") {
|
|
490
|
+
return unavailableRemedy(failureContext?.accounts, failureContext?.targetHost);
|
|
491
|
+
}
|
|
492
|
+
return pick(VERDICT_REMEDY, error.verdict, null) ?? error.raw;
|
|
493
|
+
}
|
|
494
|
+
|
|
409
495
|
// Some pty wrappers (and a terminal mid-resize) report a size of 0 or
|
|
410
496
|
// undefined. Taking that literally collapses the table to a single row (or
|
|
411
497
|
// draws a zero-width border), so fall back to sane defaults until real
|
|
@@ -478,6 +564,7 @@ function logGh(args, startedAt, outcome) {
|
|
|
478
564
|
// outside React entirely -- there is no component left to read a ref from. Kept
|
|
479
565
|
// in this section, above every consumer, so nothing has to reach downward for it.
|
|
480
566
|
let liveAbort = null;
|
|
567
|
+
let setupChild = null;
|
|
481
568
|
function registerLiveAbort(controller) {
|
|
482
569
|
liveAbort = controller;
|
|
483
570
|
}
|
|
@@ -486,6 +573,13 @@ function abortLiveRequests() {
|
|
|
486
573
|
liveAbort = null;
|
|
487
574
|
}
|
|
488
575
|
|
|
576
|
+
function forwardSignalToChild(child, signal) {
|
|
577
|
+
// child.killed means only that kill() was called successfully; it becomes
|
|
578
|
+
// true before the process exits and therefore cannot gate SIGKILL escalation.
|
|
579
|
+
if (!child || child.exitCode !== null || child.signalCode !== null) return false;
|
|
580
|
+
return child.kill(signal);
|
|
581
|
+
}
|
|
582
|
+
|
|
489
583
|
async function runGh(args, { signal } = {}) {
|
|
490
584
|
const startedAt = Date.now();
|
|
491
585
|
try {
|
|
@@ -542,6 +636,146 @@ function apiPath(path) {
|
|
|
542
636
|
// inline, because `--doctor` reports these vectors and a second copy of them
|
|
543
637
|
// would be a report that drifts away from what the dashboard actually sends --
|
|
544
638
|
// which is the failure mode the whole diagnostics command exists to rule out.
|
|
639
|
+
function repoContextArgs() {
|
|
640
|
+
const target = runtime.repo ? qualifiedRepo() : null;
|
|
641
|
+
return [
|
|
642
|
+
"repo",
|
|
643
|
+
"view",
|
|
644
|
+
...(target ? [target] : []),
|
|
645
|
+
"--json",
|
|
646
|
+
"nameWithOwner,url,viewerPermission",
|
|
647
|
+
];
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function authContextArgs() {
|
|
651
|
+
return [
|
|
652
|
+
"auth",
|
|
653
|
+
"status",
|
|
654
|
+
"--active",
|
|
655
|
+
"--json",
|
|
656
|
+
"hosts",
|
|
657
|
+
"--jq",
|
|
658
|
+
".hosts | to_entries | map(.key as $host | .value[] | select(.active == true) | {host: $host, login: .login})",
|
|
659
|
+
];
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function parseRepoContext(raw) {
|
|
663
|
+
try {
|
|
664
|
+
const value = JSON.parse(raw);
|
|
665
|
+
if (
|
|
666
|
+
value == null ||
|
|
667
|
+
Array.isArray(value) ||
|
|
668
|
+
typeof value !== "object" ||
|
|
669
|
+
typeof value.nameWithOwner !== "string" ||
|
|
670
|
+
typeof value.url !== "string" ||
|
|
671
|
+
typeof value.viewerPermission !== "string"
|
|
672
|
+
) {
|
|
673
|
+
return null;
|
|
674
|
+
}
|
|
675
|
+
return {
|
|
676
|
+
ok: true,
|
|
677
|
+
nameWithOwner: safe(value.nameWithOwner),
|
|
678
|
+
url: safe(value.url),
|
|
679
|
+
viewerPermission: safe(value.viewerPermission),
|
|
680
|
+
};
|
|
681
|
+
} catch {
|
|
682
|
+
return null;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function parseAuthContext(raw) {
|
|
687
|
+
try {
|
|
688
|
+
const value = JSON.parse(raw);
|
|
689
|
+
if (!Array.isArray(value)) return null;
|
|
690
|
+
return value
|
|
691
|
+
.filter(
|
|
692
|
+
(row) =>
|
|
693
|
+
row != null &&
|
|
694
|
+
!Array.isArray(row) &&
|
|
695
|
+
typeof row === "object" &&
|
|
696
|
+
typeof row.host === "string" &&
|
|
697
|
+
typeof row.login === "string",
|
|
698
|
+
)
|
|
699
|
+
.map((row) => ({ host: safe(row.host), login: safe(row.login) }));
|
|
700
|
+
} catch {
|
|
701
|
+
return null;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function failedContext(err) {
|
|
706
|
+
return { ok: false, verdict: classify(err), raw: shortErr(err) };
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function missingFailureContext() {
|
|
710
|
+
return {
|
|
711
|
+
repo: { ok: false, verdict: "other", raw: "Repository context unavailable" },
|
|
712
|
+
accounts: null,
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function buildFailureContext(repoSettlement, authSettlement) {
|
|
717
|
+
const parsedRepo =
|
|
718
|
+
repoSettlement?.status === "fulfilled" ? parseRepoContext(repoSettlement.value) : null;
|
|
719
|
+
const repo =
|
|
720
|
+
parsedRepo ??
|
|
721
|
+
(repoSettlement?.status === "rejected"
|
|
722
|
+
? failedContext(repoSettlement.reason)
|
|
723
|
+
: missingFailureContext().repo);
|
|
724
|
+
const accounts =
|
|
725
|
+
authSettlement?.status === "fulfilled" ? parseAuthContext(authSettlement.value) : null;
|
|
726
|
+
return { repo, accounts };
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
async function resolveFailureContext(signal) {
|
|
730
|
+
const [repo, auth] = await Promise.allSettled([
|
|
731
|
+
runGh(repoContextArgs(), { signal }),
|
|
732
|
+
runGh(authContextArgs(), { signal }),
|
|
733
|
+
]);
|
|
734
|
+
return buildFailureContext(repo, auth);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function createFailureContextCoordinator({ resolve, commit, fallback }) {
|
|
738
|
+
let epoch = 0;
|
|
739
|
+
let value = null;
|
|
740
|
+
let inFlight = null;
|
|
741
|
+
|
|
742
|
+
function ensure(signal) {
|
|
743
|
+
const captured = epoch;
|
|
744
|
+
if (value !== null) return Promise.resolve(true);
|
|
745
|
+
if (inFlight?.epoch === captured) return inFlight.promise;
|
|
746
|
+
|
|
747
|
+
const promise = Promise.resolve()
|
|
748
|
+
.then(() => resolve(signal))
|
|
749
|
+
.then(
|
|
750
|
+
(result) => {
|
|
751
|
+
if (epoch !== captured) return false;
|
|
752
|
+
commit(result);
|
|
753
|
+
value = result;
|
|
754
|
+
return true;
|
|
755
|
+
},
|
|
756
|
+
() => {
|
|
757
|
+
if (epoch !== captured) return false;
|
|
758
|
+
commit(fallback);
|
|
759
|
+
value = fallback;
|
|
760
|
+
return false;
|
|
761
|
+
},
|
|
762
|
+
)
|
|
763
|
+
.finally(() => {
|
|
764
|
+
if (inFlight?.epoch === captured) inFlight = null;
|
|
765
|
+
});
|
|
766
|
+
inFlight = { epoch: captured, promise };
|
|
767
|
+
return promise;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function invalidate() {
|
|
771
|
+
epoch += 1;
|
|
772
|
+
value = null;
|
|
773
|
+
commit(null);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
return { ensure, invalidate };
|
|
777
|
+
}
|
|
778
|
+
|
|
545
779
|
function actionsArgs(limit) {
|
|
546
780
|
return [
|
|
547
781
|
"run",
|
|
@@ -731,6 +965,7 @@ function backoffActive(key, now) {
|
|
|
731
965
|
// comes back the moment it does. "rate-limited" used to be absent for the same
|
|
732
966
|
// stated reason, which had it backwards -- see RATE_LIMIT_RETRY_MS.
|
|
733
967
|
const FAILURE_LADDER = {
|
|
968
|
+
"no-remote": BACKOFF_STEPS_MS,
|
|
734
969
|
unavailable: BACKOFF_STEPS_MS,
|
|
735
970
|
"auth-problem": AUTH_RETRY_MS,
|
|
736
971
|
"rate-limited": RATE_LIMIT_RETRY_MS,
|
|
@@ -1114,6 +1349,7 @@ async function runDoctor() {
|
|
|
1114
1349
|
alertBackoff.clear();
|
|
1115
1350
|
|
|
1116
1351
|
const probes = [
|
|
1352
|
+
["Repository access", repoContextArgs()],
|
|
1117
1353
|
["Actions (run list)", actionsArgs(MIN_RUN_LIMIT)],
|
|
1118
1354
|
["Issues (issue list)", issuesArgs()],
|
|
1119
1355
|
["Pull requests (pr list)", prsArgs()],
|
|
@@ -1121,7 +1357,7 @@ async function runDoctor() {
|
|
|
1121
1357
|
];
|
|
1122
1358
|
|
|
1123
1359
|
// allSettled, and every probe already resolves rather than rejects, so one
|
|
1124
|
-
// slow endpoint cannot suppress the other
|
|
1360
|
+
// slow endpoint cannot suppress the other six. runGh's own GH_TIMEOUT_MS
|
|
1125
1361
|
// bounds each one.
|
|
1126
1362
|
const [ghVersion, authStatus, remote, budget, results] = await Promise.all([
|
|
1127
1363
|
captureGh(["--version"]),
|
|
@@ -1343,7 +1579,7 @@ const KEY_TABLE = [
|
|
|
1343
1579
|
["Tab / Shift+Tab", "Next / previous tab"],
|
|
1344
1580
|
["Up / Down, j / k", "Move the cursor between rows"],
|
|
1345
1581
|
["PgUp / PgDn", "Move a page at a time"],
|
|
1346
|
-
["Enter", "Open the selected item
|
|
1582
|
+
["Enter", "Open the selected item or accept a prompt"],
|
|
1347
1583
|
["r", "Refresh the current tab now"],
|
|
1348
1584
|
["?", "Show the keys (any key closes it)"],
|
|
1349
1585
|
["q / Esc / Ctrl+C", "Quit"],
|
|
@@ -2081,17 +2317,29 @@ const KEY_HINTS = [
|
|
|
2081
2317
|
{ label: "Quit", keys: "q" },
|
|
2082
2318
|
];
|
|
2083
2319
|
|
|
2320
|
+
const REMOTE_SETUP_HINTS = [
|
|
2321
|
+
{ label: "Create remote", keys: "Ent" },
|
|
2322
|
+
{ label: "Quit", keys: "q" },
|
|
2323
|
+
];
|
|
2324
|
+
|
|
2325
|
+
const REMOTE_SETUP_LINES = [
|
|
2326
|
+
"No GitHub remote found",
|
|
2327
|
+
"gh-glance needs a GitHub repository to show this dashboard.",
|
|
2328
|
+
"Enter Start `gh repo create` (choose Push an existing local repository)",
|
|
2329
|
+
"q/Esc Quit, or restart with `gh-glance --repo owner/name`",
|
|
2330
|
+
];
|
|
2331
|
+
|
|
2332
|
+
const REMOTE_SETUP_NONINTERACTIVE_LINES = [
|
|
2333
|
+
"No GitHub remote found",
|
|
2334
|
+
"Run `gh repo create` in an interactive terminal.",
|
|
2335
|
+
"Choose Push an existing local repository.",
|
|
2336
|
+
"Or use `gh-glance --repo owner/name`; Ctrl+C quits.",
|
|
2337
|
+
];
|
|
2338
|
+
|
|
2084
2339
|
// Reserved so the hints don't shift sideways every time a refresh starts and
|
|
2085
2340
|
// finishes. Wide enough for the spinner, a space and "Fetching".
|
|
2086
2341
|
const FETCHING_WIDTH = 12;
|
|
2087
2342
|
|
|
2088
|
-
// Width the full hint set needs: every "Label: keys" plus a " | " between each.
|
|
2089
|
-
// Derived from KEY_HINTS rather than written down, for the same reason
|
|
2090
|
-
// minimumWidthFor() derives the table breakpoint from its header descriptors --
|
|
2091
|
-
// a copy change must not be able to invalidate the number.
|
|
2092
|
-
const HINTS_FULL_WIDTH =
|
|
2093
|
-
KEY_HINTS.reduce((sum, h) => sum + h.label.length + 2 + [...h.keys].length, 0) +
|
|
2094
|
-
(KEY_HINTS.length - 1) * 3;
|
|
2095
2343
|
// Compact drops the labels and keeps the keys, separated by a space:
|
|
2096
2344
|
// "↑↓ Ent r q". No constant for its width -- nothing branches on it, and the
|
|
2097
2345
|
// keys are short enough that it fits anywhere the frame itself does.
|
|
@@ -2104,22 +2352,24 @@ const HINTS_FULL_WIDTH =
|
|
|
2104
2352
|
// bar read "Move: | Open: | Refresh |Quit:…" -- the arrows gone, Refresh
|
|
2105
2353
|
// missing its key, and Quit, the last entry, first to be cut. That is the one
|
|
2106
2354
|
// hint a confused first-time user needs, in a full-screen alternate-screen app.
|
|
2107
|
-
const STATUS_FULL_WIDTH = FETCHING_WIDTH + HINTS_FULL_WIDTH;
|
|
2108
|
-
|
|
2109
2355
|
// Two tones rather than one flat gray: the keys you press are the part worth
|
|
2110
2356
|
// finding at a glance, so they get the accent colour and the words describing
|
|
2111
2357
|
// them stay dim. The accent is the panel-title cyan rather than the amber used
|
|
2112
2358
|
// for in-progress status, so amber means exactly one thing across the product.
|
|
2113
|
-
function StatusBar({ fetching, spin, stale, interactive, cols }) {
|
|
2359
|
+
function StatusBar({ fetching, spin, stale, interactive, cols, remoteSetup = false }) {
|
|
2114
2360
|
// Without raw mode none of the key handlers run, so advertising them would be
|
|
2115
2361
|
// telling the user something untrue about what the app can do. Ctrl+C still
|
|
2116
2362
|
// works there, because the tty delivers a real SIGINT.
|
|
2117
2363
|
const hints = interactive
|
|
2118
|
-
?
|
|
2364
|
+
? remoteSetup
|
|
2365
|
+
? REMOTE_SETUP_HINTS
|
|
2366
|
+
: KEY_HINTS
|
|
2119
2367
|
: [{ label: "Quit", keys: "^C" }];
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2368
|
+
const hintsFullWidth =
|
|
2369
|
+
hints.reduce((sum, hint) => sum + hint.label.length + 2 + [...hint.keys].length, 0) +
|
|
2370
|
+
(hints.length - 1) * 3;
|
|
2371
|
+
// Measured against the active full set even when a compact one is rendered.
|
|
2372
|
+
const compact = interactive && cols < FETCHING_WIDTH + hintsFullWidth;
|
|
2123
2373
|
return e(
|
|
2124
2374
|
Box,
|
|
2125
2375
|
{ flexDirection: "row" },
|
|
@@ -2133,7 +2383,7 @@ function StatusBar({ fetching, spin, stale, interactive, cols }) {
|
|
|
2133
2383
|
e(
|
|
2134
2384
|
Text,
|
|
2135
2385
|
{ color: fetching ? TITLE_COLOR : undefined, dimColor: !fetching },
|
|
2136
|
-
`${fetching && spin ? spin : SPINNER[0]} Fetching`,
|
|
2386
|
+
remoteSetup ? `${SPINNER[0]} Setup` : `${fetching && spin ? spin : SPINNER[0]} Fetching`,
|
|
2137
2387
|
),
|
|
2138
2388
|
),
|
|
2139
2389
|
// No reserved width here, unlike the fetching slot above: this only
|
|
@@ -2155,6 +2405,12 @@ function StatusBar({ fetching, spin, stale, interactive, cols }) {
|
|
|
2155
2405
|
),
|
|
2156
2406
|
])
|
|
2157
2407
|
.filter(Boolean),
|
|
2408
|
+
// Right-aligned, like lazygit's footer. Dropped in compact mode rather than
|
|
2409
|
+
// left to shrink alongside the hints: it would compete for the same shrink
|
|
2410
|
+
// budget as "Quit", the one hint the comment above already fought to keep
|
|
2411
|
+
// on screen down to 45 columns, and version digits are not worth that.
|
|
2412
|
+
!compact && e(Box, { flexGrow: 1 }),
|
|
2413
|
+
!compact && e(Text, { key: "version", dimColor: true, wrap: "truncate-end" }, version),
|
|
2158
2414
|
);
|
|
2159
2415
|
}
|
|
2160
2416
|
|
|
@@ -2203,7 +2459,7 @@ function useTerminalSize(stdout) {
|
|
|
2203
2459
|
|
|
2204
2460
|
// ---------- App ----------
|
|
2205
2461
|
|
|
2206
|
-
function App() {
|
|
2462
|
+
function App({ onCreateRemote = () => {} } = {}) {
|
|
2207
2463
|
const { stdout } = useStdout();
|
|
2208
2464
|
const { isRawModeSupported } = useStdin();
|
|
2209
2465
|
const { exit } = useApp();
|
|
@@ -2229,6 +2485,7 @@ function App() {
|
|
|
2229
2485
|
// clearing it would cost a second state write for no visible difference.
|
|
2230
2486
|
const [iconHintDue, setIconHintDue] = useState(false);
|
|
2231
2487
|
const [errors, setErrors] = useState({ actions: null, issues: null, prs: null, security: null });
|
|
2488
|
+
const [failureContext, setFailureContext] = useState(null);
|
|
2232
2489
|
const [loading, setLoading] = useState({ actions: true, issues: true, prs: true, security: true });
|
|
2233
2490
|
const [now, setNow] = useState(new Date());
|
|
2234
2491
|
const { rows, cols, useShortLabels } = useTerminalSize(stdout);
|
|
@@ -2240,6 +2497,14 @@ function App() {
|
|
|
2240
2497
|
const [offset, setOffset] = useState({});
|
|
2241
2498
|
|
|
2242
2499
|
const tab = TABS[activeIndex];
|
|
2500
|
+
const tabError = errors[tab.key];
|
|
2501
|
+
// Once any endpoint proves the folder has no remote, the whole dashboard is
|
|
2502
|
+
// in setup mode. Keeping this tab-local made a quick tab switch replace the
|
|
2503
|
+
// onboarding prompt with a second raw fetch failure while another command
|
|
2504
|
+
// was still settling.
|
|
2505
|
+
const remoteSetup = Object.values(errors).some(
|
|
2506
|
+
(error) => error?.kind === "fetch" && error.verdict === "no-remote",
|
|
2507
|
+
);
|
|
2243
2508
|
// The not-enabled notes are collapsed into a single line. Each unavailable
|
|
2244
2509
|
// alert source used to contribute a full-width row above the column header,
|
|
2245
2510
|
// and on any repo without Advanced Security that is two permanent lines --
|
|
@@ -2262,7 +2527,8 @@ function App() {
|
|
|
2262
2527
|
// Counts the lines actually rendered, not the notes collected -- getting this
|
|
2263
2528
|
// wrong by one row is what makes ink repaint the whole frame.
|
|
2264
2529
|
const extraLines =
|
|
2265
|
-
(
|
|
2530
|
+
(tabError && !remoteSetup ? 1 : 0) +
|
|
2531
|
+
(tab.key === "security" && !remoteSetup ? securityLines.length : 0);
|
|
2266
2532
|
// Reserve lines for: the tab bar and the divider under it (2), the panel's
|
|
2267
2533
|
// top and bottom edges (2), the column header and its separator (2), and
|
|
2268
2534
|
// the status line (1), plus a 1-line safety margin. Slack is absorbed by
|
|
@@ -2300,6 +2566,7 @@ function App() {
|
|
|
2300
2566
|
// persistently failing tab would report itself fresh forever.
|
|
2301
2567
|
const lastOkRef = useRef({});
|
|
2302
2568
|
const fetchTabRef = useRef(null);
|
|
2569
|
+
const contextCoordinatorRef = useRef(null);
|
|
2303
2570
|
|
|
2304
2571
|
const interactive = Boolean(isRawModeSupported);
|
|
2305
2572
|
|
|
@@ -2321,6 +2588,8 @@ function App() {
|
|
|
2321
2588
|
// closure must see the current value without being rebuilt on every toggle.
|
|
2322
2589
|
const showHelpRef = useRef(false);
|
|
2323
2590
|
showHelpRef.current = showHelp;
|
|
2591
|
+
const remoteSetupRef = useRef(false);
|
|
2592
|
+
remoteSetupRef.current = remoteSetup;
|
|
2324
2593
|
|
|
2325
2594
|
function moveSelection(delta) {
|
|
2326
2595
|
const { items, key: currentKey, bodyRows: rows_, tabKey, offset: offsetRaw } = navRef.current;
|
|
@@ -2387,7 +2656,7 @@ function App() {
|
|
|
2387
2656
|
// unhandled rejection.
|
|
2388
2657
|
openInBrowser(tabKey, item)
|
|
2389
2658
|
.catch((err) => {
|
|
2390
|
-
setErrors((x) => ({ ...x, [tabKey]:
|
|
2659
|
+
setErrors((x) => ({ ...x, [tabKey]: textTabError(err) }));
|
|
2391
2660
|
})
|
|
2392
2661
|
.finally(() => {
|
|
2393
2662
|
delete openingRef.current[guard];
|
|
@@ -2415,13 +2684,15 @@ function App() {
|
|
|
2415
2684
|
} else if (key.pageUp) {
|
|
2416
2685
|
moveSelection(-pageStep);
|
|
2417
2686
|
} else if (key.return) {
|
|
2418
|
-
|
|
2687
|
+
if (remoteSetupRef.current) onCreateRemote();
|
|
2688
|
+
else openSelected();
|
|
2419
2689
|
} else if (input === "r") {
|
|
2420
2690
|
// Goes through the same per-tab in-flight guard as the poll loop, so
|
|
2421
2691
|
// holding the key down cannot stack concurrent subprocesses. `force`
|
|
2422
2692
|
// bypasses (and clears) any failure backoff: this key is the user saying
|
|
2423
2693
|
// "try again now", and a refresh that silently declined to refresh would
|
|
2424
2694
|
// be worse than no key at all.
|
|
2695
|
+
contextCoordinatorRef.current?.invalidate();
|
|
2425
2696
|
fetchTabRef.current?.(TABS[activeIndexRef.current].key, { force: true });
|
|
2426
2697
|
} else if (input >= "1" && input <= String(TABS.length)) {
|
|
2427
2698
|
setActiveIndex(Number(input) - 1);
|
|
@@ -2457,6 +2728,23 @@ function App() {
|
|
|
2457
2728
|
let ticks = 0;
|
|
2458
2729
|
const controller = new AbortController();
|
|
2459
2730
|
registerLiveAbort(controller);
|
|
2731
|
+
const withTargetHost = (context) => ({
|
|
2732
|
+
...context,
|
|
2733
|
+
targetHost: failureTargetHost({
|
|
2734
|
+
runtimeHost: runtime.host,
|
|
2735
|
+
ghHost: process.env.GH_HOST,
|
|
2736
|
+
ghRepo: process.env.GH_REPO,
|
|
2737
|
+
accounts: context.accounts,
|
|
2738
|
+
}),
|
|
2739
|
+
});
|
|
2740
|
+
const coordinator = createFailureContextCoordinator({
|
|
2741
|
+
resolve: async (signal) => withTargetHost(await resolveFailureContext(signal)),
|
|
2742
|
+
commit: (context) => {
|
|
2743
|
+
if (!cancelled) setFailureContext(context);
|
|
2744
|
+
},
|
|
2745
|
+
fallback: withTargetHost(missingFailureContext()),
|
|
2746
|
+
});
|
|
2747
|
+
contextCoordinatorRef.current = coordinator;
|
|
2460
2748
|
|
|
2461
2749
|
// Each tab commits its own result the moment it lands instead of waiting on
|
|
2462
2750
|
// a Promise.allSettled barrier. Actions is by far the slowest fetch, so
|
|
@@ -2522,13 +2810,14 @@ function App() {
|
|
|
2522
2810
|
.catch((err) => {
|
|
2523
2811
|
if (cancelled || err?.name === "AbortError") return;
|
|
2524
2812
|
cleanRef.current[key] = false;
|
|
2525
|
-
//
|
|
2526
|
-
//
|
|
2527
|
-
//
|
|
2528
|
-
//
|
|
2529
|
-
|
|
2530
|
-
const verdict =
|
|
2531
|
-
setErrors((x) => ({ ...x, [key]:
|
|
2813
|
+
// Preserve both the verdict and the bounded raw error in state. The
|
|
2814
|
+
// renderer translates recognized verdicts at draw time, which lets a
|
|
2815
|
+
// later repository/account context refine the one-line remedy without
|
|
2816
|
+
// throwing away the original evidence.
|
|
2817
|
+
const failure = toTabError(err);
|
|
2818
|
+
const verdict = failure.verdict;
|
|
2819
|
+
setErrors((x) => ({ ...x, [key]: failure }));
|
|
2820
|
+
if (verdict === "unavailable") coordinator.ensure(controller.signal);
|
|
2532
2821
|
// ...and back off, which the list tabs never did at all. A tab wedged
|
|
2533
2822
|
// on an expired token used to re-spawn `gh` every tick forever -- 720
|
|
2534
2823
|
// subprocesses an hour, indefinitely, against a token that is already
|
|
@@ -2560,6 +2849,11 @@ function App() {
|
|
|
2560
2849
|
fetchTabRef.current = fetchTab;
|
|
2561
2850
|
|
|
2562
2851
|
async function tick() {
|
|
2852
|
+
// Setup mode owns the whole screen and can only finish by handing off to
|
|
2853
|
+
// gh or exiting. Hidden tab retries cannot make that state more useful,
|
|
2854
|
+
// so stop spawning subprocesses once any endpoint proves there is no
|
|
2855
|
+
// remote. The interval may still wake, but this branch is otherwise idle.
|
|
2856
|
+
if (remoteSetupRef.current) return;
|
|
2563
2857
|
// Only the tab you're looking at needs to keep up with REFRESH_MS. The
|
|
2564
2858
|
// other three exist to keep the tab-bar counts honest, which tolerates
|
|
2565
2859
|
// being a minute behind -- so they refresh every BACKGROUND_EVERY ticks
|
|
@@ -2585,9 +2879,10 @@ function App() {
|
|
|
2585
2879
|
return () => {
|
|
2586
2880
|
cancelled = true;
|
|
2587
2881
|
clearInterval(id);
|
|
2882
|
+
if (contextCoordinatorRef.current === coordinator) contextCoordinatorRef.current = null;
|
|
2588
2883
|
// `cancelled` stops state updates from a promise that already resolved;
|
|
2589
2884
|
// the signal stops the subprocess itself, so quitting doesn't orphan up to
|
|
2590
|
-
//
|
|
2885
|
+
// eight `gh` children mid-request. They cover different windows and both are
|
|
2591
2886
|
// needed.
|
|
2592
2887
|
controller.abort();
|
|
2593
2888
|
};
|
|
@@ -2621,7 +2916,7 @@ function App() {
|
|
|
2621
2916
|
TABS.map((t) => [t.key, data[t.key] == null && !errors[t.key]]),
|
|
2622
2917
|
);
|
|
2623
2918
|
const anyFirstLoad = Object.values(firstLoad).some(Boolean);
|
|
2624
|
-
const showSpinner = ANIMATE && (anyFirstLoad || hasRunningVisible);
|
|
2919
|
+
const showSpinner = !remoteSetup && ANIMATE && (anyFirstLoad || hasRunningVisible);
|
|
2625
2920
|
useEffect(() => {
|
|
2626
2921
|
if (!showSpinner) {
|
|
2627
2922
|
// Park on a fixed frame rather than freezing wherever the animation
|
|
@@ -2647,7 +2942,7 @@ function App() {
|
|
|
2647
2942
|
}, [anyFirstLoad, iconHintDue]);
|
|
2648
2943
|
|
|
2649
2944
|
const items = data[tab.key];
|
|
2650
|
-
const
|
|
2945
|
+
const displayError = formatTabError(tabError, failureContext);
|
|
2651
2946
|
const spin = SPINNER[frame % SPINNER.length];
|
|
2652
2947
|
|
|
2653
2948
|
const counts = Object.fromEntries(
|
|
@@ -2685,7 +2980,9 @@ function App() {
|
|
|
2685
2980
|
];
|
|
2686
2981
|
}),
|
|
2687
2982
|
);
|
|
2688
|
-
const failed = Object.fromEntries(
|
|
2983
|
+
const failed = Object.fromEntries(
|
|
2984
|
+
TABS.map((t) => [t.key, Boolean(errors[t.key]) && errors[t.key]?.verdict !== "no-remote"]),
|
|
2985
|
+
);
|
|
2689
2986
|
|
|
2690
2987
|
// Data can be arbitrarily old without anything on screen saying so: a failing
|
|
2691
2988
|
// poll only surfaces on the tab you have selected, and after a laptop sleep
|
|
@@ -2822,15 +3119,37 @@ function App() {
|
|
|
2822
3119
|
!showHelp &&
|
|
2823
3120
|
tooNarrow &&
|
|
2824
3121
|
e(Text, { dimColor: true, wrap: "truncate-end" }, "too narrow"),
|
|
2825
|
-
!showHelp && !tooNarrow && error && e(Text, { color: ERROR_TEXT, wrap: "truncate-end" }, error),
|
|
2826
3122
|
!showHelp &&
|
|
2827
3123
|
!tooNarrow &&
|
|
3124
|
+
!remoteSetup &&
|
|
3125
|
+
displayError &&
|
|
3126
|
+
e(Text, { color: ERROR_TEXT, wrap: "truncate-end" }, displayError),
|
|
3127
|
+
!showHelp &&
|
|
3128
|
+
!tooNarrow &&
|
|
3129
|
+
!remoteSetup &&
|
|
2828
3130
|
tab.key === "security" &&
|
|
2829
3131
|
securityLines.map((note, i) =>
|
|
2830
3132
|
e(Text, { key: i, dimColor: true, wrap: "truncate-end" }, note),
|
|
2831
3133
|
),
|
|
2832
|
-
!showHelp &&
|
|
2833
|
-
|
|
3134
|
+
!showHelp &&
|
|
3135
|
+
!tooNarrow &&
|
|
3136
|
+
remoteSetup &&
|
|
3137
|
+
(interactive ? REMOTE_SETUP_LINES : REMOTE_SETUP_NONINTERACTIVE_LINES).map(
|
|
3138
|
+
(line, index) =>
|
|
3139
|
+
e(
|
|
3140
|
+
Text,
|
|
3141
|
+
{
|
|
3142
|
+
key: `remote-setup-${index}`,
|
|
3143
|
+
color: index === 0 ? TITLE_COLOR : undefined,
|
|
3144
|
+
bold: index === 0,
|
|
3145
|
+
dimColor: index === 1,
|
|
3146
|
+
wrap: "truncate-end",
|
|
3147
|
+
},
|
|
3148
|
+
line,
|
|
3149
|
+
),
|
|
3150
|
+
),
|
|
3151
|
+
!showHelp && !tooNarrow && !remoteSetup && e(HeaderCells, { cells: header }),
|
|
3152
|
+
...(tooNarrow || showHelp || remoteSetup ? [] : visibleItems).map((item) => {
|
|
2834
3153
|
const key = itemKey(item);
|
|
2835
3154
|
return e(
|
|
2836
3155
|
RowBoundary,
|
|
@@ -2860,8 +3179,9 @@ function App() {
|
|
|
2860
3179
|
// repository rather than the absence of an answer.
|
|
2861
3180
|
!showHelp &&
|
|
2862
3181
|
!tooNarrow &&
|
|
3182
|
+
!remoteSetup &&
|
|
2863
3183
|
visibleItems.length === 0 &&
|
|
2864
|
-
!(
|
|
3184
|
+
!(tabError && items == null) &&
|
|
2865
3185
|
e(
|
|
2866
3186
|
Text,
|
|
2867
3187
|
{ dimColor: true },
|
|
@@ -2890,6 +3210,7 @@ function App() {
|
|
|
2890
3210
|
stale: staleLabel,
|
|
2891
3211
|
interactive,
|
|
2892
3212
|
cols: frameCols,
|
|
3213
|
+
remoteSetup,
|
|
2893
3214
|
}),
|
|
2894
3215
|
);
|
|
2895
3216
|
}
|
|
@@ -2963,7 +3284,42 @@ if (IS_MAIN) {
|
|
|
2963
3284
|
// root box is the terminal height. Windows is already documented as untested
|
|
2964
3285
|
// (README Limitations), and the pty harness covers the two platforms that are
|
|
2965
3286
|
// supported, so the flag is verified where it is claimed to work.
|
|
2966
|
-
|
|
3287
|
+
let app;
|
|
3288
|
+
let remoteSetupStarted = false;
|
|
3289
|
+
const createRemote = () => {
|
|
3290
|
+
if (remoteSetupStarted) return;
|
|
3291
|
+
remoteSetupStarted = true;
|
|
3292
|
+
try {
|
|
3293
|
+
app?.unmount();
|
|
3294
|
+
} catch {
|
|
3295
|
+
// The interactive gh flow matters more than a best-effort final repaint.
|
|
3296
|
+
}
|
|
3297
|
+
restoreScreen();
|
|
3298
|
+
abortLiveRequests();
|
|
3299
|
+
|
|
3300
|
+
// Plain `gh repo create` is the interactive form. Supplying --source here
|
|
3301
|
+
// would switch gh to non-interactive mode and require gh-glance to choose a
|
|
3302
|
+
// visibility on the user's behalf, which this consent boundary must never
|
|
3303
|
+
// do. The prompt tells the user which interactive path matches this folder.
|
|
3304
|
+
const child = spawn("gh", ["repo", "create"], {
|
|
3305
|
+
stdio: "inherit",
|
|
3306
|
+
env: process.env,
|
|
3307
|
+
});
|
|
3308
|
+
setupChild = child;
|
|
3309
|
+
child.once("error", (err) => {
|
|
3310
|
+
if (setupChild === child) setupChild = null;
|
|
3311
|
+
console.error(`gh-glance: could not start repository setup: ${redact(shortErr(err))}`);
|
|
3312
|
+
process.exitCode = 1;
|
|
3313
|
+
});
|
|
3314
|
+
child.once("exit", (code, signal) => {
|
|
3315
|
+
if (setupChild === child) setupChild = null;
|
|
3316
|
+
process.exitCode = signal ? 1 : (code ?? 1);
|
|
3317
|
+
if (code === 0) {
|
|
3318
|
+
console.log("gh repo create finished. Run gh-glance again when this folder has a remote.");
|
|
3319
|
+
}
|
|
3320
|
+
});
|
|
3321
|
+
};
|
|
3322
|
+
app = render(e(App, { onCreateRemote: createRemote }), { incrementalRendering: true });
|
|
2967
3323
|
|
|
2968
3324
|
// 128 + signal number, so a supervisor or `timeout` can tell an interrupted
|
|
2969
3325
|
// run from a clean one. These fire on external `kill` and when raw mode is
|
|
@@ -2986,19 +3342,38 @@ if (IS_MAIN) {
|
|
|
2986
3342
|
// exiting immediately afterwards keeps the prompt-exit guarantee: waiting for
|
|
2987
3343
|
// the event loop to drain would let a hung `gh` turn Ctrl+C into an apparent
|
|
2988
3344
|
// hang.
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
3345
|
+
let signalExitStarted = false;
|
|
3346
|
+
const bySignal = (code, signal) => () => {
|
|
3347
|
+
if (signalExitStarted) return;
|
|
3348
|
+
signalExitStarted = true;
|
|
3349
|
+
const finish = () => {
|
|
3350
|
+
try {
|
|
3351
|
+
app.unmount();
|
|
3352
|
+
} catch {
|
|
3353
|
+
// Teardown is best effort. restoreScreen below, and the `exit` listener
|
|
3354
|
+
// above, both still run, so the terminal is handed back either way.
|
|
3355
|
+
}
|
|
3356
|
+
restoreScreen();
|
|
3357
|
+
process.exit(code);
|
|
3358
|
+
};
|
|
3359
|
+
|
|
3360
|
+
const child = setupChild;
|
|
3361
|
+
if (forwardSignalToChild(child, signal)) {
|
|
3362
|
+
const force = setTimeout(() => {
|
|
3363
|
+
forwardSignalToChild(child, "SIGKILL");
|
|
3364
|
+
finish();
|
|
3365
|
+
}, 1000);
|
|
3366
|
+
child.once("exit", () => {
|
|
3367
|
+
clearTimeout(force);
|
|
3368
|
+
finish();
|
|
3369
|
+
});
|
|
3370
|
+
return;
|
|
2995
3371
|
}
|
|
2996
|
-
|
|
2997
|
-
process.exit(code);
|
|
3372
|
+
finish();
|
|
2998
3373
|
};
|
|
2999
|
-
process.on("SIGINT", bySignal(130));
|
|
3000
|
-
process.on("SIGTERM", bySignal(143));
|
|
3001
|
-
process.on("SIGHUP", bySignal(129));
|
|
3374
|
+
process.on("SIGINT", bySignal(130, "SIGINT"));
|
|
3375
|
+
process.on("SIGTERM", bySignal(143, "SIGTERM"));
|
|
3376
|
+
process.on("SIGHUP", bySignal(129, "SIGHUP"));
|
|
3002
3377
|
}
|
|
3003
3378
|
|
|
3004
3379
|
// Exported for unit tests. The dashboard itself is still one file; these are
|
|
@@ -3017,6 +3392,16 @@ export {
|
|
|
3017
3392
|
isUnavailable,
|
|
3018
3393
|
isRateLimited,
|
|
3019
3394
|
isAuthProblem,
|
|
3395
|
+
isMissingRemote,
|
|
3396
|
+
forwardSignalToChild,
|
|
3397
|
+
toTabError,
|
|
3398
|
+
formatTabError,
|
|
3399
|
+
parseRepoContext,
|
|
3400
|
+
parseAuthContext,
|
|
3401
|
+
buildFailureContext,
|
|
3402
|
+
failureTargetHost,
|
|
3403
|
+
unavailableRemedy,
|
|
3404
|
+
createFailureContextCoordinator,
|
|
3020
3405
|
AUTH_RETRY_MS,
|
|
3021
3406
|
BACKOFF_STEPS_MS,
|
|
3022
3407
|
redact,
|
|
@@ -3037,6 +3422,9 @@ export {
|
|
|
3037
3422
|
OCT_UNICODE,
|
|
3038
3423
|
KEY_TABLE,
|
|
3039
3424
|
KEY_HINTS,
|
|
3425
|
+
REMOTE_SETUP_HINTS,
|
|
3426
|
+
REMOTE_SETUP_LINES,
|
|
3427
|
+
REMOTE_SETUP_NONINTERACTIVE_LINES,
|
|
3040
3428
|
VERDICT_REMEDY,
|
|
3041
3429
|
RATE_LIMIT_RETRY_MS,
|
|
3042
3430
|
FAILURE_LADDER,
|
package/package.json
CHANGED