gh-glance 0.16.2 → 0.17.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 +30 -1
- package/README.md +53 -12
- package/index.mjs +617 -177
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.17.0] - 2026-10-25
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Contextual, paginated help with the selected GitHub host and repository,
|
|
15
|
+
complete diagnostics and recovery actions, including in narrow terminals.
|
|
16
|
+
- Optional Actions setup checks with `s` in standalone mode: one read-only
|
|
17
|
+
request lists up to 100 workflows, reports incomplete coverage and keeps a
|
|
18
|
+
separate result for five minutes. Press `g` to open the repository's Actions
|
|
19
|
+
page, including from collector and SSH views.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Empty tabs explain what was checked, what the result means and what to do
|
|
24
|
+
next. Saved views, waiting, overdue data and failed refreshes retain their
|
|
25
|
+
actual source-check context.
|
|
26
|
+
- README: the provenance note now says the published package is the exact
|
|
27
|
+
tarball tested for the release pull request.
|
|
28
|
+
- Development CI monitoring uses Sutura v0.3.9 with a repository-scoped App token.
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- Preserve actionable login and local-storage remedies in short terminal panes.
|
|
33
|
+
- Distinguish partial or disabled Security coverage from an all-clear result,
|
|
34
|
+
and disclose uncertain check times without inventing freshness.
|
|
35
|
+
- Keep setup and browser failures separate from workflow-run results, with
|
|
36
|
+
bounded retries, shared rate-limit accounting and account-change fencing.
|
|
37
|
+
|
|
10
38
|
## [0.16.2] - 2026-10-03
|
|
11
39
|
|
|
12
40
|
No change to the dashboard itself: `index.mjs` is identical to 0.16.1. This
|
|
@@ -1254,7 +1282,8 @@ engineering, security, QA and UX. What follows is what changed as a result.
|
|
|
1254
1282
|
- The `main` field from `package.json`. It advertised the file as importable,
|
|
1255
1283
|
but importing it took over the terminal or exited the host process.
|
|
1256
1284
|
|
|
1257
|
-
[Unreleased]: https://github.com/juan294/gh-glance/compare/v0.
|
|
1285
|
+
[Unreleased]: https://github.com/juan294/gh-glance/compare/v0.17.0...HEAD
|
|
1286
|
+
[0.17.0]: https://github.com/juan294/gh-glance/compare/v0.16.2...v0.17.0
|
|
1258
1287
|
[0.16.2]: https://github.com/juan294/gh-glance/compare/v0.16.1...v0.16.2
|
|
1259
1288
|
[0.16.1]: https://github.com/juan294/gh-glance/compare/v0.16.0...v0.16.1
|
|
1260
1289
|
[0.16.0]: https://github.com/juan294/gh-glance/compare/v0.15.2...v0.16.0
|
package/README.md
CHANGED
|
@@ -25,14 +25,14 @@ without switching to the browser.
|
|
|
25
25
|
╭─ Actions · owner/repo ──────────────────────────────────────────────────╮
|
|
26
26
|
│ TITLE │WORKFLOW │BRANCH │TIME │UPDATED │
|
|
27
27
|
│ ─────────────────────────────────────────────────────────────────────── │
|
|
28
|
-
│ >+ ci: pin actions to com… #443 CI develop 1m20s
|
|
29
|
-
│ x fix: restore the prima… #442 Code… develop 1m28s
|
|
30
|
-
│ + chore: bump dependenci… #441 CI dependa…int-10 30s
|
|
31
|
-
│ - docs: update the readme #440 CI develop 15s
|
|
28
|
+
│ >+ ci: pin actions to com… #443 CI develop 1m20s 85d ago │
|
|
29
|
+
│ x fix: restore the prima… #442 Code… develop 1m28s 85d ago │
|
|
30
|
+
│ + chore: bump dependenci… #441 CI dependa…int-10 30s 85d ago │
|
|
31
|
+
│ - docs: update the readme #440 CI develop 15s 86d ago │
|
|
32
32
|
│ │
|
|
33
33
|
│ │
|
|
34
34
|
╰──────────────────────────────────────────────────────────────── 4 of 4 ─╯
|
|
35
|
-
· Watching Refresh: r Quit: q Move: ↑↓ Open: Ent
|
|
35
|
+
· Watching Refresh: r Quit: q Help: ? Move: ↑↓ Open: Ent w
|
|
36
36
|
```
|
|
37
37
|
|
|
38
38
|
> Generated from this candidate's real binary under the repository PTY harness
|
|
@@ -134,15 +134,20 @@ The installed package is a CLI, not a JavaScript library. The `gh-glance`
|
|
|
134
134
|
executable is supported; package-root and deep imports are intentionally
|
|
135
135
|
blocked so an internal test seam cannot become an accidental public API.
|
|
136
136
|
|
|
137
|
-
Every published version is
|
|
138
|
-
|
|
137
|
+
Every published version is the exact tarball GitHub Actions packed and tested
|
|
138
|
+
for the release pull request, published by GitHub Actions from a tagged commit
|
|
139
|
+
on `main`, never from a maintainer's laptop, and carries [npm
|
|
139
140
|
provenance](https://docs.npmjs.com/generating-provenance-statements) linking
|
|
140
|
-
the tarball back to the workflow run and commit that
|
|
141
|
+
the tarball back to the workflow run and commit that published it. The
|
|
141
142
|
"Provenance" section on the [package
|
|
142
143
|
page](https://www.npmjs.com/package/gh-glance) shows the exact source.
|
|
143
144
|
|
|
144
145
|
### First run
|
|
145
146
|
|
|
147
|
+
The footer keeps `?` available for contextual help, even in a narrow pane.
|
|
148
|
+
Help explains the selected repository and host, the current observation, and
|
|
149
|
+
what to do next; its pages keep long targets and recovery details reachable.
|
|
150
|
+
|
|
146
151
|
The default standalone dashboard uses the credentials and active account
|
|
147
152
|
already selected by your local `gh` CLI. It does not persist or display the
|
|
148
153
|
token. Check that account before starting the dashboard:
|
|
@@ -176,6 +181,33 @@ the core gh-glance minimum remains unchanged. To keep simultaneous panes on
|
|
|
176
181
|
different accounts, use separate `GH_CONFIG_DIR` values as described under
|
|
177
182
|
[No account pinning](#limitations).
|
|
178
183
|
|
|
184
|
+
A successful empty result is different from a check that has not succeeded.
|
|
185
|
+
Actions says “No workflow runs found” and explains that runs come from workflows
|
|
186
|
+
with matching triggers. Issues and Pull requests report open items. Security
|
|
187
|
+
reports only the sources it could check and keeps unavailable-source notes visible.
|
|
188
|
+
An empty result is not a repository-wide health or security clearance.
|
|
189
|
+
|
|
190
|
+
On Actions, press `s` for an optional read-only setup check or `g` to open the
|
|
191
|
+
selected repository's Actions page. The setup check lists at most 100 workflows
|
|
192
|
+
in one request. It reports partial coverage and unfamiliar states explicitly;
|
|
193
|
+
zero workflows or an access error does not prove that Actions is disabled.
|
|
194
|
+
Setup results have their own check time and do not change the run list or its
|
|
195
|
+
freshness. A successful or partial result is reused for five minutes. Failures
|
|
196
|
+
keep the prior dated result and require `s` to retry after at least 30 seconds
|
|
197
|
+
and any shared API hold. There is no automatic setup retry. Setup checks are
|
|
198
|
+
available only in standalone mode; connected collector and SSH views offer `g`
|
|
199
|
+
instead. Neither key creates, enables, edits, or dispatches a workflow.
|
|
200
|
+
|
|
201
|
+
“Checking” and “Waiting to check” mean no successful result is available yet.
|
|
202
|
+
A failed load shows the actual recovery action. A failed refresh keeps the prior
|
|
203
|
+
result and its original check time. “Needs an update” describes overdue data;
|
|
204
|
+
it does not by itself mean a network request failed. Unknown or future source
|
|
205
|
+
clocks show “Last-check time unavailable”.
|
|
206
|
+
|
|
207
|
+
With the default on-demand policy, Issues, Pull requests and Security show a
|
|
208
|
+
saved view between checks. Press `r` to check again; opening the tab also follows
|
|
209
|
+
the normal refresh policy. These tabs do not promise continuous polling.
|
|
210
|
+
|
|
179
211
|
### From a clone
|
|
180
212
|
|
|
181
213
|
For hacking on it, or to run an unreleased revision:
|
|
@@ -251,8 +283,10 @@ stdout isn't a terminal rather than streaming redraw frames into a pipe.
|
|
|
251
283
|
| `Enter` | Open the selected item, or accept an onboarding prompt |
|
|
252
284
|
| `r` | Request a current-tab refresh. It sends the cached validators, so an unchanged tab answers `304` and spends nothing. A safe grant is still required. |
|
|
253
285
|
| `R` | Resynchronize the current tab: one generation without validators, and the only key that clears a negative capability backoff. Still subject to the budget and the secondary cooldown. |
|
|
286
|
+
| `s` | On Actions, request or reuse a read-only setup check (standalone only) |
|
|
287
|
+
| `g` | On Actions, open the selected repository’s Actions page |
|
|
254
288
|
| `w` | Adjust table column widths |
|
|
255
|
-
| `?` |
|
|
289
|
+
| `?` | Open or close contextual help; `n` / `p` moves through its pages |
|
|
256
290
|
| `q` / `Esc` / `Ctrl+C` | Quit (`Esc` leaves width mode) |
|
|
257
291
|
|
|
258
292
|
### Adjusting column widths
|
|
@@ -304,9 +338,16 @@ switch `gh` into non-interactive mode and require choosing a visibility in
|
|
|
304
338
|
advance. Press `q` or `Esc` to decline. To watch an existing repository without
|
|
305
339
|
attaching this folder, restart with `gh-glance --repo owner/name`.
|
|
306
340
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
341
|
+
The panel names the selected repository, including targets inferred from Git
|
|
342
|
+
remotes: `╭─ Actions · acme/widget ─`. Non-default hosts are included in the
|
|
343
|
+
target. Selection does not prove that the active account can access it. When
|
|
344
|
+
the pane is too narrow for the target, press `?` to read its complete name and
|
|
345
|
+
host, the current check state, source time, recovery action, and key reference.
|
|
346
|
+
|
|
347
|
+
Help wraps long text and uses `n` / `p` for next / previous page. `?` closes it;
|
|
348
|
+
any other ordinary key closes help without performing that key's usual action.
|
|
349
|
+
`q`, `Esc`, and `Ctrl+C` still quit. Width mode keeps its own keys: leave it
|
|
350
|
+
before opening help.
|
|
310
351
|
|
|
311
352
|
If `gh-glance` is started without an interactive stdin (for example with stdin
|
|
312
353
|
redirected), the key handlers cannot run and the status bar shows only `Quit: ^C`
|
package/index.mjs
CHANGED
|
@@ -1005,7 +1005,7 @@ function ghApiArgs(args, etag = null) {
|
|
|
1005
1005
|
];
|
|
1006
1006
|
}
|
|
1007
1007
|
|
|
1008
|
-
async function ghApi(args, { operation, signal, etag = null, run = runGh } = {}) {
|
|
1008
|
+
async function ghApi(args, { operation, signal, etag = null, run = runGh, includeHeaders = false } = {}) {
|
|
1009
1009
|
const argv = ghApiArgs(args, etag);
|
|
1010
1010
|
try {
|
|
1011
1011
|
const parsed = parseGhApiResponse(await run(argv, { operation, signal }));
|
|
@@ -1014,6 +1014,7 @@ async function ghApi(args, { operation, signal, etag = null, run = runGh } = {})
|
|
|
1014
1014
|
etag: parsed.headers.etag ?? null,
|
|
1015
1015
|
rateLimit: pickRateLimit(parsed.headers),
|
|
1016
1016
|
body: parsed.body,
|
|
1017
|
+
...(includeHeaders ? { headers: parsed.headers } : {}),
|
|
1017
1018
|
};
|
|
1018
1019
|
} catch (error) {
|
|
1019
1020
|
const parsed = parseGhApiResponse(error?.stdout);
|
|
@@ -1490,6 +1491,72 @@ function actionsWorkflowsArgs() {
|
|
|
1490
1491
|
];
|
|
1491
1492
|
}
|
|
1492
1493
|
|
|
1494
|
+
const ACTIONS_SETUP_TTL_MS = 5 * 60_000;
|
|
1495
|
+
|
|
1496
|
+
function actionsSetupArgs({ host, repository }) {
|
|
1497
|
+
return [`repos/${repository}/actions/workflows?per_page=100&page=1`, "--hostname", host,
|
|
1498
|
+
"--jq", "{total_count, workflows: [.workflows[] | {id, name, state}]}"];
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
function parseActionsSetup(body, headers = {}) {
|
|
1502
|
+
const unusable = () => { throw new Error("Actions setup response is unusable"); };
|
|
1503
|
+
let value;
|
|
1504
|
+
try { value = JSON.parse(body); } catch { return unusable(); }
|
|
1505
|
+
if (!isRecord(value) || !Number.isSafeInteger(value.total_count) || value.total_count < 0 ||
|
|
1506
|
+
!Array.isArray(value.workflows) || value.workflows.length > 100 ||
|
|
1507
|
+
value.total_count < value.workflows.length || (value.total_count > 0 && value.workflows.length === 0)) return unusable();
|
|
1508
|
+
const ids = new Set();
|
|
1509
|
+
const workflows = value.workflows.map((entry) => {
|
|
1510
|
+
if (!isRecord(entry) || !Number.isSafeInteger(entry.id) || entry.id <= 0 || ids.has(entry.id) ||
|
|
1511
|
+
typeof entry.name !== "string" || !safe(entry.name).trim() ||
|
|
1512
|
+
typeof entry.state !== "string" || !entry.state.trim()) return unusable();
|
|
1513
|
+
ids.add(entry.id);
|
|
1514
|
+
return { id: entry.id, name: safe(entry.name), state: entry.state };
|
|
1515
|
+
});
|
|
1516
|
+
const hasNextPage = /rel=["']?next\b/i.test(headers.link ?? "");
|
|
1517
|
+
if (value.total_count === 0 && hasNextPage) return unusable();
|
|
1518
|
+
const complete = value.total_count === workflows.length && !hasNextPage;
|
|
1519
|
+
const disabled = workflows.filter(({ state }) => ["disabled_manually", "disabled_inactivity"].includes(state)).length;
|
|
1520
|
+
const active = workflows.filter(({ state }) => state === "active").length;
|
|
1521
|
+
const title = !complete ? "Workflows found; setup check incomplete"
|
|
1522
|
+
: workflows.length === 0 ? "No workflows found"
|
|
1523
|
+
: disabled === workflows.length ? "All listed workflows are disabled" : "Workflows found";
|
|
1524
|
+
return { total: value.total_count, workflows, complete, disabled, active,
|
|
1525
|
+
other: workflows.length - disabled - active, title };
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
function actionsSetupFresh(result, nowMs) {
|
|
1529
|
+
return result != null && nowMs >= result.checkedAt && nowMs - result.checkedAt < ACTIONS_SETUP_TTL_MS;
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
function actionsSetupRetryAt(nowMs, holdUntil = 0) {
|
|
1533
|
+
return Math.max(nowMs + 30_000, Number.isFinite(holdUntil) ? holdUntil : 0);
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
function actionsPageUrl(target = dashboardTarget()) {
|
|
1537
|
+
const validated = dashboardTarget(target);
|
|
1538
|
+
return validated.host && validated.repository
|
|
1539
|
+
? `https://${validated.host}/${validated.repository}/actions` : null;
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
function actionsSetupPresentation(state, nowMs) {
|
|
1543
|
+
const result = state.result;
|
|
1544
|
+
const notes = [];
|
|
1545
|
+
if (result) {
|
|
1546
|
+
notes.push(`${state.saved ? "Saved setup check" : "Setup checked"}: ${new Date(result.checkedAt).toISOString()}`,
|
|
1547
|
+
`${result.workflows.length} of ${result.total} workflows listed: ${result.active} active, ${result.disabled} disabled, ${result.other} other state.`,
|
|
1548
|
+
actionsSetupFresh(result, nowMs)
|
|
1549
|
+
? `Press s to check again after ${new Date(result.checkedAt + ACTIONS_SETUP_TTL_MS).toISOString()}.`
|
|
1550
|
+
: "Setup check expired; press s to check again.");
|
|
1551
|
+
}
|
|
1552
|
+
if (state.error) notes.push(state.error,
|
|
1553
|
+
`Press s to retry${state.retryAt ? ` after ${new Date(state.retryAt).toISOString()}` : ""}.`);
|
|
1554
|
+
if (state.message) notes.push(state.message);
|
|
1555
|
+
return { title: state.busy ? "Checking Actions setup" : state.error ? "Setup check unavailable"
|
|
1556
|
+
: state.message ? state.title ?? "Actions setup" : result?.title,
|
|
1557
|
+
action: state.action ?? (state.error ? "s Retry; g Actions; ? Details" : "s Setup; g Actions; ? Details"), notes };
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1493
1560
|
function parseActionsRuns(runsBody) {
|
|
1494
1561
|
return parseJsonOutput(runsBody).map((run) => ({
|
|
1495
1562
|
databaseId: run.databaseId,
|
|
@@ -2214,6 +2281,7 @@ const OPERATION_COSTS = Object.freeze({
|
|
|
2214
2281
|
// Separately admitted, like a later list page: only fetched when a run has
|
|
2215
2282
|
// no name of its own, and settled against its own reservation.
|
|
2216
2283
|
"catalog:actions-workflows": { core: 1, graphql: 0 },
|
|
2284
|
+
"setup:actions-workflows": { core: 1, graphql: 0 },
|
|
2217
2285
|
"tab:security-endpoint": { core: 1, graphql: 0 },
|
|
2218
2286
|
"failure-context:repository": { core: 0, graphql: GRAPHQL_OBSERVER_POINTS },
|
|
2219
2287
|
// Each page past the first reserves its own envelope, so a denial there is a
|
|
@@ -6977,6 +7045,10 @@ async function openInBrowser(tabKey, item, signal, _governor = null, { run = nul
|
|
|
6977
7045
|
// Returning quietly here is how a dead Enter key looks: the row has no usable
|
|
6978
7046
|
// page, and the user is entitled to know that rather than press it again.
|
|
6979
7047
|
if (!url) throw new Error("This row has no page to open yet; refresh and try again");
|
|
7048
|
+
return openBrowserUrl(url, signal, { run });
|
|
7049
|
+
}
|
|
7050
|
+
|
|
7051
|
+
async function openBrowserUrl(url, signal, { run = null } = {}) {
|
|
6980
7052
|
const opener = pick(BROWSER_OPENERS, process.platform, null);
|
|
6981
7053
|
if (!opener) throw new Error(`Opening a browser is not supported on ${process.platform}`);
|
|
6982
7054
|
const [command, ...prefix] = opener;
|
|
@@ -11571,8 +11643,10 @@ const KEY_TABLE = [
|
|
|
11571
11643
|
["Enter", "Open the selected item or accept a prompt"],
|
|
11572
11644
|
["r", "Refresh the current tab when a safe grant is available"],
|
|
11573
11645
|
["R", "Resynchronize the current tab, ignoring cached validators and backoff"],
|
|
11646
|
+
["s", "Actions: read-only setup check (standalone only; saved for five minutes)"],
|
|
11647
|
+
["g", "Actions: open the selected repository Actions page"],
|
|
11574
11648
|
["w", "Adjust table column widths"],
|
|
11575
|
-
["?", "
|
|
11649
|
+
["?", "Open / close contextual help; n / p changes page"],
|
|
11576
11650
|
["q / Esc / Ctrl+C", "Quit (Esc leaves width mode)"],
|
|
11577
11651
|
];
|
|
11578
11652
|
const KEY_COL = Math.max(...KEY_TABLE.map(([k]) => k.length)) + 3;
|
|
@@ -11594,6 +11668,8 @@ const HELP_PRIORITY = [
|
|
|
11594
11668
|
"Tab / Shift+Tab",
|
|
11595
11669
|
"PgUp / PgDn",
|
|
11596
11670
|
"R",
|
|
11671
|
+
"s",
|
|
11672
|
+
"g",
|
|
11597
11673
|
];
|
|
11598
11674
|
|
|
11599
11675
|
function helpLines(maxRows) {
|
|
@@ -15760,12 +15836,14 @@ function cleanupQueueForResource(queue, resource) {
|
|
|
15760
15836
|
return count;
|
|
15761
15837
|
}
|
|
15762
15838
|
|
|
15763
|
-
function presentRecovery(cause, { nowMs = Date.now(), cols = 80 } = {}) {
|
|
15839
|
+
function presentRecovery(cause, { nowMs = Date.now(), cols = 80, hasSnapshot } = {}) {
|
|
15764
15840
|
if (!cause) return [];
|
|
15765
15841
|
const width = Math.max(1, Number.isSafeInteger(cols) ? cols - 1 : 79);
|
|
15766
15842
|
const resource = cause.resource === "graphql" ? "GraphQL" : cause.resource === "core" ? "Core" : "GitHub";
|
|
15767
|
-
const age =
|
|
15768
|
-
|
|
15843
|
+
const age = hasSnapshot === false ? "Not checked yet"
|
|
15844
|
+
: Number.isFinite(cause.sourceAt) && cause.sourceAt <= nowMs
|
|
15845
|
+
? `cached ${formatDuration(nowMs - cause.sourceAt)}`
|
|
15846
|
+
: hasSnapshot === true ? "Last-check time unavailable" : "cached age unavailable";
|
|
15769
15847
|
const retry = Number.isFinite(cause.retryAt)
|
|
15770
15848
|
? `; next ${statusInterval(cause.retryAt, nowMs)}` : "";
|
|
15771
15849
|
const summary = cause.code === "legacy-unresolved" && Number.isSafeInteger(cause.debtUnits)
|
|
@@ -15800,6 +15878,189 @@ function presentRecovery(cause, { nowMs = Date.now(), cols = 80 } = {}) {
|
|
|
15800
15878
|
`${cause.action}${retry}`.slice(0, width), age.slice(0, width)];
|
|
15801
15879
|
}
|
|
15802
15880
|
|
|
15881
|
+
// Presentation consumes source observations, never receipt or cache-load clocks.
|
|
15882
|
+
function dashboardPresentation({ tab, rows = null, hasSnapshot = rows !== null,
|
|
15883
|
+
sourceAt = null, nowMs = Date.now(), overdue = false, onDemand = false,
|
|
15884
|
+
requestState = "idle", error = false, recovery = null, disconnected = false,
|
|
15885
|
+
securityNotes = [], securityAllUnavailable = false, target = null, diagnostic = null } = {}) {
|
|
15886
|
+
const label = TABS.find((candidate) => candidate.key === tab)?.label ?? "Repository";
|
|
15887
|
+
const empty = { actions: "No workflow runs found", issues: "No open issues",
|
|
15888
|
+
prs: "No open pull requests", security: "No alerts returned by the checked sources" };
|
|
15889
|
+
const lastEmpty = { actions: "Last check found no workflow runs.", issues: "Last check found no open issues.",
|
|
15890
|
+
prs: "Last check found no open pull requests.", security: "Last check returned no alerts from the checked sources." };
|
|
15891
|
+
const sourceCheck = !hasSnapshot ? "Not checked yet"
|
|
15892
|
+
: !Number.isFinite(sourceAt) || sourceAt > nowMs ? "Last-check time unavailable"
|
|
15893
|
+
: (() => {
|
|
15894
|
+
const date = new Date(sourceAt);
|
|
15895
|
+
const today = new Date(nowMs);
|
|
15896
|
+
const day = date.toDateString() === today.toDateString() ? ""
|
|
15897
|
+
: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")} `;
|
|
15898
|
+
return `Checked ${day}${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
|
|
15899
|
+
})();
|
|
15900
|
+
const showTable = Array.isArray(rows) && rows.length > 0;
|
|
15901
|
+
const failed = Boolean(error || disconnected);
|
|
15902
|
+
const held = Boolean(recovery) || requestState === "waiting";
|
|
15903
|
+
let state = "ready";
|
|
15904
|
+
let title = showTable ? label : empty[tab] ?? label;
|
|
15905
|
+
let explanation = tab === "actions"
|
|
15906
|
+
? "Actions shows automated jobs. A push starts a run only when a workflow has a matching trigger."
|
|
15907
|
+
: "GitHub returned this result for the selected repository.";
|
|
15908
|
+
let action = "Press r to refresh.";
|
|
15909
|
+
if (failed) {
|
|
15910
|
+
state = "failed";
|
|
15911
|
+
title = `Could not ${hasSnapshot ? "refresh" : "load"} ${label}`;
|
|
15912
|
+
} else if (held) {
|
|
15913
|
+
state = "waiting";
|
|
15914
|
+
title = hasSnapshot ? `Waiting to update ${label}` : `Waiting to check ${label}`;
|
|
15915
|
+
} else if (!hasSnapshot) {
|
|
15916
|
+
state = requestState === "loading" ? "loading" : "waiting";
|
|
15917
|
+
title = state === "loading" ? `Checking ${label}…` : `Waiting to check ${label}`;
|
|
15918
|
+
explanation = `Fetching this repository's ${tab === "actions" ? "workflow runs" : label.toLowerCase()}.`;
|
|
15919
|
+
} else if (overdue && !onDemand) {
|
|
15920
|
+
state = "overdue";
|
|
15921
|
+
title = `${label} needs an update`;
|
|
15922
|
+
} else if (onDemand) {
|
|
15923
|
+
state = "saved";
|
|
15924
|
+
title = `Saved ${label} view`;
|
|
15925
|
+
action = "Press r to check again. This view checks when opened or refreshed.";
|
|
15926
|
+
}
|
|
15927
|
+
if ((failed || held || state === "overdue" || state === "saved") && hasSnapshot && !showTable) {
|
|
15928
|
+
explanation = lastEmpty[tab] ?? "Showing the last checked result.";
|
|
15929
|
+
} else if ((failed || held) && !hasSnapshot) {
|
|
15930
|
+
explanation = "No successful check is available yet.";
|
|
15931
|
+
}
|
|
15932
|
+
if (recovery) {
|
|
15933
|
+
explanation = `${recovery.summary}. ${explanation}`;
|
|
15934
|
+
action = recovery.action + (Number.isFinite(recovery.retryAt)
|
|
15935
|
+
? `; next ${statusInterval(recovery.retryAt, nowMs)}` : "");
|
|
15936
|
+
}
|
|
15937
|
+
if (tab === "security" && securityAllUnavailable) {
|
|
15938
|
+
if (!failed && !held) title = "No alert sources available";
|
|
15939
|
+
explanation = `${recovery ? `${recovery.summary}. ` : ""}No alert source returned data for this check.`;
|
|
15940
|
+
}
|
|
15941
|
+
return { state, title, explanation, sourceCheck, action, showTable, target,
|
|
15942
|
+
notes: [...(diagnostic ? [diagnostic] : []), ...(tab === "security" ? securityNotes : [])] };
|
|
15943
|
+
}
|
|
15944
|
+
|
|
15945
|
+
// Panels contain sanitized display text. Width follows terminal cells, including
|
|
15946
|
+
// wide Unicode characters; combining marks stay with the preceding character.
|
|
15947
|
+
function panelCellWidth(char) {
|
|
15948
|
+
const code = char.codePointAt(0);
|
|
15949
|
+
return /\p{Mark}/u.test(char) || code === 0x200d || code === 0xfe0f ? 0
|
|
15950
|
+
: code >= 0x1100 && (code <= 0x115f || code === 0x2329 || code === 0x232a ||
|
|
15951
|
+
code >= 0x2e80 && code <= 0xa4cf || code >= 0xac00 && code <= 0xd7a3 ||
|
|
15952
|
+
code >= 0xf900 && code <= 0xfaff || code >= 0xfe10 && code <= 0xfe6f ||
|
|
15953
|
+
code >= 0xff00 && code <= 0xff60 || code >= 0xffe0 && code <= 0xffe6 || code >= 0x1f300) ? 2 : 1;
|
|
15954
|
+
}
|
|
15955
|
+
|
|
15956
|
+
function wrapPanelText(text, width) {
|
|
15957
|
+
const limit = Math.max(1, width);
|
|
15958
|
+
const lines = [];
|
|
15959
|
+
let line = "";
|
|
15960
|
+
let cells = 0;
|
|
15961
|
+
for (const char of safe(text)) {
|
|
15962
|
+
const size = panelCellWidth(char);
|
|
15963
|
+
if (cells + size > limit && line) {
|
|
15964
|
+
const space = line.lastIndexOf(" ");
|
|
15965
|
+
if (space > 0 && char !== " ") {
|
|
15966
|
+
lines.push(line.slice(0, space));
|
|
15967
|
+
line = line.slice(space + 1);
|
|
15968
|
+
// The retained word contains the same glyphs already measured above.
|
|
15969
|
+
cells = [...line].reduce((sum, glyph) => sum + panelCellWidth(glyph), 0);
|
|
15970
|
+
} else { lines.push(line.trimEnd()); line = ""; cells = 0; }
|
|
15971
|
+
}
|
|
15972
|
+
if (!line && char === " ") continue;
|
|
15973
|
+
line += char;
|
|
15974
|
+
cells += size;
|
|
15975
|
+
}
|
|
15976
|
+
if (line) lines.push(line.trimEnd());
|
|
15977
|
+
return lines;
|
|
15978
|
+
}
|
|
15979
|
+
|
|
15980
|
+
function contextualPanelLayout(view, { cols, rows } = {}) {
|
|
15981
|
+
const width = Math.max(1, cols - 5);
|
|
15982
|
+
const capacity = Math.max(1, rows - 6);
|
|
15983
|
+
const title = wrapPanelText(view.title, width);
|
|
15984
|
+
// Keep a short required remedy before explanation when the body is cramped.
|
|
15985
|
+
const action = capacity < 8 && /gh-glance --doctor/.test(view.action)
|
|
15986
|
+
? "gh-glance --doctor" : view.action;
|
|
15987
|
+
const lines = [view.title, action, view.sourceCheck, ...view.notes, view.explanation]
|
|
15988
|
+
.flatMap((line) => wrapPanelText(line, width));
|
|
15989
|
+
// A long title cannot consume the only row available for the recovery action.
|
|
15990
|
+
if (title.length >= capacity && capacity > 1) {
|
|
15991
|
+
return [...title.slice(0, capacity - 1), ...wrapPanelText(action, width).slice(0, 1)];
|
|
15992
|
+
}
|
|
15993
|
+
return lines.slice(0, capacity);
|
|
15994
|
+
}
|
|
15995
|
+
|
|
15996
|
+
// Selection is already resolved for acquisition. Displaying it must not ask gh
|
|
15997
|
+
// to resolve it again, or imply that the selected account can access it.
|
|
15998
|
+
function dashboardTarget({ repository = effectiveRuntimeRepository(), host = effectiveRuntimeHost() } = {}) {
|
|
15999
|
+
// These identifiers have a strict ASCII grammar. Validate the entire value
|
|
16000
|
+
// instead of applying the shorter retention limit for remote display fields.
|
|
16001
|
+
const slug = typeof repository === "string" && REPO_PATTERN.exec(repository)?.[0] === repository
|
|
16002
|
+
? repository : null;
|
|
16003
|
+
const selectedHost = normalizeHost(host);
|
|
16004
|
+
return { repository: slug, host: selectedHost,
|
|
16005
|
+
label: slug ? selectedHost && selectedHost !== "github.com" ? `${selectedHost}/${slug}` : slug
|
|
16006
|
+
: "Repository unresolved" };
|
|
16007
|
+
}
|
|
16008
|
+
|
|
16009
|
+
function clampHelpPage(index, count) {
|
|
16010
|
+
return Math.max(0, Math.min(Number.isSafeInteger(index) ? index : 0, Math.max(0, count - 1)));
|
|
16011
|
+
}
|
|
16012
|
+
|
|
16013
|
+
function contextHelpPages({ view, target, tab, remoteSetup = false }, keys = keyTableLines(), width = 75, height = 18) {
|
|
16014
|
+
const cols = Math.max(1, Math.trunc(width));
|
|
16015
|
+
const rows = Math.max(1, Math.trunc(height));
|
|
16016
|
+
const controlRows = cols >= 48 ? 1 : 2;
|
|
16017
|
+
const capacity = rows - controlRows;
|
|
16018
|
+
if (capacity < 1) return [[...wrapPanelText("q Quit", cols)].slice(0, rows)];
|
|
16019
|
+
const meaning = {
|
|
16020
|
+
actions: "Actions shows automated jobs. A push starts a run only when a workflow has a matching trigger.",
|
|
16021
|
+
issues: "Issues shows open issues in the selected repository.",
|
|
16022
|
+
prs: "Pull requests shows open requests to merge changes into the selected repository.",
|
|
16023
|
+
security: "Security shows alerts returned by the checked sources; unavailable sources can hide alerts.",
|
|
16024
|
+
};
|
|
16025
|
+
const title = remoteSetup ? "No GitHub remote found" : view.title;
|
|
16026
|
+
const action = remoteSetup ? "Close help with ?, then Enter to start gh repo create; or restart with gh-glance --repo owner/name."
|
|
16027
|
+
: view.action;
|
|
16028
|
+
// Wrap validated identity in bounded chunks so the remote-field sanitizer
|
|
16029
|
+
// cannot clip a long accepted target or consume its tail with a label prefix.
|
|
16030
|
+
const targetLines = [["Target", target.repository ?? target.label], ["Host", target.host ?? "unresolved"]]
|
|
16031
|
+
.flatMap(([label, value]) => {
|
|
16032
|
+
const text = `${label}: ${value}`;
|
|
16033
|
+
const wrapped = [];
|
|
16034
|
+
for (let offset = 0; offset < text.length; offset += MAX_FIELD_LENGTH) {
|
|
16035
|
+
wrapped.push(...wrapPanelText(text.slice(offset, offset + MAX_FIELD_LENGTH), cols));
|
|
16036
|
+
}
|
|
16037
|
+
return wrapped;
|
|
16038
|
+
});
|
|
16039
|
+
const context = [title, action, view.sourceCheck,
|
|
16040
|
+
...targetLines,
|
|
16041
|
+
"Target selection does not confirm access.",
|
|
16042
|
+
...(!target.repository ? ["Restart with gh-glance --repo owner/name."] : []),
|
|
16043
|
+
meaning[tab] === view.explanation ? null : meaning[tab], view.explanation, ...view.notes,
|
|
16044
|
+
"Other keys close help without performing their usual action.",
|
|
16045
|
+
"Keys (after closing help)", ...keys].filter(Boolean);
|
|
16046
|
+
const lines = context.flatMap((line) => wrapPanelText(line, cols));
|
|
16047
|
+
const chunks = [];
|
|
16048
|
+
// Narrow pages need room for navigation and quit. A compact first page keeps
|
|
16049
|
+
// the immediate state/remedy visible; all complete text follows unabridged.
|
|
16050
|
+
const priority = [title, action].flatMap((line) => wrapPanelText(line, cols));
|
|
16051
|
+
if (priority.length > capacity && capacity >= 2) {
|
|
16052
|
+
const shortAction = /gh-glance --doctor/.test(action) ? "gh-glance --doctor"
|
|
16053
|
+
: /gh auth/.test(action) ? "gh auth status" : remoteSetup ? "? then Enter"
|
|
16054
|
+
: /^Press r/.test(action) ? "Close ?, then r" : action;
|
|
16055
|
+
chunks.push([wrapPanelText(title, cols)[0], wrapPanelText(shortAction, cols)[0]]);
|
|
16056
|
+
}
|
|
16057
|
+
for (let offset = 0; offset < lines.length; offset += capacity) chunks.push(lines.slice(offset, offset + capacity));
|
|
16058
|
+
return chunks.map((chunk, index) => [...chunk,
|
|
16059
|
+
...(controlRows === 1
|
|
16060
|
+
? [`${index + 1}/${chunks.length} n next p prev ? close q/Esc/^C quit`]
|
|
16061
|
+
: [`${index + 1}/${chunks.length} n/p ?close`, "q/Esc/^C quit"])]);
|
|
16062
|
+
}
|
|
16063
|
+
|
|
15803
16064
|
function recoveryJournalPath(scope) {
|
|
15804
16065
|
return `${scope.path}.recovery.json`;
|
|
15805
16066
|
}
|
|
@@ -16406,6 +16667,7 @@ const KEY_HINTS = [
|
|
|
16406
16667
|
{ label: "Refresh", keys: "r" },
|
|
16407
16668
|
{ label: "Width", keys: "w" },
|
|
16408
16669
|
{ label: "Quit", keys: "q" },
|
|
16670
|
+
{ label: "Help", keys: "?" },
|
|
16409
16671
|
];
|
|
16410
16672
|
|
|
16411
16673
|
function activeKeyHints({
|
|
@@ -16428,6 +16690,7 @@ function activeKeyHints({
|
|
|
16428
16690
|
const REMOTE_SETUP_HINTS = [
|
|
16429
16691
|
{ label: "Create remote", keys: "Ent" },
|
|
16430
16692
|
{ label: "Quit", keys: "q" },
|
|
16693
|
+
{ label: "Help", keys: "?" },
|
|
16431
16694
|
];
|
|
16432
16695
|
|
|
16433
16696
|
const REMOTE_SETUP_LINES = [
|
|
@@ -16451,7 +16714,6 @@ const WIDE_STATE_WIDTH = REFRESH_STATUS_WIDTH + 1 + 12;
|
|
|
16451
16714
|
// StatusBar receives the drawable frame width, which is one column narrower
|
|
16452
16715
|
// than the terminal. A 44-cell threshold preserves the 45-column contract.
|
|
16453
16716
|
const WIDE_STATE_MIN_COLS = 44;
|
|
16454
|
-
const NOTICE_ROWS = 1;
|
|
16455
16717
|
|
|
16456
16718
|
const REFRESH_STATUS_GLYPHS = Object.freeze({
|
|
16457
16719
|
unicode: Object.freeze({
|
|
@@ -16473,7 +16735,7 @@ const REFRESH_STATUS_GLYPHS = Object.freeze({
|
|
|
16473
16735
|
});
|
|
16474
16736
|
|
|
16475
16737
|
function isMandatoryHint(hint) {
|
|
16476
|
-
return hint?.label === "Refresh" || hint?.label === "Quit";
|
|
16738
|
+
return hint?.label === "Refresh" || hint?.label === "Quit" || hint?.label === "Help";
|
|
16477
16739
|
}
|
|
16478
16740
|
|
|
16479
16741
|
function refreshStatus({
|
|
@@ -16597,7 +16859,7 @@ function statusBarLayout({
|
|
|
16597
16859
|
for (const [index, hint] of mandatory.entries()) {
|
|
16598
16860
|
// A full label is optional; every essential key is not. Reserve one
|
|
16599
16861
|
// separator and the key itself for each action still to come before taking
|
|
16600
|
-
// the current action's long form. This keeps
|
|
16862
|
+
// the current action's long form. This keeps r, q and ? at the 24-column
|
|
16601
16863
|
// terminal minimum, whose drawable frame is 23 cells wide.
|
|
16602
16864
|
const remainingMinimum = mandatory.slice(index + 1).reduce(
|
|
16603
16865
|
(total, candidate) => total + 1 + [...candidate.keys].length,
|
|
@@ -17006,10 +17268,13 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
17006
17268
|
// The `?` overlay. Renders only on a keypress, so consecutive idle frames are
|
|
17007
17269
|
// still byte-identical and the redraw suppression is untouched.
|
|
17008
17270
|
const [showHelp, setShowHelp] = useState(false);
|
|
17271
|
+
const [helpPage, setHelpPage] = useState(0);
|
|
17009
17272
|
// Set once, ICON_HINT_AFTER_MS into a first load that is still running. Never
|
|
17010
17273
|
// reset: it only gates a line that a resolved tab stops rendering anyway, and
|
|
17011
17274
|
// clearing it would cost a second state write for no visible difference.
|
|
17012
17275
|
const [iconHintDue, setIconHintDue] = useState(false);
|
|
17276
|
+
const [actionsSetup, setActionsSetup] = useState({});
|
|
17277
|
+
const actionsSetupRef = useRef(null);
|
|
17013
17278
|
const [errors, setErrors] = useState({ actions: null, issues: null, prs: null, security: null });
|
|
17014
17279
|
const [failureContext, setFailureContext] = useState(null);
|
|
17015
17280
|
const [loading, setLoading] = useState({ actions: false, issues: false, prs: false, security: false });
|
|
@@ -17064,6 +17329,9 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
17064
17329
|
// transient, and they are the ones worth the space. Derived here at render
|
|
17065
17330
|
// time rather than cached alongside the notes, because the unchanged-payload
|
|
17066
17331
|
// short-circuit can skip parse() entirely and a cached string would go stale.
|
|
17332
|
+
// Each unsuccessful source contributes one note; successful sources contribute
|
|
17333
|
+
// none (truncation is separate). Count before collapsing disabled-source notes.
|
|
17334
|
+
const securityAllUnavailable = securityNotes.length === ALERT_SOURCES.length;
|
|
17067
17335
|
const NOT_ENABLED = /not enabled/i;
|
|
17068
17336
|
const disabledNotes = securityNotes.filter((n) => NOT_ENABLED.test(n));
|
|
17069
17337
|
const otherNotes = securityNotes.filter((n) => !NOT_ENABLED.test(n));
|
|
@@ -17074,19 +17342,173 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
17074
17342
|
...otherNotes,
|
|
17075
17343
|
]
|
|
17076
17344
|
: securityNotes;
|
|
17077
|
-
|
|
17078
|
-
//
|
|
17079
|
-
|
|
17080
|
-
|
|
17081
|
-
|
|
17082
|
-
|
|
17083
|
-
|
|
17084
|
-
|
|
17085
|
-
|
|
17086
|
-
|
|
17087
|
-
|
|
17088
|
-
|
|
17089
|
-
|
|
17345
|
+
const admittedCadenceRef = useRef({});
|
|
17346
|
+
// Last *successful* poll per tab, wall-clock. Wall-clock on purpose: a laptop
|
|
17347
|
+
// sleeping is exactly the gap this is meant to report, and a monotonic clock
|
|
17348
|
+
// does not advance across suspend. Never written on the failure path, or a
|
|
17349
|
+
// persistently failing tab would report itself fresh forever.
|
|
17350
|
+
const lastOkRef = useRef(
|
|
17351
|
+
Object.fromEntries(
|
|
17352
|
+
TABS.flatMap((candidate) => {
|
|
17353
|
+
const cachedTab = cachedEntry?.tabs[candidate.key];
|
|
17354
|
+
const cachedAge = collectorCachedSourceAge(cachedTab);
|
|
17355
|
+
const lastOk = cachedAge === null ? cachedTab?.lastOk : Date.now() - cachedAge;
|
|
17356
|
+
return lastOk == null ? [] : [[candidate.key, lastOk]];
|
|
17357
|
+
}),
|
|
17358
|
+
),
|
|
17359
|
+
);
|
|
17360
|
+
const collectorSourceAgeRef = useRef(Object.fromEntries(TABS.flatMap((candidate) => {
|
|
17361
|
+
const cachedTab = cachedEntry?.tabs[candidate.key];
|
|
17362
|
+
const ageMs = collectorCachedSourceAge(cachedTab);
|
|
17363
|
+
return ageMs === null ? [] : [[candidate.key, { ageMs, measuredAt: performance.now() }]];
|
|
17364
|
+
})));
|
|
17365
|
+
const statusDisconnected = runtime.connect !== null
|
|
17366
|
+
? activeGovernorDecision?.disconnected === true
|
|
17367
|
+
: data[tab.key] !== null && !runtimeIdentityCoordinator?.current();
|
|
17368
|
+
const items = data[tab.key];
|
|
17369
|
+
const displayError = formatTabErrorForWidth(tabError, failureContext, Math.max(1, cols - 5));
|
|
17370
|
+
const coordinationError = activeGovernorDecision?.coordinationError && !remoteSetup;
|
|
17371
|
+
const coordinationReason = activeFailure ?? activeGovernorDecision?.reason ?? "unavailable";
|
|
17372
|
+
const noticeReason = activeRecovery?.code ??
|
|
17373
|
+
(tabError?.kind === "fetch" ? tabError.verdict : null) ??
|
|
17374
|
+
(coordinationError || activeGovernorDecision?.mode === "paused" ? coordinationReason : null) ??
|
|
17375
|
+
(statusDisconnected ? runtime.connect === null ? "identity-unavailable"
|
|
17376
|
+
: activeGovernorDecision?.reason === "app-auth" ? "app-auth" : "disconnected" : null);
|
|
17377
|
+
// A continuous fault stays sustained when its explanation becomes more
|
|
17378
|
+
// specific. Only losing the fault (or switching tabs) restarts the debounce.
|
|
17379
|
+
const coordinationCondition = !remoteSetup && noticeReason ? tab.key : null;
|
|
17380
|
+
useEffect(() => {
|
|
17381
|
+
setVisibleCoordinationCondition(null);
|
|
17382
|
+
if (coordinationCondition === null) return;
|
|
17383
|
+
const timer = setTimeout(
|
|
17384
|
+
() => setVisibleCoordinationCondition(coordinationCondition),
|
|
17385
|
+
COORDINATION_NOTICE_AFTER_MS,
|
|
17386
|
+
);
|
|
17387
|
+
return () => clearTimeout(timer);
|
|
17388
|
+
}, [coordinationCondition]);
|
|
17389
|
+
const showCoordinationNotice = coordinationCondition !== null &&
|
|
17390
|
+
visibleCoordinationCondition === coordinationCondition;
|
|
17391
|
+
const noticeResource = activeResource;
|
|
17392
|
+
const noticeSourceAt = collectorSourceAgeRef.current[tab.key]
|
|
17393
|
+
? now.getTime() - collectorSourceAgeRef.current[tab.key].ageMs -
|
|
17394
|
+
Math.max(0, performance.now() - collectorSourceAgeRef.current[tab.key].measuredAt)
|
|
17395
|
+
: lastOkRef.current[tab.key] ?? null;
|
|
17396
|
+
const noticeCause = activeRecovery ?? (noticeReason ? recoveryCause({
|
|
17397
|
+
reason: noticeReason === "other" ? "network-outage" : noticeReason,
|
|
17398
|
+
resource: noticeResource, sourceAt: noticeSourceAt,
|
|
17399
|
+
retryAt: activeGovernorDecision?.retryAt ?? activeGovernorDecision?.resetMs ??
|
|
17400
|
+
activeGovernorDecision?.notBefore ?? null,
|
|
17401
|
+
origin: tabError ? "source" : "coordination", tab: tabError ? tab.key : null,
|
|
17402
|
+
}) : null);
|
|
17403
|
+
const recoveryLines = showCoordinationNotice && noticeCause
|
|
17404
|
+
? presentRecovery(noticeCause, { nowMs: now.getTime(), cols, hasSnapshot: items !== null })
|
|
17405
|
+
: [!remoteSetup && displayError ? displayError : ""];
|
|
17406
|
+
const noticeTone = showCoordinationNotice ? ATTENTION : displayError ? ERROR_TEXT : undefined;
|
|
17407
|
+
// Data can be arbitrarily old without anything on screen saying so: a failing
|
|
17408
|
+
// poll only surfaces on the tab you have selected, and after a laptop sleep
|
|
17409
|
+
// every pane is plausible and wrong. Threshold-gated and minute-granular on
|
|
17410
|
+
// purpose -- see STALE_AFTER_MS.
|
|
17411
|
+
// Read from the ref written on every successful poll, not from meta.at, which
|
|
17412
|
+
// only moves when the *payload* changes -- see lastOkRef. Reading a ref during
|
|
17413
|
+
// render lags by one render, which is harmless here because `now` advances on
|
|
17414
|
+
// its own and the label is minute-granular by design.
|
|
17415
|
+
const collectorAge = collectorSourceAgeRef.current[tab.key];
|
|
17416
|
+
const sourceAgeMs = collectorAge
|
|
17417
|
+
? collectorAge.ageMs + Math.max(0, performance.now() - collectorAge.measuredAt)
|
|
17418
|
+
: null;
|
|
17419
|
+
const lastOk = sourceAgeMs === null ? lastOkRef.current[tab.key] : now.getTime() - sourceAgeMs;
|
|
17420
|
+
const staleFor = sourceAgeMs ?? (lastOk == null ? null : now.getTime() - lastOk);
|
|
17421
|
+
const staleAt = freshnessDeadline({
|
|
17422
|
+
lastOk,
|
|
17423
|
+
refreshMs: runtime.refreshMs,
|
|
17424
|
+
grantedMs: admittedCadenceRef.current[tab.key]?.grantedMs,
|
|
17425
|
+
governorDecision: activeGovernorDecision,
|
|
17426
|
+
currentEpochs: governorEpochs,
|
|
17427
|
+
});
|
|
17428
|
+
const staleLabel =
|
|
17429
|
+
staleFor != null && staleAt != null && now.getTime() > staleAt
|
|
17430
|
+
? formatDuration(staleFor)
|
|
17431
|
+
: null;
|
|
17432
|
+
// A quiet dashboard's display clock can precede a newly completed request.
|
|
17433
|
+
// Compare source clocks with actual render time, without adding a timer.
|
|
17434
|
+
const presentationNow = Date.now();
|
|
17435
|
+
const selectedTarget = dashboardTarget();
|
|
17436
|
+
const presentation = dashboardPresentation({
|
|
17437
|
+
tab: tab.key, rows: items, hasSnapshot: items !== null,
|
|
17438
|
+
sourceAt: sourceAgeMs === null ? lastOk : presentationNow - sourceAgeMs,
|
|
17439
|
+
nowMs: presentationNow, overdue: Boolean(staleLabel),
|
|
17440
|
+
onDemand: runtime.background === "on-demand" && tab.key !== "actions" &&
|
|
17441
|
+
items !== null && !loading[tab.key] && !activeRequestStatus,
|
|
17442
|
+
requestState: loading[tab.key] || activeRequestStatus ? "loading"
|
|
17443
|
+
: items === null && waiting[tab.key] ? "waiting" : "idle",
|
|
17444
|
+
error: tabError || activeFailure, recovery: noticeCause, diagnostic: displayError,
|
|
17445
|
+
disconnected: statusDisconnected, securityNotes: securityLines, securityAllUnavailable,
|
|
17446
|
+
target: selectedTarget.label,
|
|
17447
|
+
});
|
|
17448
|
+
const setupView = tab.key === "actions" ? actionsSetupPresentation(actionsSetup, presentationNow) : null;
|
|
17449
|
+
if (tab.key === "actions") {
|
|
17450
|
+
const url = actionsPageUrl(selectedTarget);
|
|
17451
|
+
if (runtime.connect !== null && setupView.title && (statusDisconnected || noticeCause || tabError)) {
|
|
17452
|
+
setupView.action = `${statusDisconnected ? "Restore connection;" : presentation.action} g Actions; ? Help`;
|
|
17453
|
+
}
|
|
17454
|
+
const showSetupPanel = !presentation.showTable && !remoteSetup &&
|
|
17455
|
+
((presentation.state === "ready" && !showCoordinationNotice) || runtime.connect !== null);
|
|
17456
|
+
presentation.notes.push(...(setupView.title && !showSetupPanel ? [setupView.title] : []), ...setupView.notes,
|
|
17457
|
+
"Close help with ?, then press s for a read-only setup check or g to open Actions.",
|
|
17458
|
+
...(url ? [url] : ["Restart with gh-glance --repo owner/name."]));
|
|
17459
|
+
if (showSetupPanel) {
|
|
17460
|
+
if (setupView.title) {
|
|
17461
|
+
presentation.notes.push(`Runs: ${presentation.title}`, presentation.action);
|
|
17462
|
+
presentation.title = setupView.title;
|
|
17463
|
+
}
|
|
17464
|
+
if (setupView.title) presentation.sourceCheck = `Runs: ${presentation.sourceCheck}`;
|
|
17465
|
+
if (setupView.title || presentation.state === "ready") presentation.action = setupView.action;
|
|
17466
|
+
}
|
|
17467
|
+
}
|
|
17468
|
+
if (USING_NERD_ICONS && iconHintDue && items === null && !tabError) {
|
|
17469
|
+
presentation.notes.push("Icons blank? GH_GLANCE_ICONS=unicode");
|
|
17470
|
+
}
|
|
17471
|
+
const panelLines = presentation.showTable ? [] : contextualPanelLayout(presentation, { cols, rows });
|
|
17472
|
+
|
|
17473
|
+
const tableVisible = Array.isArray(items) && items.length > 0;
|
|
17474
|
+
const tableNoticeCapacity = Math.max(0, rows - 9);
|
|
17475
|
+
const needsTableContext = ["failed", "overdue", "waiting"].includes(presentation.state);
|
|
17476
|
+
const shortAction = /gh-glance --doctor/.test(presentation.action) ? "gh-glance --doctor"
|
|
17477
|
+
: /gh auth/.test(presentation.action) ? "Run gh auth status" : presentation.action;
|
|
17478
|
+
const tableNotices = needsTableContext
|
|
17479
|
+
? tableNoticeCapacity < 4
|
|
17480
|
+
? tableNoticeCapacity === 1 ? [shortAction]
|
|
17481
|
+
: showCoordinationNotice && noticeCause
|
|
17482
|
+
? [noticeCause.summary, shortAction, presentation.sourceCheck]
|
|
17483
|
+
: [shortAction, presentation.sourceCheck, presentation.title]
|
|
17484
|
+
: [presentation.title, presentation.sourceCheck, ...recoveryLines.filter(Boolean)]
|
|
17485
|
+
: recoveryLines;
|
|
17486
|
+
const noticeLines = [...new Set([...(showCoordinationNotice ? tableNotices : []),
|
|
17487
|
+
...(setupView?.title ? [setupView.title, setupView.action, ...setupView.notes] : []),
|
|
17488
|
+
...tableNotices])].slice(0, tableNoticeCapacity);
|
|
17489
|
+
const visibleSecurityLines = tab.key === "security"
|
|
17490
|
+
? securityLines.slice(0, Math.max(0, tableNoticeCapacity - noticeLines.length)) : [];
|
|
17491
|
+
const extraLines = !showHelp && !remoteSetup && tableVisible
|
|
17492
|
+
? noticeLines.length + visibleSecurityLines.length : 0;
|
|
17493
|
+
// Tab strip, divider, frame edges, footer and terminal guard use six rows.
|
|
17494
|
+
// Table headers alone need two more. The same budget drives navigation.
|
|
17495
|
+
const bodyRows = Math.max(1, rows - 6 - (tableVisible && !showHelp ? 2 : 0) - extraLines);
|
|
17496
|
+
let helpPages = [];
|
|
17497
|
+
if (showHelp) {
|
|
17498
|
+
const fullDiagnostic = formatTabError(tabError, failureContext);
|
|
17499
|
+
const helpView = { ...presentation, notes: [
|
|
17500
|
+
...(fullDiagnostic ? [fullDiagnostic] : []),
|
|
17501
|
+
...presentation.notes.filter((note) => note !== displayError),
|
|
17502
|
+
] };
|
|
17503
|
+
helpPages = contextHelpPages({ view: helpView, target: selectedTarget,
|
|
17504
|
+
tab: tab.key, remoteSetup }, keyTableLines(), cols - 5, bodyRows);
|
|
17505
|
+
}
|
|
17506
|
+
const visibleHelpPage = clampHelpPage(helpPage, helpPages.length);
|
|
17507
|
+
const helpPageRef = useRef({ index: 0, count: 0 });
|
|
17508
|
+
helpPageRef.current = { index: visibleHelpPage, count: helpPages.length };
|
|
17509
|
+
useEffect(() => {
|
|
17510
|
+
if (helpPage !== visibleHelpPage) setHelpPage(visibleHelpPage);
|
|
17511
|
+
}, [helpPage, visibleHelpPage]);
|
|
17090
17512
|
|
|
17091
17513
|
// Read at fetch time rather than being a hook dependency, so dragging the
|
|
17092
17514
|
// pane wider doesn't cancel and restart in-flight requests -- the next tick
|
|
@@ -17109,28 +17531,8 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
17109
17531
|
ANIMATE && tab.key === "actions" && (data.actions ?? []).some((r) => r.status === "in_progress");
|
|
17110
17532
|
|
|
17111
17533
|
const inFlightRef = useRef({});
|
|
17112
|
-
const admittedCadenceRef = useRef({});
|
|
17113
17534
|
const rawRef = useRef({});
|
|
17114
17535
|
const entityRef = useRef(new Map());
|
|
17115
|
-
// Last *successful* poll per tab, wall-clock. Wall-clock on purpose: a laptop
|
|
17116
|
-
// sleeping is exactly the gap this is meant to report, and a monotonic clock
|
|
17117
|
-
// does not advance across suspend. Never written on the failure path, or a
|
|
17118
|
-
// persistently failing tab would report itself fresh forever.
|
|
17119
|
-
const lastOkRef = useRef(
|
|
17120
|
-
Object.fromEntries(
|
|
17121
|
-
TABS.flatMap((candidate) => {
|
|
17122
|
-
const cachedTab = cachedEntry?.tabs[candidate.key];
|
|
17123
|
-
const cachedAge = collectorCachedSourceAge(cachedTab);
|
|
17124
|
-
const lastOk = cachedAge === null ? cachedTab?.lastOk : Date.now() - cachedAge;
|
|
17125
|
-
return lastOk == null ? [] : [[candidate.key, lastOk]];
|
|
17126
|
-
}),
|
|
17127
|
-
),
|
|
17128
|
-
);
|
|
17129
|
-
const collectorSourceAgeRef = useRef(Object.fromEntries(TABS.flatMap((candidate) => {
|
|
17130
|
-
const cachedTab = cachedEntry?.tabs[candidate.key];
|
|
17131
|
-
const ageMs = collectorCachedSourceAge(cachedTab);
|
|
17132
|
-
return ageMs === null ? [] : [[candidate.key, { ageMs, measuredAt: performance.now() }]];
|
|
17133
|
-
})));
|
|
17134
17536
|
// Keep server source timestamps separate from client-local age estimates.
|
|
17135
17537
|
// An SSH receipt's age is measured on the client and cannot prove a new
|
|
17136
17538
|
// source observation when a cached snapshot is delivered again.
|
|
@@ -17426,6 +17828,25 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
17426
17828
|
const [openRequests] = useState(() => createOpenRequestRegistry());
|
|
17427
17829
|
useEffect(() => () => openRequests.abortAll(), [openRequests]);
|
|
17428
17830
|
|
|
17831
|
+
function openActionsPage() {
|
|
17832
|
+
const target = dashboardTarget();
|
|
17833
|
+
const url = actionsPageUrl(target);
|
|
17834
|
+
if (!url) {
|
|
17835
|
+
setActionsSetup((state) => ({ ...state, message: "Restart with gh-glance --repo owner/name." }));
|
|
17836
|
+
return;
|
|
17837
|
+
}
|
|
17838
|
+
const accessKey = runtimeIdentityCoordinator?.current()?.accessKey;
|
|
17839
|
+
openRequests.start(`actions-page:${url}`, ({ signal }) => openBrowserUrl(url, signal))?.then(() => {
|
|
17840
|
+
if (actionsPageUrl() === url && runtimeIdentityCoordinator?.current()?.accessKey === accessKey) {
|
|
17841
|
+
setActionsSetup((state) => ({ ...state, message: null, title: null, action: null }));
|
|
17842
|
+
}
|
|
17843
|
+
}).catch((err) => {
|
|
17844
|
+
if (err?.name === "AbortError" || actionsPageUrl() !== url ||
|
|
17845
|
+
runtimeIdentityCoordinator?.current()?.accessKey !== accessKey) return;
|
|
17846
|
+
setActionsSetup((state) => ({ ...state, title: "Actions page unavailable", action: "g Retry; ? Details", message: `Could not open Actions: ${shortErr(err)}. Press g to retry.` }));
|
|
17847
|
+
});
|
|
17848
|
+
}
|
|
17849
|
+
|
|
17429
17850
|
function openSelected() {
|
|
17430
17851
|
const { items, key: currentKey, tabKey } = navRef.current;
|
|
17431
17852
|
const item = items.find((i) => itemKey(i) === currentKey);
|
|
@@ -17480,13 +17901,19 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
17480
17901
|
flushWidthPreferences();
|
|
17481
17902
|
exit();
|
|
17482
17903
|
} else if (showHelpRef.current) {
|
|
17483
|
-
//
|
|
17484
|
-
//
|
|
17485
|
-
|
|
17486
|
-
|
|
17487
|
-
|
|
17904
|
+
// Paging belongs to the modal. Every other ordinary key closes it and
|
|
17905
|
+
// is consumed; quit remains global and width mode retains its ownership.
|
|
17906
|
+
if (input === "n" || input === "p") {
|
|
17907
|
+
setHelpPage(clampHelpPage(helpPageRef.current.index + (input === "n" ? 1 : -1),
|
|
17908
|
+
helpPageRef.current.count));
|
|
17909
|
+
} else setShowHelp(false);
|
|
17488
17910
|
} else if (input === "?") {
|
|
17911
|
+
setHelpPage(0);
|
|
17489
17912
|
setShowHelp(true);
|
|
17913
|
+
} else if (input === "s" && TABS[activeIndexRef.current].key === "actions") {
|
|
17914
|
+
void actionsSetupRef.current?.();
|
|
17915
|
+
} else if (input === "g" && TABS[activeIndexRef.current].key === "actions") {
|
|
17916
|
+
openActionsPage();
|
|
17490
17917
|
} else if (input === "w") {
|
|
17491
17918
|
enterWidthMode();
|
|
17492
17919
|
} else if (key.downArrow || input === "j") {
|
|
@@ -17615,7 +18042,11 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
17615
18042
|
reason, resource, at, retryAt, sourceAt: lastOkRef.current[key] ?? null,
|
|
17616
18043
|
origin, tab: origin === "source" ? key : null,
|
|
17617
18044
|
});
|
|
17618
|
-
const
|
|
18045
|
+
const storageFailures = ["corrupt", "unwritable", "capacity", "accounting-overflow"];
|
|
18046
|
+
const hardFailure = storageFailures.includes(candidate.code);
|
|
18047
|
+
// Contention does not prove that previously unreadable storage recovered.
|
|
18048
|
+
// The existing healthy-control/source-success paths clear that remedy.
|
|
18049
|
+
if (!hardFailure && storageFailures.includes(previous?.code)) return;
|
|
17619
18050
|
if (!hardFailure && previous?.code === "receipt-retry" &&
|
|
17620
18051
|
terminalizationBacklogForResource(terminalizationBacklog, scope?.hash, resource) > 0) return;
|
|
17621
18052
|
if (!hardFailure && previous?.code === "request-queue" &&
|
|
@@ -17715,6 +18146,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
17715
18146
|
// scheduler out, and the Enter hint hands off to `gh repo create`.
|
|
17716
18147
|
function enterRemoteSetup() {
|
|
17717
18148
|
if (!runtimeHasNoRepositoryTarget()) return false;
|
|
18149
|
+
retireSetup();
|
|
17718
18150
|
const failure = toTabError(new Error(NO_REMOTE_ERROR_TEXT));
|
|
17719
18151
|
setErrors(Object.fromEntries(TABS.map((t) => [t.key, failure])));
|
|
17720
18152
|
return true;
|
|
@@ -18110,7 +18542,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
18110
18542
|
.finally(() => {
|
|
18111
18543
|
inFlightRef.current[key] = false;
|
|
18112
18544
|
delete inFlightStartedAt[key];
|
|
18113
|
-
if (!currentAccess() && !Object.values(inFlightRef.current).some(Boolean) && runtimeIdentityCoordinator?.current()?.quotaKey !== settlementScope.quotaKey) releaseLease(settlementScope, leaseId);
|
|
18545
|
+
if (!currentAccess() && !setupFlight && !Object.values(inFlightRef.current).some(Boolean) && runtimeIdentityCoordinator?.current()?.quotaKey !== settlementScope.quotaKey) releaseLease(settlementScope, leaseId);
|
|
18114
18546
|
manualInFlight.delete(key);
|
|
18115
18547
|
onSettled?.();
|
|
18116
18548
|
if (!cancelled) {
|
|
@@ -18136,6 +18568,23 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
18136
18568
|
}
|
|
18137
18569
|
|
|
18138
18570
|
const leaseId = governorId();
|
|
18571
|
+
let setupFlight = null;
|
|
18572
|
+
let setupGeneration = 0;
|
|
18573
|
+
let setupPartition = null;
|
|
18574
|
+
let setupState = {};
|
|
18575
|
+
function publishSetup(next) {
|
|
18576
|
+
setupState = next;
|
|
18577
|
+
if (!cancelled) setActionsSetup(next);
|
|
18578
|
+
}
|
|
18579
|
+
function retireSetup() {
|
|
18580
|
+
setupGeneration += 1;
|
|
18581
|
+
if (setupFlight) {
|
|
18582
|
+
setupFlight.retired = true;
|
|
18583
|
+
setupFlight.controller.abort();
|
|
18584
|
+
}
|
|
18585
|
+
setupPartition = null;
|
|
18586
|
+
publishSetup({});
|
|
18587
|
+
}
|
|
18139
18588
|
const pending = new Map();
|
|
18140
18589
|
const cleanupQueue = new Set();
|
|
18141
18590
|
let lastWallAt = Date.now();
|
|
@@ -18258,6 +18707,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
18258
18707
|
}
|
|
18259
18708
|
}
|
|
18260
18709
|
|
|
18710
|
+
actionsSetupRef.current = () => publishSetup({ title: "Standalone only", action: "g Actions; ? Help", message: "Setup checks are available in standalone mode. Press g to open Actions." });
|
|
18261
18711
|
if (runtime.connect !== null) {
|
|
18262
18712
|
const repository = effectiveRuntimeRepository({ remoteUrls: runtimeRemoteUrls });
|
|
18263
18713
|
const host = effectiveRuntimeHost({ remoteUrls: runtimeRemoteUrls });
|
|
@@ -18651,13 +19101,14 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
18651
19101
|
}
|
|
18652
19102
|
|
|
18653
19103
|
function retireCurrentScope() {
|
|
19104
|
+
retireSetup();
|
|
18654
19105
|
if (!cleanupScope || !registeredScopeHash) return;
|
|
18655
19106
|
for (const key of [...pending.keys()]) cancelPending(key, cleanupScope);
|
|
18656
19107
|
for (const subscription of acquisitionSubscriptions.values()) {
|
|
18657
19108
|
acquisitionEngine.unsubscribe(subscription.id);
|
|
18658
19109
|
}
|
|
18659
19110
|
acquisitionSubscriptions.clear();
|
|
18660
|
-
if (!Object.values(inFlightRef.current).some(Boolean)) releaseLease(cleanupScope, leaseId);
|
|
19111
|
+
if (!setupFlight && !Object.values(inFlightRef.current).some(Boolean)) releaseLease(cleanupScope, leaseId);
|
|
18661
19112
|
}
|
|
18662
19113
|
|
|
18663
19114
|
// discardData separates the two reasons this runs. An account *change* must
|
|
@@ -18733,6 +19184,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
18733
19184
|
const activeTab = TABS[activeIndexRef.current].key;
|
|
18734
19185
|
const kept = maintainControlLease(scope, leaseId, runtime.refreshMs, activeTab, nowMs);
|
|
18735
19186
|
if (kept.ok) return scope;
|
|
19187
|
+
retireSetup();
|
|
18736
19188
|
registeredScopeHash = null;
|
|
18737
19189
|
if (governorRef.current?.leaseId === leaseId) governorRef.current = null;
|
|
18738
19190
|
return null;
|
|
@@ -19126,6 +19578,85 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
19126
19578
|
} else await transport;
|
|
19127
19579
|
return { persisted: true, retry: false, kind, key };
|
|
19128
19580
|
}
|
|
19581
|
+
actionsSetupRef.current = async () => {
|
|
19582
|
+
if (cancelled || setupFlight) return;
|
|
19583
|
+
const nowMs = Date.now();
|
|
19584
|
+
const target = dashboardTarget();
|
|
19585
|
+
if (remoteSetupRef.current || !actionsPageUrl(target)) {
|
|
19586
|
+
publishSetup({ message: "Restart with gh-glance --repo owner/name." });
|
|
19587
|
+
return;
|
|
19588
|
+
}
|
|
19589
|
+
const capturedScope = ensureScope(nowMs);
|
|
19590
|
+
if (!capturedScope) {
|
|
19591
|
+
publishSetup({ ...setupState, error: "Setup check unavailable while account coordination recovers.",
|
|
19592
|
+
retryAt: actionsSetupRetryAt(nowMs) });
|
|
19593
|
+
return;
|
|
19594
|
+
}
|
|
19595
|
+
const partition = JSON.stringify(["standalone", target.host, target.repository, capturedScope.accessKey]);
|
|
19596
|
+
if (setupPartition !== partition) { retireSetup(); setupPartition = partition; }
|
|
19597
|
+
if (setupState.retryAt > nowMs) { publishSetup({ ...setupState }); return; }
|
|
19598
|
+
if (actionsSetupFresh(setupState.result, nowMs)) {
|
|
19599
|
+
publishSetup({ ...setupState, saved: true, error: null, message: null });
|
|
19600
|
+
return;
|
|
19601
|
+
}
|
|
19602
|
+
const generation = setupGeneration;
|
|
19603
|
+
const flight = { controller: new AbortController(), scope: { ...capturedScope, identityProvider: null }, retired: false };
|
|
19604
|
+
setupFlight = flight;
|
|
19605
|
+
const setupSignal = AbortSignal.any([controller.signal, flight.controller.signal]);
|
|
19606
|
+
const current = () => !cancelled && !setupSignal.aborted && generation === setupGeneration &&
|
|
19607
|
+
setupPartition === partition && identity()?.accessKey === capturedScope.accessKey &&
|
|
19608
|
+
actionsPageUrl() === actionsPageUrl(target) && !remoteSetupRef.current;
|
|
19609
|
+
publishSetup({ ...setupState, busy: true, error: null, saved: false, message: null });
|
|
19610
|
+
try {
|
|
19611
|
+
const admitted = await runAdmittedOperation({ scope: capturedScope, leaseId,
|
|
19612
|
+
operation: "setup:actions-workflows", priority: "manual", signal: setupSignal,
|
|
19613
|
+
waitMs: GOVERNOR_ADMISSION_WAIT_MS,
|
|
19614
|
+
run: async (signal) => {
|
|
19615
|
+
const response = await ghApi(actionsSetupArgs(target), {
|
|
19616
|
+
operation: "setup:actions-workflows", signal, includeHeaders: true,
|
|
19617
|
+
});
|
|
19618
|
+
return { ...response, observation: parseActionsSetup(response.body, response.headers), checkedAt: Date.now() };
|
|
19619
|
+
},
|
|
19620
|
+
});
|
|
19621
|
+
if (!current()) return;
|
|
19622
|
+
if (!admitted.ok) {
|
|
19623
|
+
const error = admitted.error;
|
|
19624
|
+
const failedAt = Date.now();
|
|
19625
|
+
let snapshot = inspectGovernor(capturedScope, failedAt);
|
|
19626
|
+
const throttle = classifyThrottle({ ...error?.apiResponse, stderr: errText(error) });
|
|
19627
|
+
let holdUntil = 0;
|
|
19628
|
+
if (classify(error) === "rate-limited" || throttle.kind !== "none") {
|
|
19629
|
+
const budget = snapshot.value?.budgets?.core;
|
|
19630
|
+
const resetMs = throttle.kind === "primary" ? throttle.resetMs
|
|
19631
|
+
: throttle.kind === "secondary" ? transportCooldownDeadline({ ...throttle, at: failedAt })
|
|
19632
|
+
: budget?.resetMs ?? null;
|
|
19633
|
+
holdUntil = resetMs ?? 0;
|
|
19634
|
+
handleRateLimitBlocks("actions", [{ resource: "core", resetMs,
|
|
19635
|
+
epoch: budget?.epoch ?? null }], failedAt);
|
|
19636
|
+
snapshot = inspectGovernor(capturedScope, failedAt);
|
|
19637
|
+
}
|
|
19638
|
+
const retryAt = actionsSetupRetryAt(failedAt, Math.max(
|
|
19639
|
+
holdUntil, snapshot.value?.budgets?.core?.blockUntil ?? 0, admitted.decision?.retryAt ?? 0,
|
|
19640
|
+
admitted.decision?.value?.retryAt ?? 0, admitted.decision?.value?.notBefore ?? 0,
|
|
19641
|
+
admitted.decision?.value?.resetMs ?? 0,
|
|
19642
|
+
error?.retryAt ?? 0, failedAt + githubAppResponseRetryAfter(error?.apiResponse, failedAt)));
|
|
19643
|
+
publishSetup({ ...setupState, busy: false, error: shortErr(error), retryAt });
|
|
19644
|
+
return;
|
|
19645
|
+
}
|
|
19646
|
+
publishSetup({ result: { ...admitted.value.observation, checkedAt: admitted.value.checkedAt }, busy: false });
|
|
19647
|
+
if (terminalizationBacklogForResource(terminalizationBacklog, capturedScope.hash, "core") > 0) {
|
|
19648
|
+
recordRuntimeRecovery("actions", "receipt-retry");
|
|
19649
|
+
}
|
|
19650
|
+
} finally {
|
|
19651
|
+
if (setupFlight === flight) setupFlight = null;
|
|
19652
|
+
if ((cancelled || (flight.retired && flight.scope.hash !== scope?.hash)) &&
|
|
19653
|
+
!Object.values(inFlightRef.current).some(Boolean)) {
|
|
19654
|
+
releaseLease(flight.scope, leaseId);
|
|
19655
|
+
}
|
|
19656
|
+
if (current() && setupState.busy) publishSetup({ ...setupState, busy: false });
|
|
19657
|
+
}
|
|
19658
|
+
};
|
|
19659
|
+
|
|
19129
19660
|
fetchTabRef.current = (key, { force = false, kind = force ? "manual" : "active" } = {}) => {
|
|
19130
19661
|
if (kind === "tab-switch") {
|
|
19131
19662
|
// A newly active subscription is not evidence of quiet: the tab you
|
|
@@ -19513,7 +20044,9 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
19513
20044
|
acquisitionEngine.unsubscribe(subscription.id);
|
|
19514
20045
|
}
|
|
19515
20046
|
acquisitionSubscriptions.clear();
|
|
19516
|
-
|
|
20047
|
+
retireSetup();
|
|
20048
|
+
actionsSetupRef.current = null;
|
|
20049
|
+
if (cleanupScope && registeredScopeHash && !setupFlight) releaseLease(cleanupScope, leaseId);
|
|
19517
20050
|
if (governorRef.current?.leaseId === leaseId) governorRef.current = null;
|
|
19518
20051
|
if (contextCoordinatorRef.current === coordinator) contextCoordinatorRef.current = null;
|
|
19519
20052
|
// `cancelled` stops state updates from a promise that already resolved;
|
|
@@ -19551,9 +20084,6 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
19551
20084
|
TABS.map((t) => [t.key, data[t.key] == null && !errors[t.key] && loading[t.key]]),
|
|
19552
20085
|
);
|
|
19553
20086
|
const anyFirstLoad = Object.values(firstLoad).some(Boolean);
|
|
19554
|
-
const statusDisconnected = runtime.connect !== null
|
|
19555
|
-
? activeGovernorDecision?.disconnected === true
|
|
19556
|
-
: data[tab.key] !== null && !runtimeIdentityCoordinator?.current();
|
|
19557
20087
|
let semanticStatus = refreshStatus({
|
|
19558
20088
|
widthMode: resizeRef.current.active,
|
|
19559
20089
|
remoteSetup,
|
|
@@ -19593,45 +20123,6 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
19593
20123
|
return () => clearTimeout(timer);
|
|
19594
20124
|
}, [anyFirstLoad, iconHintDue]);
|
|
19595
20125
|
|
|
19596
|
-
const items = data[tab.key];
|
|
19597
|
-
const displayError = formatTabErrorForWidth(tabError, failureContext, Math.max(1, cols - 5));
|
|
19598
|
-
const coordinationError = activeGovernorDecision?.coordinationError && !remoteSetup;
|
|
19599
|
-
const coordinationReason = activeFailure ?? activeGovernorDecision?.reason ?? "unavailable";
|
|
19600
|
-
const noticeReason = activeRecovery?.code ??
|
|
19601
|
-
(tabError?.kind === "fetch" ? tabError.verdict : null) ??
|
|
19602
|
-
(coordinationError || activeGovernorDecision?.mode === "paused" ? coordinationReason : null) ??
|
|
19603
|
-
(statusDisconnected ? runtime.connect === null ? "identity-unavailable"
|
|
19604
|
-
: activeGovernorDecision?.reason === "app-auth" ? "app-auth" : "disconnected" : null);
|
|
19605
|
-
// A continuous fault stays sustained when its explanation becomes more
|
|
19606
|
-
// specific. Only losing the fault (or switching tabs) restarts the debounce.
|
|
19607
|
-
const coordinationCondition = !remoteSetup && noticeReason ? tab.key : null;
|
|
19608
|
-
useEffect(() => {
|
|
19609
|
-
setVisibleCoordinationCondition(null);
|
|
19610
|
-
if (coordinationCondition === null) return;
|
|
19611
|
-
const timer = setTimeout(
|
|
19612
|
-
() => setVisibleCoordinationCondition(coordinationCondition),
|
|
19613
|
-
COORDINATION_NOTICE_AFTER_MS,
|
|
19614
|
-
);
|
|
19615
|
-
return () => clearTimeout(timer);
|
|
19616
|
-
}, [coordinationCondition]);
|
|
19617
|
-
const showCoordinationNotice = coordinationCondition !== null &&
|
|
19618
|
-
visibleCoordinationCondition === coordinationCondition;
|
|
19619
|
-
const noticeResource = activeResource;
|
|
19620
|
-
const noticeSourceAt = collectorSourceAgeRef.current[tab.key]
|
|
19621
|
-
? now.getTime() - collectorSourceAgeRef.current[tab.key].ageMs -
|
|
19622
|
-
Math.max(0, performance.now() - collectorSourceAgeRef.current[tab.key].measuredAt)
|
|
19623
|
-
: lastOkRef.current[tab.key] ?? null;
|
|
19624
|
-
const noticeCause = activeRecovery ?? (noticeReason ? recoveryCause({
|
|
19625
|
-
reason: noticeReason === "other" ? "network-outage" : noticeReason,
|
|
19626
|
-
resource: noticeResource, sourceAt: noticeSourceAt,
|
|
19627
|
-
retryAt: activeGovernorDecision?.retryAt ?? activeGovernorDecision?.resetMs ??
|
|
19628
|
-
activeGovernorDecision?.notBefore ?? null,
|
|
19629
|
-
origin: tabError ? "source" : "coordination", tab: tabError ? tab.key : null,
|
|
19630
|
-
}) : null);
|
|
19631
|
-
const noticeLines = showCoordinationNotice && noticeCause
|
|
19632
|
-
? presentRecovery(noticeCause, { nowMs: now.getTime(), cols })
|
|
19633
|
-
: [!remoteSetup && displayError ? displayError : ""];
|
|
19634
|
-
const noticeTone = showCoordinationNotice ? ATTENTION : displayError ? ERROR_TEXT : undefined;
|
|
19635
20126
|
const spin = SPINNER[frame % SPINNER.length];
|
|
19636
20127
|
|
|
19637
20128
|
const counts = Object.fromEntries(
|
|
@@ -19673,31 +20164,6 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
19673
20164
|
TABS.map((t) => [t.key, Boolean(errors[t.key]) && errors[t.key]?.verdict !== "no-remote"]),
|
|
19674
20165
|
);
|
|
19675
20166
|
|
|
19676
|
-
// Data can be arbitrarily old without anything on screen saying so: a failing
|
|
19677
|
-
// poll only surfaces on the tab you have selected, and after a laptop sleep
|
|
19678
|
-
// every pane is plausible and wrong. Threshold-gated and minute-granular on
|
|
19679
|
-
// purpose -- see STALE_AFTER_MS.
|
|
19680
|
-
// Read from the ref written on every successful poll, not from meta.at, which
|
|
19681
|
-
// only moves when the *payload* changes -- see lastOkRef. Reading a ref during
|
|
19682
|
-
// render lags by one render, which is harmless here because `now` advances on
|
|
19683
|
-
// its own and the label is minute-granular by design.
|
|
19684
|
-
const collectorAge = collectorSourceAgeRef.current[tab.key];
|
|
19685
|
-
const sourceAgeMs = collectorAge
|
|
19686
|
-
? collectorAge.ageMs + Math.max(0, performance.now() - collectorAge.measuredAt)
|
|
19687
|
-
: null;
|
|
19688
|
-
const lastOk = sourceAgeMs === null ? lastOkRef.current[tab.key] : now.getTime() - sourceAgeMs;
|
|
19689
|
-
const staleFor = sourceAgeMs ?? (lastOk == null ? null : now.getTime() - lastOk);
|
|
19690
|
-
const staleAt = freshnessDeadline({
|
|
19691
|
-
lastOk,
|
|
19692
|
-
refreshMs: runtime.refreshMs,
|
|
19693
|
-
grantedMs: admittedCadenceRef.current[tab.key]?.grantedMs,
|
|
19694
|
-
governorDecision: activeGovernorDecision,
|
|
19695
|
-
currentEpochs: governorEpochs,
|
|
19696
|
-
});
|
|
19697
|
-
const staleLabel =
|
|
19698
|
-
staleFor != null && staleAt != null && now.getTime() > staleAt
|
|
19699
|
-
? formatDuration(staleFor)
|
|
19700
|
-
: null;
|
|
19701
20167
|
if (staleLabel && (["watching", "shared"].includes(semanticStatus.kind) ||
|
|
19702
20168
|
activeFailure && semanticStatus.kind === "paused" &&
|
|
19703
20169
|
activeGovernorDecision?.cause)) {
|
|
@@ -19713,6 +20179,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
19713
20179
|
semanticStatus = refreshStatus({ onDemand: true });
|
|
19714
20180
|
}
|
|
19715
20181
|
|
|
20182
|
+
|
|
19716
20183
|
// A settled on-demand view has no data wake to advance its visible age.
|
|
19717
20184
|
// Tick only its display clock; source timestamps and acquisition stay idle.
|
|
19718
20185
|
const showCachedAge = semanticStatus.kind === "cached" && staleFor !== null;
|
|
@@ -19763,7 +20230,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
19763
20230
|
// Bottom-right of the frame, lazygit style: how much of the tab you can
|
|
19764
20231
|
// currently see out of how much there is.
|
|
19765
20232
|
const countLabel =
|
|
19766
|
-
|
|
20233
|
+
!tableVisible
|
|
19767
20234
|
? null
|
|
19768
20235
|
: tabOffset > 0
|
|
19769
20236
|
? `${tabOffset + 1}-${tabOffset + visibleItems.length} of ${counts[tab.key]}`
|
|
@@ -19777,22 +20244,9 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
19777
20244
|
// terminal is doing the clipping.
|
|
19778
20245
|
const frameCols = Math.max(1, cols - 1);
|
|
19779
20246
|
|
|
19780
|
-
//
|
|
19781
|
-
//
|
|
19782
|
-
|
|
19783
|
-
// several panes side by side plus `--repo` from anywhere -- so telling them
|
|
19784
|
-
// apart meant quitting and running --doctor. Only the explicit cases are shown:
|
|
19785
|
-
// resolving the *inferred* repo would cost a subprocess call at startup for a
|
|
19786
|
-
// label that, by definition, names the directory you are already sitting in.
|
|
19787
|
-
//
|
|
19788
|
-
// GH_REPO is read straight from the environment and never went through
|
|
19789
|
-
// parseRepoTarget, so unlike runtime.repo it is unvalidated -- safe() before it
|
|
19790
|
-
// is drawn, on the same rule as every other string this app does not own.
|
|
19791
|
-
// Dropped before the tab name rather than with it: PanelEdge drops its whole
|
|
19792
|
-
// label when it cannot seat one, so a single concatenated string would take the
|
|
19793
|
-
// tab name down with the target at ordinary widths.
|
|
19794
|
-
const target = runtime.repo ?? safe(process.env.GH_REPO ?? "");
|
|
19795
|
-
const withTarget = target ? `${tab.label} · ${target}` : tab.label;
|
|
20247
|
+
// The existing resolver also supplies inferred targets without another lookup.
|
|
20248
|
+
// Complete target and host remain reachable in help when this edge is short.
|
|
20249
|
+
const withTarget = `${tab.label} · ${selectedTarget.label}`;
|
|
19796
20250
|
const topLabel = frameCols - 3 - withTarget.length >= 0 ? withTarget : tab.label;
|
|
19797
20251
|
|
|
19798
20252
|
// Fixed columns deliberately do not shrink, so BRANCH and TIME stay readable
|
|
@@ -19822,8 +20276,8 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
19822
20276
|
// avoid, and it was reachable simply by dragging a sidebar narrow. The guard
|
|
19823
20277
|
// sits after usableSize(), which substitutes DEFAULT_COLS for a 0 reported
|
|
19824
20278
|
// mid-resize, so a transient zero cannot be mistaken for a genuinely tiny pane.
|
|
19825
|
-
const tooNarrow = frameCols < MIN_COMPACT_WIDTH;
|
|
19826
|
-
const fullHeaderVisible = !compact && !showHelp && !tooNarrow && !remoteSetup;
|
|
20279
|
+
const tooNarrow = tableVisible && frameCols < MIN_COMPACT_WIDTH;
|
|
20280
|
+
const fullHeaderVisible = tableVisible && !compact && !showHelp && !tooNarrow && !remoteSetup;
|
|
19827
20281
|
const selectedWidthKey = selectedWidthKeyByTab[tab.key] ?? null;
|
|
19828
20282
|
const selectedWidthColumn = effectiveHeader?.find(
|
|
19829
20283
|
(column) => column.key === selectedWidthKey,
|
|
@@ -19913,23 +20367,22 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
19913
20367
|
// recovers immediately -- this is a render-time branch and touches no state.
|
|
19914
20368
|
// Short enough to survive at the widths it actually appears at -- a message
|
|
19915
20369
|
// about the pane being too narrow that is itself truncated would be a joke.
|
|
19916
|
-
//
|
|
19917
|
-
//
|
|
19918
|
-
// truncate-end so it degrades rather than wraps in a narrow pane. Every
|
|
19919
|
-
// glyph in KEY_TABLE is plain ASCII, which is what keeps it safe here.
|
|
20370
|
+
// Help is cell-wrapped and paginated within bodyRows, with navigation
|
|
20371
|
+
// and quit reserved on every page. Rendering never adds another wrap.
|
|
19920
20372
|
showHelp &&
|
|
19921
|
-
|
|
20373
|
+
(helpPages[visibleHelpPage] ?? [])
|
|
19922
20374
|
.map((line, i) => e(Text, { key: `help${i}`, wrap: "truncate-end" }, line)),
|
|
19923
20375
|
!showHelp &&
|
|
19924
20376
|
tooNarrow &&
|
|
19925
20377
|
e(Text, { dimColor: true, wrap: "truncate-end" }, "too narrow"),
|
|
19926
|
-
...noticeLines.map((line, index) =>
|
|
20378
|
+
...(showHelp || !tableVisible ? [] : noticeLines).map((line, index) =>
|
|
19927
20379
|
e(Text, { key: `recovery:${index}`, color: noticeTone, wrap: "truncate-end" }, line)),
|
|
19928
20380
|
!showHelp &&
|
|
19929
20381
|
!tooNarrow &&
|
|
19930
20382
|
!remoteSetup &&
|
|
20383
|
+
tableVisible &&
|
|
19931
20384
|
tab.key === "security" &&
|
|
19932
|
-
|
|
20385
|
+
visibleSecurityLines.map((note, i) =>
|
|
19933
20386
|
e(Text, { key: i, dimColor: true, wrap: "truncate-end" }, note),
|
|
19934
20387
|
),
|
|
19935
20388
|
!showHelp &&
|
|
@@ -19952,6 +20405,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
19952
20405
|
!showHelp &&
|
|
19953
20406
|
!tooNarrow &&
|
|
19954
20407
|
!remoteSetup &&
|
|
20408
|
+
tableVisible &&
|
|
19955
20409
|
e(MemoHeaderCells, {
|
|
19956
20410
|
cells: header,
|
|
19957
20411
|
selectedWidthKey: resizeRef.current.active ? selectedWidthKey : null,
|
|
@@ -19978,49 +20432,23 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
19978
20432
|
}),
|
|
19979
20433
|
);
|
|
19980
20434
|
}),
|
|
19981
|
-
|
|
19982
|
-
|
|
19983
|
-
|
|
19984
|
-
|
|
19985
|
-
|
|
19986
|
-
// an error and has never resolved: the error line directly above already
|
|
19987
|
-
// says what happened, and "no runs" underneath it reads as a fact about the
|
|
19988
|
-
// repository rather than the absence of an answer.
|
|
19989
|
-
!showHelp &&
|
|
19990
|
-
!tooNarrow &&
|
|
19991
|
-
!remoteSetup &&
|
|
19992
|
-
visibleItems.length === 0 &&
|
|
19993
|
-
!(tabError && items == null) &&
|
|
19994
|
-
e(
|
|
19995
|
-
Text,
|
|
19996
|
-
{ dimColor: true },
|
|
19997
|
-
firstLoad[tab.key]
|
|
19998
|
-
? `${showSpinner ? spin : SPINNER[0]} loading ${tab.label.toLowerCase()}…` +
|
|
19999
|
-
// Appended only once a first fetch has been running for
|
|
20000
|
-
// ICON_HINT_AFTER_MS, so an ordinary start never shows it: the
|
|
20001
|
-
// remedy is for someone looking at a pane of blank boxes, and by
|
|
20002
|
-
// then they have been looking long enough to want an explanation.
|
|
20003
|
-
// Suppressed once icons are already ASCII, since then there is
|
|
20004
|
-
// nothing to fix.
|
|
20005
|
-
(USING_NERD_ICONS && iconHintDue ? " (icons blank? GH_GLANCE_ICONS=unicode)" : "")
|
|
20006
|
-
: semanticStatus.kind === "paused" || waiting[tab.key]
|
|
20007
|
-
? "waiting for API budget…"
|
|
20008
|
-
: tab.key === "security" && securityNotes.length === ALERT_SOURCES.length
|
|
20009
|
-
? "no alert sources available"
|
|
20010
|
-
: `no ${tab.countLabel}`,
|
|
20011
|
-
),
|
|
20435
|
+
!showHelp && !remoteSetup && !tableVisible &&
|
|
20436
|
+
panelLines.map((line, index) => e(Text, {
|
|
20437
|
+
key: `context:${index}`, wrap: "truncate-end",
|
|
20438
|
+
bold: index === 0, color: index === 0 ? TITLE_COLOR : undefined,
|
|
20439
|
+
}, line)),
|
|
20012
20440
|
// Pushes the panel's bottom edge down to the foot of the pane, so the
|
|
20013
20441
|
// frame stays put instead of closing up under the column headers on a
|
|
20014
20442
|
// tab that only has a handful of rows.
|
|
20015
20443
|
e(Box, { flexGrow: 1 }),
|
|
20016
20444
|
),
|
|
20017
|
-
e(PanelEdge, { width: frameCols, top: false, label: countLabel, labelColor: BORDER_COLOR }),
|
|
20445
|
+
e(PanelEdge, { width: frameCols, top: false, label: showHelp ? null : countLabel, labelColor: BORDER_COLOR }),
|
|
20018
20446
|
e(StatusBar, {
|
|
20019
20447
|
status: semanticStatus,
|
|
20020
20448
|
detail: activeGovernorDecision,
|
|
20021
20449
|
spin: showSpinner ? spin : null,
|
|
20022
|
-
stale: semanticStatus.kind === "cached" && staleFor
|
|
20023
|
-
? formatDuration(
|
|
20450
|
+
stale: semanticStatus.kind === "cached" && Number.isFinite(staleFor) && staleFor >= 0
|
|
20451
|
+
? formatDuration(staleFor) : staleLabel,
|
|
20024
20452
|
nowMs: now.getTime(),
|
|
20025
20453
|
interactive,
|
|
20026
20454
|
cols: frameCols,
|
|
@@ -20465,6 +20893,12 @@ export {
|
|
|
20465
20893
|
ghApi,
|
|
20466
20894
|
actionsRunsArgs,
|
|
20467
20895
|
actionsWorkflowsArgs,
|
|
20896
|
+
actionsSetupArgs,
|
|
20897
|
+
parseActionsSetup,
|
|
20898
|
+
actionsSetupFresh,
|
|
20899
|
+
actionsSetupRetryAt,
|
|
20900
|
+
actionsPageUrl,
|
|
20901
|
+
openBrowserUrl,
|
|
20468
20902
|
parseActionsBodies,
|
|
20469
20903
|
parseActionsRuns,
|
|
20470
20904
|
parseWorkflowCatalog,
|
|
@@ -20633,6 +21067,12 @@ export {
|
|
|
20633
21067
|
collectorSourceAdvanced,
|
|
20634
21068
|
cleanupQueueForResource,
|
|
20635
21069
|
presentRecovery,
|
|
21070
|
+
dashboardPresentation,
|
|
21071
|
+
contextualPanelLayout,
|
|
21072
|
+
dashboardTarget,
|
|
21073
|
+
contextHelpPages,
|
|
21074
|
+
clampHelpPage,
|
|
21075
|
+
wrapPanelText,
|
|
20636
21076
|
recordRecoveryTransition,
|
|
20637
21077
|
safeRecordRecoveryTransition,
|
|
20638
21078
|
readRecoveryTransitions,
|
package/package.json
CHANGED