gh-glance 0.16.1 → 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.
Files changed (4) hide show
  1. package/CHANGELOG.md +53 -1
  2. package/README.md +64 -12
  3. package/index.mjs +617 -177
  4. package/package.json +8 -4
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
- ["?", "Show the keys (any key closes it)"],
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 = Number.isFinite(cause.sourceAt) && cause.sourceAt <= nowMs
15768
- ? `cached ${formatDuration(nowMs - cause.sourceAt)}` : "cached age unavailable";
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 both r and q at the 24-column
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
- // Counts the lines actually rendered, not the notes collected -- getting this
17078
- // wrong by one row is what makes ink repaint the whole frame.
17079
- const extraLines =
17080
- (activeRecovery || tabError || activeGovernorDecision?.coordinationError ||
17081
- activeGovernorDecision?.mode === "paused" || activeGovernorDecision?.disconnected ||
17082
- data[tab.key] !== null && !runtimeIdentityCoordinator?.current()
17083
- ? 3 : NOTICE_ROWS) +
17084
- (tab.key === "security" && !remoteSetup ? securityLines.length : 0);
17085
- // Reserve lines for: the tab bar and the divider under it (2), the panel's
17086
- // top and bottom edges (2), the column header and its separator (2), and
17087
- // the status line (1), plus a 1-line safety margin. Slack is absorbed by
17088
- // the spacer in the tree below.
17089
- const bodyRows = Math.max(1, rows - 8 - extraLines);
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
- // Any key dismisses -- except quit, handled above, which must never be
17484
- // swallowed by a modal in a full-screen app. Deliberately does not fall
17485
- // through to the binding the key would normally trigger: closing the
17486
- // overlay is the whole intent of that press.
17487
- setShowHelp(false);
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 hardFailure = ["corrupt", "unwritable", "capacity", "accounting-overflow"].includes(candidate.code);
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
- if (cleanupScope && registeredScopeHash) releaseLease(cleanupScope, leaseId);
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
- items == null
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
- // Name the repository in the top edge when it was chosen explicitly, because
19781
- // then it is not the one you would guess from the working directory. Nothing
19782
- // on screen said which repo a pane was watching, and the documented workflow is
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
- // Clamped to bodyRows so it can never push the frame past `rows` and
19917
- // trigger the full repaint the height reservation exists to prevent, and
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
- helpLines(bodyRows)
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
- securityLines.map((note, i) =>
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
- // Distinguishes "still fetching" from "resolved and empty" in the body,
19982
- // which is the difference between a dashboard that looks hung and one
19983
- // that looks correct. Driven by "never resolved" rather than by the
19984
- // loading flag, which also covers a manual refresh and made a
19985
- // settled-empty tab swap its message. Suppressed entirely when a tab has
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 != null
20023
- ? formatDuration(Math.max(0, staleFor)) : staleLabel,
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,