gh-glance 0.3.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 +200 -0
- package/LICENSE +21 -0
- package/README.md +277 -0
- package/index.mjs +2071 -0
- package/package.json +57 -0
package/index.mjs
ADDED
|
@@ -0,0 +1,2071 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// gh-glance -- a live-refreshing GitHub dashboard sized for a narrow terminal
|
|
3
|
+
// pane: Actions, Issues, Pull Requests, and Security (Dependabot/
|
|
4
|
+
// code-scanning/secret-scanning alerts). All data comes from `gh` (issue/pr/
|
|
5
|
+
// run list + `gh api` for security alerts) -- no direct GitHub API calls of
|
|
6
|
+
// our own. Renders with ink so redraws are diffed in place, no full-screen
|
|
7
|
+
// clear/flash on refresh.
|
|
8
|
+
|
|
9
|
+
// React's development build instruments every render with performance.measure()
|
|
10
|
+
// calls for its DevTools timeline. In a browser those entries are dropped once
|
|
11
|
+
// the timeline buffer fills; in Node the user-timing buffer is never trimmed,
|
|
12
|
+
// so each render leaves a PerformanceMeasure behind permanently. A dashboard
|
|
13
|
+
// that redraws for hours accumulates them until the heap is gone -- measured at
|
|
14
|
+
// ~14 entries/s, which is a fatal "JavaScript heap out of memory" after a few
|
|
15
|
+
// hours of uptime. Selecting React's production build turns the instrumentation
|
|
16
|
+
// off at the source (verified: 0 entries, flat heap).
|
|
17
|
+
//
|
|
18
|
+
// react and ink pick their build by reading NODE_ENV at import time, so the flag
|
|
19
|
+
// has to be set before either is loaded. This assignment must stay the first
|
|
20
|
+
// statement in the file. NODE_ENV is only defaulted, not overwritten, so
|
|
21
|
+
// `NODE_ENV=development gh-glance` still gets React's warnings -- see
|
|
22
|
+
// disarmDevBuildLeak() for how that path is kept survivable rather than fatal.
|
|
23
|
+
process.env.NODE_ENV ??= "production";
|
|
24
|
+
|
|
25
|
+
import { execFile } from "node:child_process";
|
|
26
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
27
|
+
import { fileURLToPath } from "node:url";
|
|
28
|
+
import { promisify } from "node:util";
|
|
29
|
+
|
|
30
|
+
const execFileAsync = promisify(execFile);
|
|
31
|
+
|
|
32
|
+
// Running as the CLI vs. being imported by a test. Everything with a side
|
|
33
|
+
// effect -- argv handling, the TTY guard, the preflight, entering the alternate
|
|
34
|
+
// screen, render() -- hangs off this, so `import("./index.mjs")` is inert and
|
|
35
|
+
// the pure helpers below are unit-testable. Compared through realpath because
|
|
36
|
+
// `npm link` puts a symlink on PATH, so argv[1] and this module's own URL are
|
|
37
|
+
// different strings for the same file.
|
|
38
|
+
function detectMainModule() {
|
|
39
|
+
try {
|
|
40
|
+
const self = realpathSync(fileURLToPath(import.meta.url));
|
|
41
|
+
return Boolean(process.argv[1]) && realpathSync(process.argv[1]) === self;
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const IS_MAIN = detectMainModule();
|
|
47
|
+
|
|
48
|
+
const REFRESH_MS = 5000;
|
|
49
|
+
|
|
50
|
+
// `gh run list` costs roughly linearly in --limit (measured: ~1.2s at 20 runs,
|
|
51
|
+
// ~3.0s at 100, ~4.9s at 150), so asking for a fixed 150 to render ~35 visible
|
|
52
|
+
// rows was paying several seconds per refresh for rows nobody sees. Actions is
|
|
53
|
+
// a scrolling log whose count is arbitrary anyway, so it fetches only what the
|
|
54
|
+
// pane can show -- one extra row tells us whether to render the count as "n+".
|
|
55
|
+
const MIN_RUN_LIMIT = 20;
|
|
56
|
+
|
|
57
|
+
// Ceiling on the pane-height-derived run limit. Without it a very tall terminal
|
|
58
|
+
// asks for enough runs that the fetch outlasts REFRESH_MS, at which point ticks
|
|
59
|
+
// are permanently absorbed by the in-flight guard and the effective refresh rate
|
|
60
|
+
// silently becomes whatever `gh` can sustain.
|
|
61
|
+
const MAX_RUN_LIMIT = 60;
|
|
62
|
+
|
|
63
|
+
// Issues and PRs are sets rather than logs: the count *is* the signal, so these
|
|
64
|
+
// stay generous. They are also far cheaper, being bounded by what's open.
|
|
65
|
+
const LIST_LIMIT = 150;
|
|
66
|
+
|
|
67
|
+
// Alert endpoints are filtered server-side to open items and capped at one
|
|
68
|
+
// page. The previous --paginate walked the repo's entire alert history --
|
|
69
|
+
// mostly closed alerts -- and then discarded them client-side.
|
|
70
|
+
const ALERT_PER_PAGE = 100;
|
|
71
|
+
const ALERT_QUERY = `?state=open&per_page=${ALERT_PER_PAGE}`;
|
|
72
|
+
|
|
73
|
+
// Inactive tabs only feed the tab-bar counts, so they refresh every Nth tick
|
|
74
|
+
// rather than every tick. Raised from 4 to 12 (a 60s cycle at the default
|
|
75
|
+
// refresh) because the measured steady-state cost was 1,980-2,520 REST calls
|
|
76
|
+
// per hour against a 5,000/hr limit -- 40-50% of the user's entire budget for a
|
|
77
|
+
// single pane. Only the tab you are looking at needs REFRESH_MS latency; the
|
|
78
|
+
// other three exist to keep counts honest, which tolerates a minute.
|
|
79
|
+
const BACKGROUND_EVERY = 12;
|
|
80
|
+
|
|
81
|
+
// A stalled `gh` used to wedge a tab permanently: the promise never settled, so
|
|
82
|
+
// the in-flight guard was never cleared and that tab stopped fetching for the
|
|
83
|
+
// life of the process while the spinner kept insisting it was working. The
|
|
84
|
+
// timeout has to clear the slowest *legitimate* fetch by a wide margin -- the
|
|
85
|
+
// MIN_RUN_LIMIT note above measures ~4.9s at 150 runs -- so it sits far above
|
|
86
|
+
// REFRESH_MS rather than near it. SIGKILL because `gh` mid-TLS-handshake can
|
|
87
|
+
// ignore SIGTERM.
|
|
88
|
+
const GH_TIMEOUT_MS = 30_000;
|
|
89
|
+
|
|
90
|
+
// Node's execFile defaults to a 1 MiB stdout buffer. A repo with ~100 open
|
|
91
|
+
// Dependabot alerts returns ~800 KB of advisory JSON (measured 7.3 KB/advisory)
|
|
92
|
+
// and was within reach of the cliff -- where the rejection was swallowed into a
|
|
93
|
+
// note and the tab rendered "Security (0)", i.e. the repos with the most to
|
|
94
|
+
// report looked the cleanest. Paired with the --jq projection below, which cuts
|
|
95
|
+
// the payload ~48x, this is headroom rather than an allocation.
|
|
96
|
+
const GH_MAX_BUFFER = 16 * 1024 * 1024;
|
|
97
|
+
|
|
98
|
+
// Endpoints that answer "this feature is not enabled here" do so with a 403/404
|
|
99
|
+
// on every single refresh. Re-asking forever costs a process spawn and a
|
|
100
|
+
// rate-limit unit each time (measured up to 11,520 wasted calls per 8h session),
|
|
101
|
+
// so a negative result backs off -- but never permanently, because Advanced
|
|
102
|
+
// Security can be switched on mid-session and a latched "not enabled" would be
|
|
103
|
+
// a lie about a security surface.
|
|
104
|
+
const BACKOFF_STEPS_MS = [60_000, 300_000, 1_800_000, 3_600_000];
|
|
105
|
+
|
|
106
|
+
// Past this, the active tab's data is old enough to say so. Deliberately
|
|
107
|
+
// coarse: `now` only advances on minute boundaries when nothing is in progress,
|
|
108
|
+
// so a minute-granular staleness label costs zero extra redraws, whereas a
|
|
109
|
+
// live "updated Ns ago" would make every frame differ and undo the
|
|
110
|
+
// byte-identical-idle-frame property the rest of this file works to keep.
|
|
111
|
+
const STALE_AFTER_MS = 30_000;
|
|
112
|
+
|
|
113
|
+
// Mutable because the fetchers below are defined before argv is parsed, and the
|
|
114
|
+
// argv block is what fills this in. Everything here has a working default, so
|
|
115
|
+
// the zero-argument invocation the README documents behaves exactly as before.
|
|
116
|
+
const runtime = {
|
|
117
|
+
repo: null, // null means "let gh infer it from the git remote", as today
|
|
118
|
+
refreshMs: REFRESH_MS,
|
|
119
|
+
verbose: false,
|
|
120
|
+
initialTabIndex: 0,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// The four tab keys, needed by --tab validation which runs long before the TABS
|
|
124
|
+
// table itself is built.
|
|
125
|
+
const TAB_KEYS = ["actions", "issues", "prs", "security"];
|
|
126
|
+
|
|
127
|
+
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
128
|
+
const SPINNER_MS = 100;
|
|
129
|
+
|
|
130
|
+
// Motion opt-out. This pane is designed to sit in peripheral vision for hours,
|
|
131
|
+
// which is exactly where repetitive motion is most costly for users with
|
|
132
|
+
// vestibular sensitivity -- and a 20-minute workflow run means 20 minutes of
|
|
133
|
+
// 10fps animation with no way to stop it.
|
|
134
|
+
const ANIMATE = !process.env.GH_GLANCE_NO_ANIMATION;
|
|
135
|
+
|
|
136
|
+
// ---------- Untrusted input ----------
|
|
137
|
+
|
|
138
|
+
// Everything `gh` returns is chosen by strangers: on a public repo anyone can
|
|
139
|
+
// open an issue or a fork PR and pick the title, and a commit subject (which is
|
|
140
|
+
// what `displayTitle` is for push runs) has no byte restrictions at all.
|
|
141
|
+
//
|
|
142
|
+
// ink strips CSI sequences but -- verified against ink 7.1.1 -- deliberately
|
|
143
|
+
// preserves OSC sequences, SGR, and bare C0 controls so callers can pass
|
|
144
|
+
// chalk-styled strings through. So OSC 8 hyperlinks survive (an attacker-chosen
|
|
145
|
+
// clickable URL with no visual tell), CR survives (rewinds the cursor and
|
|
146
|
+
// overwrites the row above, which can forge or blank a neighbouring row --
|
|
147
|
+
// including making a critical alert read as clean), BEL survives (rings once
|
|
148
|
+
// per redraw, every 5s), and LF survives (one hostile title inflates to N lines
|
|
149
|
+
// and evicts other rows out of a height-clamped frame entirely).
|
|
150
|
+
//
|
|
151
|
+
// So sanitize here, at the data boundary, inside the parse() closures -- never
|
|
152
|
+
// on the composed output stream, which would strip ink's own SGR codes, the
|
|
153
|
+
// box-drawing in PanelEdge, and the alternate-screen escapes, i.e. the whole UI.
|
|
154
|
+
//
|
|
155
|
+
// Strip C0, DEL and C1 only. Emoji (including ZWJ sequences and variation
|
|
156
|
+
// selectors), CJK and other wide characters, combining marks and RTL text must
|
|
157
|
+
// survive untouched -- ink's width arithmetic depends on measuring them
|
|
158
|
+
// correctly, and the app's own Nerd Font glyphs live in the private use area, so
|
|
159
|
+
// anything shaped like "strip non-ASCII" would erase every status icon on
|
|
160
|
+
// screen. Control runs collapse to a single space rather than being deleted, so
|
|
161
|
+
// "a\nb" reads as "a b" rather than "ab".
|
|
162
|
+
// eslint-disable-next-line no-control-regex -- matching control characters is the entire purpose
|
|
163
|
+
const CONTROL_CHARS = /[--]+/g;
|
|
164
|
+
|
|
165
|
+
// Titles are rendered at whatever length GitHub returns, and ink memoizes
|
|
166
|
+
// wrapped text in a module-level cache it never evicts -- so unbounded remote
|
|
167
|
+
// strings are unbounded retention in a process meant to run for hours. Clamp
|
|
168
|
+
// far above any realistic column width so ink's own ellipsis stays what the user
|
|
169
|
+
// actually sees, and clamp by codepoint: slicing mid-surrogate produces a string
|
|
170
|
+
// whose measured width disagrees with what the terminal draws, which
|
|
171
|
+
// desynchronizes every column to its right.
|
|
172
|
+
const MAX_FIELD_LENGTH = 300;
|
|
173
|
+
|
|
174
|
+
function safe(value) {
|
|
175
|
+
if (typeof value !== "string") return value == null ? "" : String(value);
|
|
176
|
+
const cleaned = value.replace(CONTROL_CHARS, " ").trim();
|
|
177
|
+
const points = Array.from(cleaned);
|
|
178
|
+
return points.length > MAX_FIELD_LENGTH ? points.slice(0, MAX_FIELD_LENGTH).join("") : cleaned;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---------- Formatting ----------
|
|
182
|
+
|
|
183
|
+
// The `ms < 0` guard does not catch NaN (every NaN comparison is false), and
|
|
184
|
+
// every caller builds a Date from an unvalidated API field -- so a null, absent
|
|
185
|
+
// or Go-zero timestamp used to render "NaNd ago" or "InfinityhNaNm" straight
|
|
186
|
+
// into the table. A dash reads as absent data and fits the fixed 7- and
|
|
187
|
+
// 8-column cells; dropping the row instead would make the count disagree with
|
|
188
|
+
// the body, which is worse.
|
|
189
|
+
const NO_VALUE = "-";
|
|
190
|
+
|
|
191
|
+
function formatDuration(ms) {
|
|
192
|
+
if (!Number.isFinite(ms)) return NO_VALUE;
|
|
193
|
+
if (ms < 0) ms = 0;
|
|
194
|
+
const totalSeconds = Math.floor(ms / 1000);
|
|
195
|
+
const h = Math.floor(totalSeconds / 3600);
|
|
196
|
+
const m = Math.floor((totalSeconds % 3600) / 60);
|
|
197
|
+
const s = totalSeconds % 60;
|
|
198
|
+
if (h > 0) return `${h}h${m}m`;
|
|
199
|
+
if (m > 0) return `${m}m${s}s`;
|
|
200
|
+
return `${s}s`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function formatAge(date, now) {
|
|
204
|
+
const ms = now - date;
|
|
205
|
+
if (!Number.isFinite(ms)) return NO_VALUE;
|
|
206
|
+
const minutes = Math.floor(ms / 60000);
|
|
207
|
+
if (minutes < 1) return "just now";
|
|
208
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
209
|
+
const hours = Math.floor(minutes / 60);
|
|
210
|
+
if (hours < 24) return `${hours}h ago`;
|
|
211
|
+
const days = Math.floor(hours / 24);
|
|
212
|
+
return `${days}d ago`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// `shortErr` used to read err.shortMessage first -- an execa property that
|
|
216
|
+
// node:child_process never sets, so the branch was dead and every error fell
|
|
217
|
+
// through to err.message, which is the whole reconstructed command line
|
|
218
|
+
// (~150 characters of --json field names for the Actions fetch) followed by the
|
|
219
|
+
// part the user actually needs. Rendered against a layout that reserves exactly
|
|
220
|
+
// one row for it, that overflowed the frame and made ink full-clear and repaint
|
|
221
|
+
// every frame -- the flicker this file's header claims the design avoids.
|
|
222
|
+
//
|
|
223
|
+
// stderr carries the useful line, so prefer it. Keep the err.message fallback:
|
|
224
|
+
// a missing binary rejects with code ENOENT, an empty stderr and only
|
|
225
|
+
// "spawn gh ENOENT" as the message. Newlines are collapsed rather than kept,
|
|
226
|
+
// because ink honours an embedded \n inside a single <Text> and one row is all
|
|
227
|
+
// the layout has budgeted.
|
|
228
|
+
const MAX_ERR_LENGTH = 120;
|
|
229
|
+
|
|
230
|
+
function shortErr(err) {
|
|
231
|
+
if (err?.killed && err?.signal) return `gh timed out after ${GH_TIMEOUT_MS / 1000}s`;
|
|
232
|
+
const raw = (typeof err?.stderr === "string" && err.stderr.trim()) || err?.message || String(err);
|
|
233
|
+
const withoutPreamble = String(raw).replace(/^Command failed:[^\n]*\n?/, "");
|
|
234
|
+
const collapsed = withoutPreamble.replace(CONTROL_CHARS, " ").replace(/\s+/g, " ").trim();
|
|
235
|
+
const text = collapsed || String(raw).trim() || "unknown error";
|
|
236
|
+
return text.length > MAX_ERR_LENGTH ? `${text.slice(0, MAX_ERR_LENGTH - 1)}…` : text;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// `gh api` puts the HTTP status in stderr and exits 1, so err.code is the
|
|
240
|
+
// process exit code and says nothing about the cause. 403/404 is the honest
|
|
241
|
+
// "you can't see this here" -- everything else (auth expiry, rate limiting,
|
|
242
|
+
// DNS, a 502) is a real failure that used to be reported as a confident,
|
|
243
|
+
// plausible, and wrong claim that the feature was switched off.
|
|
244
|
+
function isUnavailable(err) {
|
|
245
|
+
return /HTTP (403|404)/.test(String(err?.stderr ?? err?.message ?? ""));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function isRateLimited(err) {
|
|
249
|
+
return /rate limit|secondary rate|API rate limit/i.test(String(err?.stderr ?? err?.message ?? ""));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Some pty wrappers (and a terminal mid-resize) report a size of 0 or
|
|
253
|
+
// undefined. Taking that literally collapses the table to a single row (or
|
|
254
|
+
// draws a zero-width border), so fall back to sane defaults until real
|
|
255
|
+
// dimensions arrive.
|
|
256
|
+
const DEFAULT_ROWS = 30;
|
|
257
|
+
const DEFAULT_COLS = 80;
|
|
258
|
+
function usableSize(value, fallback) {
|
|
259
|
+
return typeof value === "number" && value > 0 ? value : fallback;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ---------- gh subprocess boundary ----------
|
|
263
|
+
|
|
264
|
+
// One seam for every `gh` call. Previously six call sites each passed nothing
|
|
265
|
+
// but argv, which meant six places to add a timeout, six to add a buffer size,
|
|
266
|
+
// six to neutralize the environment -- and it is why the three alert fetchers
|
|
267
|
+
// silently drifted into handling errors three different ways.
|
|
268
|
+
//
|
|
269
|
+
// The environment is *overridden*, never replaced: `gh` needs GH_TOKEN,
|
|
270
|
+
// GH_HOST (GitHub Enterprise), GH_CONFIG_DIR, HOME and the proxy variables to
|
|
271
|
+
// work at all, and GH_REPO is the one documented way to point this tool at
|
|
272
|
+
// another repository. GH_FORCE_TTY is the interesting one -- people export it
|
|
273
|
+
// in shell profiles to get rich `gh` output through pipes, and it makes
|
|
274
|
+
// `gh --json` emit ANSI-coloured pretty-printed JSON that JSON.parse rejects
|
|
275
|
+
// outright (verified), which broke all four tabs at once with an error pointing
|
|
276
|
+
// at JSON rather than at their environment.
|
|
277
|
+
const GH_ENV_OVERRIDES = {
|
|
278
|
+
GH_FORCE_TTY: "",
|
|
279
|
+
NO_COLOR: "1",
|
|
280
|
+
CLICOLOR_FORCE: "0",
|
|
281
|
+
GH_PAGER: "cat",
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
// Verbose output goes to stderr and never to stdout: stdout is ink's frame
|
|
285
|
+
// stream, and writing anything else into it corrupts the diff and the
|
|
286
|
+
// alternate-screen state. The argv block refuses --verbose while stderr is still
|
|
287
|
+
// a terminal, so these lines always land in a file rather than on top of the
|
|
288
|
+
// dashboard.
|
|
289
|
+
function logGh(args, startedAt, outcome) {
|
|
290
|
+
if (!runtime.verbose) return;
|
|
291
|
+
const ms = Date.now() - startedAt;
|
|
292
|
+
process.stderr.write(
|
|
293
|
+
`${new Date().toISOString()} gh ${args.join(" ")} -- ${outcome} in ${ms}ms\n`,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function runGh(args, { signal } = {}) {
|
|
298
|
+
const startedAt = Date.now();
|
|
299
|
+
try {
|
|
300
|
+
const { stdout } = await execFileAsync("gh", args, {
|
|
301
|
+
timeout: GH_TIMEOUT_MS,
|
|
302
|
+
killSignal: "SIGKILL",
|
|
303
|
+
maxBuffer: GH_MAX_BUFFER,
|
|
304
|
+
env: { ...process.env, ...GH_ENV_OVERRIDES },
|
|
305
|
+
signal,
|
|
306
|
+
});
|
|
307
|
+
logGh(args, startedAt, `ok ${stdout.length}B`);
|
|
308
|
+
return stdout;
|
|
309
|
+
} catch (err) {
|
|
310
|
+
logGh(args, startedAt, `FAILED ${shortErr(err)}`);
|
|
311
|
+
throw err;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// `--repo` for the list subcommands. Empty when unset, so the argv vector stays
|
|
316
|
+
// byte-identical to what shipped before.
|
|
317
|
+
function repoArgs() {
|
|
318
|
+
return runtime.repo ? ["--repo", runtime.repo] : [];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// `gh api` has no --repo; it resolves the {owner}/{repo} placeholder from the
|
|
322
|
+
// working directory. Substituting the validated value is what makes --repo work
|
|
323
|
+
// for the alert endpoints. REPO_PATTERN is why this is safe to interpolate into
|
|
324
|
+
// a request path.
|
|
325
|
+
function apiPath(path) {
|
|
326
|
+
return runtime.repo ? path.replace("{owner}/{repo}", runtime.repo) : path;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ---------- Data fetchers ----------
|
|
330
|
+
|
|
331
|
+
async function fetchActions(limit, signal) {
|
|
332
|
+
const raw = await runGh(
|
|
333
|
+
[
|
|
334
|
+
"run",
|
|
335
|
+
"list",
|
|
336
|
+
...repoArgs(),
|
|
337
|
+
"--limit",
|
|
338
|
+
String(limit),
|
|
339
|
+
"--json",
|
|
340
|
+
"databaseId,displayTitle,workflowName,number,headBranch,status,conclusion,startedAt,updatedAt",
|
|
341
|
+
],
|
|
342
|
+
{ signal },
|
|
343
|
+
);
|
|
344
|
+
return {
|
|
345
|
+
raw,
|
|
346
|
+
limit,
|
|
347
|
+
parse: () =>
|
|
348
|
+
JSON.parse(raw).map((r) => ({
|
|
349
|
+
databaseId: r.databaseId,
|
|
350
|
+
displayTitle: safe(r.displayTitle),
|
|
351
|
+
workflowName: safe(r.workflowName),
|
|
352
|
+
number: r.number,
|
|
353
|
+
headBranch: safe(r.headBranch),
|
|
354
|
+
status: r.status,
|
|
355
|
+
conclusion: r.conclusion,
|
|
356
|
+
startedAt: r.startedAt,
|
|
357
|
+
updatedAt: r.updatedAt,
|
|
358
|
+
})),
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Both list endpoints return created-descending by default, while the row
|
|
363
|
+
// renders updatedAt under a column headed AGE -- so the visible column was
|
|
364
|
+
// non-monotonic (verified against cli/cli: a PR updated yesterday sat below one
|
|
365
|
+
// updated three days earlier) and, worse, truncating to pane height dropped the
|
|
366
|
+
// oldest-*created* rows. A PR opened six months ago and reviewed five minutes
|
|
367
|
+
// ago was invisible. Sorting server-side fixes the ordering and the truncation
|
|
368
|
+
// criterion together; verified that --search composes with --state open and
|
|
369
|
+
// returns the same result count.
|
|
370
|
+
const SORT_RECENT = ["--search", "sort:updated-desc"];
|
|
371
|
+
|
|
372
|
+
async function fetchIssues(signal) {
|
|
373
|
+
const raw = await runGh(
|
|
374
|
+
[
|
|
375
|
+
"issue",
|
|
376
|
+
"list",
|
|
377
|
+
...repoArgs(),
|
|
378
|
+
"--state",
|
|
379
|
+
"open",
|
|
380
|
+
"--limit",
|
|
381
|
+
String(LIST_LIMIT),
|
|
382
|
+
...SORT_RECENT,
|
|
383
|
+
"--json",
|
|
384
|
+
"number,title,author,labels,createdAt,updatedAt",
|
|
385
|
+
],
|
|
386
|
+
{ signal },
|
|
387
|
+
);
|
|
388
|
+
return {
|
|
389
|
+
raw,
|
|
390
|
+
limit: LIST_LIMIT,
|
|
391
|
+
parse: () =>
|
|
392
|
+
JSON.parse(raw).map((i) => ({
|
|
393
|
+
number: i.number,
|
|
394
|
+
title: safe(i.title),
|
|
395
|
+
author: safe(i.author?.login ?? ""),
|
|
396
|
+
label: safe(i.labels?.[0]?.name ?? ""),
|
|
397
|
+
updatedAt: i.updatedAt,
|
|
398
|
+
})),
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function fetchPRs(signal) {
|
|
403
|
+
const raw = await runGh(
|
|
404
|
+
[
|
|
405
|
+
"pr",
|
|
406
|
+
"list",
|
|
407
|
+
...repoArgs(),
|
|
408
|
+
"--state",
|
|
409
|
+
"open",
|
|
410
|
+
"--limit",
|
|
411
|
+
String(LIST_LIMIT),
|
|
412
|
+
...SORT_RECENT,
|
|
413
|
+
"--json",
|
|
414
|
+
"number,title,author,headRefName,isDraft,reviewDecision,createdAt,updatedAt",
|
|
415
|
+
],
|
|
416
|
+
{ signal },
|
|
417
|
+
);
|
|
418
|
+
return {
|
|
419
|
+
raw,
|
|
420
|
+
limit: LIST_LIMIT,
|
|
421
|
+
parse: () =>
|
|
422
|
+
JSON.parse(raw).map((p) => ({
|
|
423
|
+
number: p.number,
|
|
424
|
+
title: safe(p.title),
|
|
425
|
+
author: safe(p.author?.login ?? ""),
|
|
426
|
+
headRefName: safe(p.headRefName),
|
|
427
|
+
isDraft: p.isDraft,
|
|
428
|
+
reviewDecision: p.reviewDecision,
|
|
429
|
+
updatedAt: p.updatedAt,
|
|
430
|
+
})),
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// The three alert endpoints were three near-identical 26-line blocks that had
|
|
435
|
+
// already drifted apart -- only the Dependabot one reported the real error,
|
|
436
|
+
// which is exactly the divergence a shared seam prevents. Severity and the
|
|
437
|
+
// unavailable-note stay per-endpoint parameters rather than becoming constants,
|
|
438
|
+
// because secret scanning deliberately hardcodes critical severity where the
|
|
439
|
+
// other two derive it from the payload.
|
|
440
|
+
//
|
|
441
|
+
// --jq projects the fields we keep server-side inside `gh`: the Dependabot
|
|
442
|
+
// response carries a full advisory object per alert (~7.3 KB measured), of which
|
|
443
|
+
// this app displays five scalars. Measured 729,866 bytes -> 15,261 for 100
|
|
444
|
+
// alerts. That shrinks the parse, the retained rawRef string, and the maxBuffer
|
|
445
|
+
// exposure in one move -- but note it runs client-side, so it does not reduce
|
|
446
|
+
// network bytes or the rate-limit cost. `state` is kept in the projection so the
|
|
447
|
+
// belt-and-braces open filter below still has something to test.
|
|
448
|
+
const ALERT_SOURCES = [
|
|
449
|
+
{
|
|
450
|
+
key: "dependabot",
|
|
451
|
+
name: "Dependabot alerts",
|
|
452
|
+
path: `repos/{owner}/{repo}/dependabot/alerts${ALERT_QUERY}`,
|
|
453
|
+
jq: "[.[] | {number, state, created_at, severity: .security_advisory.severity, title: .security_advisory.summary, detail: .dependency.package.name}]",
|
|
454
|
+
unavailable: "Dependabot alerts: unavailable (not enabled for this repository)",
|
|
455
|
+
map: (a) => ({
|
|
456
|
+
id: `dependabot-${a.number}`,
|
|
457
|
+
kind: "Dependabot",
|
|
458
|
+
severity: safe(a.severity ?? "unknown"),
|
|
459
|
+
title: safe(a.title ?? "(no summary)"),
|
|
460
|
+
detail: safe(a.detail ?? ""),
|
|
461
|
+
createdAt: a.created_at,
|
|
462
|
+
}),
|
|
463
|
+
},
|
|
464
|
+
{
|
|
465
|
+
key: "codeScanning",
|
|
466
|
+
name: "Code scanning",
|
|
467
|
+
path: `repos/{owner}/{repo}/code-scanning/alerts${ALERT_QUERY}`,
|
|
468
|
+
jq: "[.[] | {number, state, created_at, severity: (.rule.security_severity_level // .rule.severity), title: (.rule.description // .rule.name), detail: .most_recent_instance.location.path}]",
|
|
469
|
+
unavailable: "Code scanning: not enabled (needs GitHub Advanced Security)",
|
|
470
|
+
map: (a) => ({
|
|
471
|
+
id: `codeql-${a.number}`,
|
|
472
|
+
kind: "CodeQL",
|
|
473
|
+
severity: safe(a.severity ?? "unknown"),
|
|
474
|
+
title: safe(a.title ?? "(no description)"),
|
|
475
|
+
detail: safe(a.detail ?? ""),
|
|
476
|
+
createdAt: a.created_at,
|
|
477
|
+
}),
|
|
478
|
+
},
|
|
479
|
+
{
|
|
480
|
+
key: "secretScanning",
|
|
481
|
+
name: "Secret scanning",
|
|
482
|
+
path: `repos/{owner}/{repo}/secret-scanning/alerts${ALERT_QUERY}`,
|
|
483
|
+
jq: "[.[] | {number, state, created_at, title: (.secret_type_display_name // .secret_type)}]",
|
|
484
|
+
unavailable: "Secret scanning: not enabled for this repository",
|
|
485
|
+
map: (a) => ({
|
|
486
|
+
id: `secret-${a.number}`,
|
|
487
|
+
kind: "Secret",
|
|
488
|
+
// A leaked credential is always critical; there is no severity field to
|
|
489
|
+
// derive one from, and this is what puts it in the red bucket below.
|
|
490
|
+
severity: "critical",
|
|
491
|
+
title: safe(a.title ?? "(unknown secret type)"),
|
|
492
|
+
detail: "",
|
|
493
|
+
createdAt: a.created_at,
|
|
494
|
+
}),
|
|
495
|
+
},
|
|
496
|
+
];
|
|
497
|
+
|
|
498
|
+
// Per-source backoff. Keyed by source so one unavailable endpoint cannot slow
|
|
499
|
+
// the other two, and capped rather than permanent so enabling Advanced Security
|
|
500
|
+
// mid-session is picked up within the hour.
|
|
501
|
+
const alertBackoff = new Map();
|
|
502
|
+
|
|
503
|
+
function backoffActive(key, now) {
|
|
504
|
+
const state = alertBackoff.get(key);
|
|
505
|
+
return Boolean(state) && now < state.until;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function recordFailure(key, now) {
|
|
509
|
+
const previous = alertBackoff.get(key);
|
|
510
|
+
const step = Math.min((previous?.step ?? -1) + 1, BACKOFF_STEPS_MS.length - 1);
|
|
511
|
+
alertBackoff.set(key, { step, until: now + BACKOFF_STEPS_MS[step] });
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function clearBackoff(key) {
|
|
515
|
+
alertBackoff.delete(key);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async function fetchAlertSource(source, signal, now) {
|
|
519
|
+
if (backoffActive(source.key, now)) {
|
|
520
|
+
const note = alertBackoff.get(source.key).note;
|
|
521
|
+
return { raw: `backoff:${note}`, parse: () => ({ alerts: [], note, truncated: false }) };
|
|
522
|
+
}
|
|
523
|
+
try {
|
|
524
|
+
const raw = await runGh(["api", apiPath(source.path), "--jq", source.jq], { signal });
|
|
525
|
+
clearBackoff(source.key);
|
|
526
|
+
return {
|
|
527
|
+
raw,
|
|
528
|
+
parse: () => {
|
|
529
|
+
const rows = JSON.parse(raw).filter((a) => a.state === "open");
|
|
530
|
+
return {
|
|
531
|
+
alerts: rows.map(source.map),
|
|
532
|
+
note: null,
|
|
533
|
+
truncated: rows.length >= ALERT_PER_PAGE,
|
|
534
|
+
};
|
|
535
|
+
},
|
|
536
|
+
};
|
|
537
|
+
} catch (err) {
|
|
538
|
+
// A 403/404 means the feature genuinely is not available here and is worth
|
|
539
|
+
// backing off. Anything else -- expired token, rate limit, network -- is
|
|
540
|
+
// transient and must surface as itself rather than as a confident and wrong
|
|
541
|
+
// claim about the repo's configuration, and must not be latched.
|
|
542
|
+
const unavailable = isUnavailable(err) && !isRateLimited(err);
|
|
543
|
+
const note = unavailable ? source.unavailable : `${source.name}: ${shortErr(err)}`;
|
|
544
|
+
if (unavailable) {
|
|
545
|
+
recordFailure(source.key, now);
|
|
546
|
+
alertBackoff.get(source.key).note = note;
|
|
547
|
+
}
|
|
548
|
+
return { raw: `unavailable:${note}`, parse: () => ({ alerts: [], note, truncated: false }) };
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Colour alone said which alerts mattered, but the list was ordered by the
|
|
553
|
+
// accident of which endpoint answered first: all Dependabot by age, then all
|
|
554
|
+
// CodeQL, then all secret-scanning -- which put leaked credentials last, below
|
|
555
|
+
// the fold, on a pane that shows ~25 rows. Rank explicitly, with createdAt as a
|
|
556
|
+
// total tiebreaker so an unchanged payload cannot reshuffle unrelated rows and
|
|
557
|
+
// defeat the redraw suppression.
|
|
558
|
+
const SEVERITY_RANK = { critical: 0, high: 1, medium: 2, moderate: 2, low: 3, unknown: 4 };
|
|
559
|
+
|
|
560
|
+
function severityRank(severity) {
|
|
561
|
+
return Object.hasOwn(SEVERITY_RANK, severity) ? SEVERITY_RANK[severity] : SEVERITY_RANK.unknown;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async function fetchSecurity(signal) {
|
|
565
|
+
const now = Date.now();
|
|
566
|
+
const parts = await Promise.all(ALERT_SOURCES.map((s) => fetchAlertSource(s, signal, now)));
|
|
567
|
+
return {
|
|
568
|
+
// Joined with a NUL so a change in any one of the three shows up as a
|
|
569
|
+
// change in the combined payload, without risk of two different splits
|
|
570
|
+
// colliding on the same string.
|
|
571
|
+
raw: parts.map((p) => p.raw).join("\0"),
|
|
572
|
+
limit: ALERT_PER_PAGE,
|
|
573
|
+
parse: () => {
|
|
574
|
+
const parsed = parts.map((p) => p.parse());
|
|
575
|
+
const alerts = parsed.flatMap((p) => p.alerts);
|
|
576
|
+
alerts.sort((a, b) => {
|
|
577
|
+
const bySeverity = severityRank(a.severity) - severityRank(b.severity);
|
|
578
|
+
if (bySeverity !== 0) return bySeverity;
|
|
579
|
+
return String(b.createdAt ?? "").localeCompare(String(a.createdAt ?? ""));
|
|
580
|
+
});
|
|
581
|
+
return {
|
|
582
|
+
alerts,
|
|
583
|
+
notes: parsed.map((p) => p.note).filter(Boolean),
|
|
584
|
+
truncated: parsed.some((p) => p.truncated),
|
|
585
|
+
};
|
|
586
|
+
},
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// ---------- Selection ----------
|
|
591
|
+
|
|
592
|
+
// The cursor tracks the ITEM, never the row index. Rows arrive newest-first and
|
|
593
|
+
// a new run pushes everything down every few seconds, so an index-based cursor
|
|
594
|
+
// would drift under the reader continuously. Keyed the same way the render loop
|
|
595
|
+
// keys its rows.
|
|
596
|
+
function itemKey(item) {
|
|
597
|
+
return item?.id ?? item?.databaseId ?? item?.number ?? null;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// `gh <kind> view --web` for the tabs that have a per-item command. The Security
|
|
601
|
+
// tab is absent on purpose: alerts have no `gh` view subcommand, so there is
|
|
602
|
+
// nothing honest to open.
|
|
603
|
+
const OPENABLE = { actions: "run", issues: "issue", prs: "pr" };
|
|
604
|
+
|
|
605
|
+
// Output is captured rather than inherited. `gh ... --web` prints "Opening ...
|
|
606
|
+
// in your browser" to stdout, and stdout is ink's frame stream -- letting that
|
|
607
|
+
// through would corrupt the diff and the alternate screen.
|
|
608
|
+
async function openInBrowser(tabKey, item, signal) {
|
|
609
|
+
const kind = pick(OPENABLE, tabKey, null);
|
|
610
|
+
const number = item?.number ?? item?.databaseId;
|
|
611
|
+
if (!kind || number == null) return;
|
|
612
|
+
await runGh([kind, "view", ...repoArgs(), String(number), "--web"], { signal });
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// ---------- Startup preflight ----------
|
|
616
|
+
|
|
617
|
+
// Three failures are guaranteed on a fresh machine or a terminal that happens
|
|
618
|
+
// to be somewhere else: no `gh`, and not inside a repository. Each used to fail
|
|
619
|
+
// four times over -- once per tab -- as a multi-line subprocess dump rendered
|
|
620
|
+
// *inside the alternate screen*, which the exit handler then wiped, so the
|
|
621
|
+
// diagnosis was unreadable and then gone.
|
|
622
|
+
//
|
|
623
|
+
// Only locally-determinable, non-transient conditions belong here. Auth and
|
|
624
|
+
// network failures deliberately do not: `gh auth status` makes a network call,
|
|
625
|
+
// and exiting on it would make the tool unusable offline where today it simply
|
|
626
|
+
// recovers when the network returns. Those keep flowing through the in-pane
|
|
627
|
+
// error path, which is now worth reading.
|
|
628
|
+
async function preflight() {
|
|
629
|
+
try {
|
|
630
|
+
await execFileAsync("gh", ["--version"], { timeout: GH_TIMEOUT_MS });
|
|
631
|
+
} catch (err) {
|
|
632
|
+
if (err?.code === "ENOENT") {
|
|
633
|
+
return "gh-glance: the gh CLI is not installed.\nInstall it from https://cli.github.com, then run `gh auth login`.";
|
|
634
|
+
}
|
|
635
|
+
return `gh-glance: could not run gh: ${shortErr(err)}`;
|
|
636
|
+
}
|
|
637
|
+
// Only meaningful when the repository is being inferred from the working
|
|
638
|
+
// directory. With --repo or GH_REPO the cwd is irrelevant, and refusing to
|
|
639
|
+
// start outside a checkout would defeat the flag's whole purpose -- watching a
|
|
640
|
+
// repository you have not cloned.
|
|
641
|
+
if (runtime.repo || process.env.GH_REPO) return null;
|
|
642
|
+
try {
|
|
643
|
+
await execFileAsync("git", ["rev-parse", "--git-dir"], { timeout: GH_TIMEOUT_MS });
|
|
644
|
+
} catch {
|
|
645
|
+
return (
|
|
646
|
+
"gh-glance: not inside a git repository.\n" +
|
|
647
|
+
"Run it from a cloned GitHub repository, or pass --repo owner/name."
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
return null;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// ---------- Command line ----------
|
|
654
|
+
|
|
655
|
+
// The repository name is the only user-supplied value that reaches a subprocess
|
|
656
|
+
// argument *and* gets interpolated into a `gh api` path. execFile with an array
|
|
657
|
+
// means there is no shell to inject into, but an unvalidated value in the API
|
|
658
|
+
// path would be a request-forgery primitive against arbitrary endpoints -- so it
|
|
659
|
+
// is validated once, here at the boundary, against exactly what GitHub allows in
|
|
660
|
+
// an owner or repository name.
|
|
661
|
+
const REPO_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?\/[A-Za-z0-9._-]+$/;
|
|
662
|
+
|
|
663
|
+
// Below roughly two seconds a fetch cannot finish before the next tick, so the
|
|
664
|
+
// in-flight guard absorbs every other one and the effective rate is whatever
|
|
665
|
+
// `gh` can sustain -- the requested interval silently stops being real. Clamping
|
|
666
|
+
// with a stated minimum is honest where silently accepting it would not be.
|
|
667
|
+
const MIN_REFRESH_SECONDS = 2;
|
|
668
|
+
const MAX_REFRESH_SECONDS = 3600;
|
|
669
|
+
|
|
670
|
+
// The argv surface was a strict allowlist that exited 2 on anything unknown.
|
|
671
|
+
// That is a feature, not an accident -- a typo fails loudly instead of being
|
|
672
|
+
// ignored -- so widening it keeps the same shape: every flag is named here, and
|
|
673
|
+
// anything else still exits 2.
|
|
674
|
+
function parseArgs(argv) {
|
|
675
|
+
const opts = { help: false, showVersion: false, repo: null, refresh: null, tab: null, verbose: false };
|
|
676
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
677
|
+
const arg = argv[i];
|
|
678
|
+
const takeValue = (name) => {
|
|
679
|
+
const inline = arg.includes("=") ? arg.slice(arg.indexOf("=") + 1) : null;
|
|
680
|
+
if (inline !== null) return inline;
|
|
681
|
+
i += 1;
|
|
682
|
+
if (i >= argv.length) throw new Error(`${name} needs a value`);
|
|
683
|
+
return argv[i];
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
if (arg === "--help" || arg === "-h") opts.help = true;
|
|
687
|
+
else if (arg === "--version" || arg === "-v") opts.showVersion = true;
|
|
688
|
+
else if (arg === "--verbose") opts.verbose = true;
|
|
689
|
+
else if (arg === "--repo" || arg === "-R" || arg.startsWith("--repo=")) {
|
|
690
|
+
opts.repo = takeValue("--repo");
|
|
691
|
+
} else if (arg === "--refresh" || arg.startsWith("--refresh=")) {
|
|
692
|
+
opts.refresh = takeValue("--refresh");
|
|
693
|
+
} else if (arg === "--tab" || arg.startsWith("--tab=")) {
|
|
694
|
+
opts.tab = takeValue("--tab");
|
|
695
|
+
} else {
|
|
696
|
+
throw new Error(`unknown argument: ${arg}`);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
return opts;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// Returns { repo, refreshMs, tabKey, verbose } or throws with a message that
|
|
703
|
+
// says what to do about it.
|
|
704
|
+
function validateArgs(opts, tabKeys) {
|
|
705
|
+
if (opts.repo !== null && !REPO_PATTERN.test(opts.repo)) {
|
|
706
|
+
throw new Error(`--repo must look like owner/name, got: ${opts.repo}`);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
let refreshMs = null;
|
|
710
|
+
if (opts.refresh !== null) {
|
|
711
|
+
const seconds = Number(opts.refresh);
|
|
712
|
+
if (!Number.isFinite(seconds) || !Number.isInteger(seconds)) {
|
|
713
|
+
throw new Error(`--refresh must be a whole number of seconds, got: ${opts.refresh}`);
|
|
714
|
+
}
|
|
715
|
+
if (seconds < MIN_REFRESH_SECONDS || seconds > MAX_REFRESH_SECONDS) {
|
|
716
|
+
throw new Error(
|
|
717
|
+
`--refresh must be between ${MIN_REFRESH_SECONDS} and ${MAX_REFRESH_SECONDS} seconds, got: ${seconds}`,
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
refreshMs = seconds * 1000;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
if (opts.tab !== null && !tabKeys.includes(opts.tab)) {
|
|
724
|
+
throw new Error(`--tab must be one of ${tabKeys.join(", ")}, got: ${opts.tab}`);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
return {
|
|
728
|
+
help: opts.help,
|
|
729
|
+
showVersion: opts.showVersion,
|
|
730
|
+
repo: opts.repo,
|
|
731
|
+
refreshMs,
|
|
732
|
+
tabKey: opts.tab,
|
|
733
|
+
verbose: opts.verbose,
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// ---------- Entry point ----------
|
|
738
|
+
|
|
739
|
+
const { version } = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8"));
|
|
740
|
+
|
|
741
|
+
const HELP = `gh-glance ${version} -- a live-refreshing GitHub dashboard for a narrow terminal pane.
|
|
742
|
+
|
|
743
|
+
Usage:
|
|
744
|
+
gh-glance Run the dashboard in the current repository
|
|
745
|
+
gh-glance --repo owner/name Watch a specific repository instead
|
|
746
|
+
gh-glance --refresh 15 Poll the active tab every 15 seconds
|
|
747
|
+
gh-glance --tab security Start on a specific tab
|
|
748
|
+
gh-glance --verbose 2>log Log every gh call to a file (see below)
|
|
749
|
+
gh-glance --help Show this help
|
|
750
|
+
gh-glance --version Show the version
|
|
751
|
+
|
|
752
|
+
Options:
|
|
753
|
+
-R, --repo <owner/name> Repository to watch. Without it the repo is inferred
|
|
754
|
+
from the git remote, the same way \`gh\` does it.
|
|
755
|
+
--refresh <seconds> Active-tab poll interval (${MIN_REFRESH_SECONDS}-${MAX_REFRESH_SECONDS},
|
|
756
|
+
default ${REFRESH_MS / 1000}). Background tabs stay at
|
|
757
|
+
${BACKGROUND_EVERY}x this.
|
|
758
|
+
--tab <name> Tab to start on: ${TAB_KEYS.join(", ")}.
|
|
759
|
+
--verbose Write one line per gh invocation to stderr. stderr
|
|
760
|
+
must be redirected -- writing it to the terminal
|
|
761
|
+
would draw over the dashboard, so this refuses to
|
|
762
|
+
start otherwise.
|
|
763
|
+
|
|
764
|
+
Run it from inside a locally cloned GitHub repository; the repo is inferred
|
|
765
|
+
from the git remote, the same way \`gh\` does it. Requires the \`gh\` CLI
|
|
766
|
+
(2.20 or newer), authenticated via \`gh auth login\`.
|
|
767
|
+
|
|
768
|
+
The active tab refreshes every ${REFRESH_MS / 1000}s; the other three refresh every
|
|
769
|
+
${(REFRESH_MS * BACKGROUND_EVERY) / 1000}s to keep their counts honest without spending the API rate limit.
|
|
770
|
+
|
|
771
|
+
Status icons are GitHub Octicons and need a Nerd Font. Without one, set
|
|
772
|
+
GH_GLANCE_ICONS=unicode for plain-unicode equivalents.
|
|
773
|
+
|
|
774
|
+
Keys:
|
|
775
|
+
1 2 3 4 Actions / Issues / Pull requests / Security
|
|
776
|
+
Left / Right Previous / next tab
|
|
777
|
+
Tab / Shift+Tab Next / previous tab
|
|
778
|
+
Up / Down, j / k Move the cursor between rows
|
|
779
|
+
PgUp / PgDn Move a page at a time
|
|
780
|
+
Enter Open the selected item in your browser
|
|
781
|
+
r Refresh the current tab now
|
|
782
|
+
q / Esc / Ctrl+C Quit
|
|
783
|
+
|
|
784
|
+
Environment:
|
|
785
|
+
GH_REPO=owner/name Watch a specific repository (--repo takes precedence)
|
|
786
|
+
GH_GLANCE_ICONS=unicode Plain-unicode status icons (no Nerd Font needed)
|
|
787
|
+
GH_GLANCE_NO_ANIMATION=1 Freeze the spinner (no motion)
|
|
788
|
+
NO_COLOR=1 Disable colour (status stays readable)
|
|
789
|
+
`;
|
|
790
|
+
|
|
791
|
+
// argv and the TTY guard run *before* react and ink are imported. Loading ink
|
|
792
|
+
// costs a measured 137ms and 46MB of RSS, which is a poor trade for printing a
|
|
793
|
+
// version string -- and `--version`/`--help` are exactly the paths shell
|
|
794
|
+
// completions, npx and the CI smoke job hit. The NODE_ENV assignment at the top
|
|
795
|
+
// of the file stays where it is; only the imports moved down.
|
|
796
|
+
//
|
|
797
|
+
// Precedence is preserved: an unknown argument is reported before the non-TTY
|
|
798
|
+
// refusal, because a typo is the more actionable of the two.
|
|
799
|
+
if (IS_MAIN) {
|
|
800
|
+
let opts;
|
|
801
|
+
try {
|
|
802
|
+
opts = validateArgs(parseArgs(process.argv.slice(2)), TAB_KEYS);
|
|
803
|
+
} catch (err) {
|
|
804
|
+
// Exit 2 for every argv problem, unchanged from when the only possible
|
|
805
|
+
// problem was an unrecognised flag. CI asserts this code.
|
|
806
|
+
console.error(`gh-glance: ${err.message}\nRun \`gh-glance --help\` for usage.`);
|
|
807
|
+
process.exit(2);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// Checked after parsing rather than before, so `gh-glance --repo` with no
|
|
811
|
+
// value reports the missing value rather than silently printing help.
|
|
812
|
+
if (opts.help) {
|
|
813
|
+
console.log(HELP);
|
|
814
|
+
process.exit(0);
|
|
815
|
+
}
|
|
816
|
+
if (opts.showVersion) {
|
|
817
|
+
console.log(version);
|
|
818
|
+
process.exit(0);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// Verbose output must never reach stdout -- that is ink's frame stream, and
|
|
822
|
+
// anything else in it corrupts the diff and the alternate-screen state. stderr
|
|
823
|
+
// is safe only once it is redirected somewhere; while it is still the
|
|
824
|
+
// terminal, these lines would be painted straight over the dashboard. Refusing
|
|
825
|
+
// is better than quietly producing a corrupted screen.
|
|
826
|
+
if (opts.verbose && process.stderr.isTTY) {
|
|
827
|
+
console.error(
|
|
828
|
+
"gh-glance: --verbose writes a log to stderr, which would draw over the dashboard.\n" +
|
|
829
|
+
"Redirect it to a file, e.g. `gh-glance --verbose 2>gh-glance.log`.",
|
|
830
|
+
);
|
|
831
|
+
process.exit(2);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
runtime.repo = opts.repo;
|
|
835
|
+
runtime.verbose = opts.verbose;
|
|
836
|
+
if (opts.refreshMs !== null) runtime.refreshMs = opts.refreshMs;
|
|
837
|
+
if (opts.tabKey !== null) runtime.initialTabIndex = TAB_KEYS.indexOf(opts.tabKey);
|
|
838
|
+
|
|
839
|
+
// This is a full-screen live dashboard, not a reporting command -- piping it
|
|
840
|
+
// somewhere would emit an endless stream of redraw frames, so fail fast with
|
|
841
|
+
// an explanation instead. Exit code 1 is asserted by CI.
|
|
842
|
+
if (!process.stdout.isTTY) {
|
|
843
|
+
console.error(
|
|
844
|
+
"gh-glance: stdout is not a terminal. This is an interactive dashboard and can't be piped or redirected.",
|
|
845
|
+
);
|
|
846
|
+
process.exit(1);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
const problem = await preflight();
|
|
850
|
+
if (problem) {
|
|
851
|
+
console.error(problem);
|
|
852
|
+
process.exit(3);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const ReactModule = await import("react");
|
|
857
|
+
const { render, Box, Text, useStdout, useInput, useStdin, useApp } = await import("ink");
|
|
858
|
+
|
|
859
|
+
const React = ReactModule.default;
|
|
860
|
+
const { useState, useEffect, useRef } = ReactModule;
|
|
861
|
+
const e = React.createElement;
|
|
862
|
+
|
|
863
|
+
// The NODE_ENV escape hatch above is genuinely useful -- React's warnings are
|
|
864
|
+
// worth having -- but the development build is what caused the fatal heap
|
|
865
|
+
// growth in the first place, and anyone with NODE_ENV=development exported from
|
|
866
|
+
// a shell profile or a dev container would silently get it back. Node never
|
|
867
|
+
// trims the user-timing buffer on its own, so clearing it periodically bounds
|
|
868
|
+
// the growth at a few seconds' worth of entries while leaving every warning
|
|
869
|
+
// intact. Scoped to the non-production branch: it discards any other consumer's
|
|
870
|
+
// marks, and there are none here today.
|
|
871
|
+
function disarmDevBuildLeak() {
|
|
872
|
+
if (process.env.NODE_ENV === "production") return;
|
|
873
|
+
console.error(
|
|
874
|
+
"gh-glance: NODE_ENV is not 'production', so React's development build is loaded.\n" +
|
|
875
|
+
" Expect higher memory use. Unset NODE_ENV for the production build.",
|
|
876
|
+
);
|
|
877
|
+
setInterval(() => {
|
|
878
|
+
performance.clearMeasures();
|
|
879
|
+
performance.clearMarks();
|
|
880
|
+
}, 10_000).unref();
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// ---------- Octicons (via the Nerd Font glyph set -- private-use-area
|
|
884
|
+
// codepoints from ryanoasis/nerd-fonts glyphnames.json). Same icon shapes
|
|
885
|
+
// github.com uses, not emoji. ----------
|
|
886
|
+
const OCT_NERD = {
|
|
887
|
+
checkCircleFill: "",
|
|
888
|
+
xCircleFill: "",
|
|
889
|
+
skipFill: "",
|
|
890
|
+
dotFill: "",
|
|
891
|
+
alertFill: "",
|
|
892
|
+
issueOpened: "",
|
|
893
|
+
pullRequest: "",
|
|
894
|
+
pullRequestDraft: "",
|
|
895
|
+
shield: "",
|
|
896
|
+
};
|
|
897
|
+
|
|
898
|
+
// Without a Nerd Font every one of those renders as a blank box, which meant
|
|
899
|
+
// status was carried by the colour of a blank box -- and by nothing at all for
|
|
900
|
+
// a colour-blind user or anyone running NO_COLOR. The README's remedy was to
|
|
901
|
+
// edit the glyph table in the installed source, which stops working the moment
|
|
902
|
+
// this ships as a package. Keys are identical to the table above by
|
|
903
|
+
// construction; a missing key renders an empty cell, which is the exact symptom
|
|
904
|
+
// this exists to fix. Every substitute is deliberately width-1 ASCII: the
|
|
905
|
+
// prettier candidates are East-Asian-Ambiguous and render two cells wide in
|
|
906
|
+
// some terminals, which shifts every column to their right.
|
|
907
|
+
const OCT_UNICODE = {
|
|
908
|
+
checkCircleFill: "+",
|
|
909
|
+
xCircleFill: "x",
|
|
910
|
+
skipFill: "-",
|
|
911
|
+
dotFill: "o",
|
|
912
|
+
alertFill: "!",
|
|
913
|
+
issueOpened: "i",
|
|
914
|
+
pullRequest: "p",
|
|
915
|
+
pullRequestDraft: "d",
|
|
916
|
+
shield: "s",
|
|
917
|
+
};
|
|
918
|
+
|
|
919
|
+
const OCT = process.env.GH_GLANCE_ICONS === "unicode" ? OCT_UNICODE : OCT_NERD;
|
|
920
|
+
|
|
921
|
+
// ---------- Shared layout primitives ----------
|
|
922
|
+
|
|
923
|
+
// ANSI 8 ("gray", i.e. bright-black) sits close to the background on most dark
|
|
924
|
+
// themes -- legible in a screenshot, not at a glance. Secondary text uses
|
|
925
|
+
// dimColor instead of an absolute colour, so de-emphasis is computed against
|
|
926
|
+
// whatever foreground the user actually has: the previous ANSI 7 was tuned for
|
|
927
|
+
// a dark terminal and became unreadable on a light one, while on a dark theme
|
|
928
|
+
// it matched the default foreground closely enough that the intended hierarchy
|
|
929
|
+
// never appeared at all. "gray" is kept for the frame and separators, where
|
|
930
|
+
// receding is the whole point.
|
|
931
|
+
const BORDER_COLOR = "gray";
|
|
932
|
+
const TITLE_COLOR = "cyanBright";
|
|
933
|
+
const IDENTIFIER = "blue";
|
|
934
|
+
const REF = "magenta";
|
|
935
|
+
|
|
936
|
+
// Status colours, named by what they mean rather than what they look like. The
|
|
937
|
+
// meaning was previously carried only by repetition -- 28 inline literals across
|
|
938
|
+
// six values -- so a reader had to infer that redBright meant "failed" here and
|
|
939
|
+
// "critical" there.
|
|
940
|
+
//
|
|
941
|
+
// INERT and BORDER_COLOR are the same value on purpose and must stay separate
|
|
942
|
+
// names: one is chrome that should recede, the other is content that is
|
|
943
|
+
// genuinely de-emphasised. Merging them means the next person who retunes the
|
|
944
|
+
// frame colour silently restyles every skipped run.
|
|
945
|
+
const OK = "greenBright";
|
|
946
|
+
const BAD = "redBright";
|
|
947
|
+
const ATTENTION = "yellowBright";
|
|
948
|
+
const INERT = "gray";
|
|
949
|
+
const ERROR_TEXT = "red";
|
|
950
|
+
|
|
951
|
+
// `label` becomes the cell's text under INK_SCREEN_READER, where the icon
|
|
952
|
+
// column is otherwise a private-use codepoint that announces as nothing --
|
|
953
|
+
// leaving every row with no status at all. It is derived from the same lookup
|
|
954
|
+
// that picks the glyph, so the two cannot drift apart.
|
|
955
|
+
function Column({ width, grow, children, bold, color, dim, wrap, label }) {
|
|
956
|
+
return e(
|
|
957
|
+
Box,
|
|
958
|
+
{ width, flexGrow: grow ? 1 : 0, flexShrink: grow ? 1 : 0, marginRight: 1 },
|
|
959
|
+
e(
|
|
960
|
+
Text,
|
|
961
|
+
{ bold, color, dimColor: dim, wrap: wrap ?? "truncate-end", "aria-label": label },
|
|
962
|
+
children,
|
|
963
|
+
),
|
|
964
|
+
);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function HeaderCells({ cells }) {
|
|
968
|
+
return e(
|
|
969
|
+
Box,
|
|
970
|
+
{
|
|
971
|
+
flexDirection: "row",
|
|
972
|
+
borderStyle: "single",
|
|
973
|
+
borderTop: false,
|
|
974
|
+
borderLeft: false,
|
|
975
|
+
borderRight: false,
|
|
976
|
+
borderColor: BORDER_COLOR,
|
|
977
|
+
},
|
|
978
|
+
...cells.map((c, i) => e(Column, { key: i, ...c.props, bold: true, dim: true }, c.label)),
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// ---------- Panel frame ----------
|
|
983
|
+
|
|
984
|
+
// Ink can draw a border but not label one, so the horizontal edges are plain
|
|
985
|
+
// text and the box between them contributes only its verticals. Drawing them
|
|
986
|
+
// ourselves is what lets the tab name sit in the top edge and the row count in
|
|
987
|
+
// the bottom, the way lazygit labels its panels.
|
|
988
|
+
function PanelEdge({ width, top, label, labelColor }) {
|
|
989
|
+
const [open, close] = top ? ["╭", "╮"] : ["╰", "╯"];
|
|
990
|
+
const text = label ? ` ${label} ` : "";
|
|
991
|
+
// The title hugs the left corner and the count the right, so each edge
|
|
992
|
+
// spends a single dash on the side its label isn't on.
|
|
993
|
+
const fill = width - 3 - text.length;
|
|
994
|
+
// Too narrow to seat the label without wrapping the line: keep the frame,
|
|
995
|
+
// drop the text.
|
|
996
|
+
if (fill < 0) {
|
|
997
|
+
return e(Text, { color: BORDER_COLOR, "aria-hidden": true }, open + "─".repeat(Math.max(0, width - 2)) + close);
|
|
998
|
+
}
|
|
999
|
+
return e(
|
|
1000
|
+
Text,
|
|
1001
|
+
{ color: BORDER_COLOR, "aria-hidden": true },
|
|
1002
|
+
open,
|
|
1003
|
+
top ? "─" : "─".repeat(fill),
|
|
1004
|
+
e(Text, { color: labelColor, bold: true }, text),
|
|
1005
|
+
top ? "─".repeat(fill) : "─",
|
|
1006
|
+
close,
|
|
1007
|
+
);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// ---------- Error containment ----------
|
|
1011
|
+
|
|
1012
|
+
// Every field on screen is defined by GitHub and will keep changing shape. With
|
|
1013
|
+
// the rows mapped straight into the tree, one unexpected value took the whole
|
|
1014
|
+
// dashboard down -- and because the process sits in the alternate screen, the
|
|
1015
|
+
// stack trace was wiped by the restore on the way out, so the tool simply
|
|
1016
|
+
// vanished. A per-row boundary turns that into one visibly broken row.
|
|
1017
|
+
class RowBoundary extends React.Component {
|
|
1018
|
+
constructor(props) {
|
|
1019
|
+
super(props);
|
|
1020
|
+
this.state = { failed: false };
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
static getDerivedStateFromError() {
|
|
1024
|
+
return { failed: true };
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// Reset when the underlying record changes, or a single bad row would keep
|
|
1028
|
+
// its slot blank long after the record that caused it is gone.
|
|
1029
|
+
static getDerivedStateFromProps(props, state) {
|
|
1030
|
+
return state.failed && props.resetKey !== state.key
|
|
1031
|
+
? { failed: false, key: props.resetKey }
|
|
1032
|
+
: { ...state, key: props.resetKey };
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
render() {
|
|
1036
|
+
if (this.state.failed) {
|
|
1037
|
+
return e(Text, { color: BAD }, "! this row could not be rendered");
|
|
1038
|
+
}
|
|
1039
|
+
return this.props.children;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// ---------- Actions tab ----------
|
|
1044
|
+
|
|
1045
|
+
// Four conclusions used to collapse into one grey glyph and timed_out was
|
|
1046
|
+
// pixel-identical to a plain failure. Splitting them uses only codepoints
|
|
1047
|
+
// already verified present in the glyph table above -- introducing new
|
|
1048
|
+
// private-use codepoints risks rendering as a blank box, which is precisely the
|
|
1049
|
+
// failure the unicode fallback exists to fix. Residual aliasing under NO_COLOR
|
|
1050
|
+
// (timed_out vs action_required; skipped vs queued) is resolved by
|
|
1051
|
+
// GH_GLANCE_ICONS=unicode, where every state has its own ASCII character.
|
|
1052
|
+
const RUN_STATUS_ICON = {
|
|
1053
|
+
success: { icon: OCT.checkCircleFill, color: OK, label: "success" },
|
|
1054
|
+
failure: { icon: OCT.xCircleFill, color: BAD, label: "failed" },
|
|
1055
|
+
startup_failure: { icon: OCT.xCircleFill, color: BAD, label: "startup failure" },
|
|
1056
|
+
timed_out: { icon: OCT.alertFill, color: BAD, label: "timed out" },
|
|
1057
|
+
action_required: { icon: OCT.alertFill, color: ATTENTION, label: "action required" },
|
|
1058
|
+
cancelled: { icon: OCT.skipFill, color: INERT, label: "cancelled" },
|
|
1059
|
+
skipped: { icon: OCT.dotFill, color: INERT, label: "skipped" },
|
|
1060
|
+
neutral: { icon: OCT.dotFill, color: INERT, label: "neutral" },
|
|
1061
|
+
stale: { icon: OCT.dotFill, color: INERT, label: "stale" },
|
|
1062
|
+
};
|
|
1063
|
+
const RUN_UNKNOWN_ICON = { icon: "?", color: INERT, label: "unknown" };
|
|
1064
|
+
|
|
1065
|
+
// github.com draws a run that is actually executing as an amber circle with a
|
|
1066
|
+
// turning segment, and one that is merely queued as the same amber standing
|
|
1067
|
+
// still -- the motion, not the colour, is what separates them. With animation
|
|
1068
|
+
// disabled there is no motion to distinguish them, so the running state falls
|
|
1069
|
+
// back to the alert glyph rather than silently reading as queued.
|
|
1070
|
+
const RUN_PENDING_ICON = { icon: OCT.dotFill, color: ATTENTION, label: "queued" };
|
|
1071
|
+
const RUN_RUNNING_STATIC = { icon: OCT.alertFill, color: ATTENTION, label: "running" };
|
|
1072
|
+
|
|
1073
|
+
// Remote strings index these tables, and a plain object literal answers
|
|
1074
|
+
// inherited keys -- `SEVERITY_COLOR["constructor"]` returned a function, which
|
|
1075
|
+
// reaches ink's colour prop and throws, killing the render. `??` cannot catch
|
|
1076
|
+
// that, because an inherited value is not nullish. Code-scanning severity comes
|
|
1077
|
+
// from uploaded SARIF, so it is genuinely attacker-influenced.
|
|
1078
|
+
function pick(table, key, fallback) {
|
|
1079
|
+
return typeof key === "string" && Object.hasOwn(table, key) ? table[key] : fallback;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
function runStatusIcon(run, spin) {
|
|
1083
|
+
if (run.status === "in_progress") {
|
|
1084
|
+
return spin
|
|
1085
|
+
? { icon: spin, color: ATTENTION, label: "running" }
|
|
1086
|
+
: RUN_RUNNING_STATIC;
|
|
1087
|
+
}
|
|
1088
|
+
if (run.status !== "completed") return RUN_PENDING_ICON;
|
|
1089
|
+
return pick(RUN_STATUS_ICON, run.conclusion, RUN_UNKNOWN_ICON);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
const ACTIONS_HEADER = [
|
|
1093
|
+
{ label: "", props: { width: 3 } },
|
|
1094
|
+
{ label: "TITLE", props: { grow: true } },
|
|
1095
|
+
{ label: "WORKFLOW", props: { width: 10 } },
|
|
1096
|
+
{ label: "BRANCH", props: { width: 14 } },
|
|
1097
|
+
{ label: "TIME", props: { width: 7 } },
|
|
1098
|
+
{ label: "UPDATED", props: { width: 8 } },
|
|
1099
|
+
];
|
|
1100
|
+
|
|
1101
|
+
// Dropped in order of least value first: BRANCH and WORKFLOW are inferable
|
|
1102
|
+
// from the title far more often than the status icon or the age are.
|
|
1103
|
+
const ACTIONS_HEADER_COMPACT = [
|
|
1104
|
+
{ label: "", props: { width: 3 } },
|
|
1105
|
+
{ label: "TITLE", props: { grow: true } },
|
|
1106
|
+
{ label: "UPDATED", props: { width: 8 } },
|
|
1107
|
+
];
|
|
1108
|
+
|
|
1109
|
+
function ActionsRow({ item, now, spin, compact, cursor }) {
|
|
1110
|
+
const { icon, color, label } = runStatusIcon(item, spin);
|
|
1111
|
+
const started = new Date(item.startedAt);
|
|
1112
|
+
const finished = item.status === "completed" ? new Date(item.updatedAt) : now;
|
|
1113
|
+
if (compact) {
|
|
1114
|
+
return e(
|
|
1115
|
+
Box,
|
|
1116
|
+
{ flexDirection: "row" },
|
|
1117
|
+
e(Column, { width: 3, color, label }, `${cursor ? ">" : " "}${icon}`),
|
|
1118
|
+
e(Column, { grow: true }, item.displayTitle),
|
|
1119
|
+
e(Column, { width: 8, dim: true }, formatAge(new Date(item.updatedAt), now)),
|
|
1120
|
+
);
|
|
1121
|
+
}
|
|
1122
|
+
return e(
|
|
1123
|
+
Box,
|
|
1124
|
+
{ flexDirection: "row" },
|
|
1125
|
+
e(Column, { width: 3, color, label }, `${cursor ? ">" : " "}${icon}`),
|
|
1126
|
+
e(Column, { grow: true }, item.displayTitle),
|
|
1127
|
+
// The run number is the actionable half and used to be the first thing
|
|
1128
|
+
// truncation ate, since it sat at the tail of a 10-column cell.
|
|
1129
|
+
e(Column, { width: 10, color: IDENTIFIER }, `#${item.number} ${item.workflowName}`),
|
|
1130
|
+
e(Column, { width: 14, color: REF, wrap: "truncate-middle" }, item.headBranch),
|
|
1131
|
+
e(Column, { width: 7 }, formatDuration(finished - started)),
|
|
1132
|
+
e(Column, { width: 8, dim: true }, formatAge(new Date(item.updatedAt), now)),
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// ---------- Issues tab ----------
|
|
1137
|
+
|
|
1138
|
+
const ISSUES_HEADER = [
|
|
1139
|
+
{ label: "", props: { width: 3 } },
|
|
1140
|
+
{ label: "TITLE", props: { grow: true } },
|
|
1141
|
+
{ label: "AUTHOR", props: { width: 12 } },
|
|
1142
|
+
{ label: "LABEL", props: { width: 14 } },
|
|
1143
|
+
{ label: "UPDATED", props: { width: 8 } },
|
|
1144
|
+
];
|
|
1145
|
+
|
|
1146
|
+
const ISSUES_HEADER_COMPACT = [
|
|
1147
|
+
{ label: "", props: { width: 3 } },
|
|
1148
|
+
{ label: "TITLE", props: { grow: true } },
|
|
1149
|
+
{ label: "UPDATED", props: { width: 8 } },
|
|
1150
|
+
];
|
|
1151
|
+
|
|
1152
|
+
function IssueRow({ item, now, compact, cursor }) {
|
|
1153
|
+
const cells = [
|
|
1154
|
+
e(Column, { key: "i", width: 3, color: OK, label: "open issue" }, `${cursor ? ">" : " "}${OCT.issueOpened}`),
|
|
1155
|
+
e(Column, { key: "t", grow: true }, `#${item.number} ${item.title}`),
|
|
1156
|
+
];
|
|
1157
|
+
if (!compact) {
|
|
1158
|
+
cells.push(e(Column, { key: "a", width: 12, color: IDENTIFIER }, item.author));
|
|
1159
|
+
cells.push(e(Column, { key: "l", width: 14, color: REF }, item.label));
|
|
1160
|
+
}
|
|
1161
|
+
cells.push(
|
|
1162
|
+
e(Column, { key: "u", width: 8, dim: true }, formatAge(new Date(item.updatedAt), now)),
|
|
1163
|
+
);
|
|
1164
|
+
return e(Box, { flexDirection: "row" }, ...cells);
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// ---------- Pull requests tab ----------
|
|
1168
|
+
|
|
1169
|
+
const PRS_HEADER = [
|
|
1170
|
+
{ label: "", props: { width: 3 } },
|
|
1171
|
+
{ label: "TITLE", props: { grow: true } },
|
|
1172
|
+
{ label: "AUTHOR", props: { width: 12 } },
|
|
1173
|
+
{ label: "BRANCH", props: { width: 14 } },
|
|
1174
|
+
{ label: "REVIEW", props: { width: 10 } },
|
|
1175
|
+
{ label: "UPDATED", props: { width: 8 } },
|
|
1176
|
+
];
|
|
1177
|
+
|
|
1178
|
+
const PRS_HEADER_COMPACT = [
|
|
1179
|
+
{ label: "", props: { width: 3 } },
|
|
1180
|
+
{ label: "TITLE", props: { grow: true } },
|
|
1181
|
+
{ label: "REVIEW", props: { width: 10 } },
|
|
1182
|
+
];
|
|
1183
|
+
|
|
1184
|
+
const REVIEW_LABEL = {
|
|
1185
|
+
APPROVED: { label: "approved", color: OK },
|
|
1186
|
+
CHANGES_REQUESTED: { label: "changes", color: BAD },
|
|
1187
|
+
REVIEW_REQUIRED: { label: "pending", color: ATTENTION },
|
|
1188
|
+
};
|
|
1189
|
+
const REVIEW_NONE = { label: "", color: INERT };
|
|
1190
|
+
|
|
1191
|
+
function PRRow({ item, now, compact, cursor }) {
|
|
1192
|
+
const prIcon = item.isDraft
|
|
1193
|
+
? { icon: OCT.pullRequestDraft, color: INERT, label: "draft pull request" }
|
|
1194
|
+
: { icon: OCT.pullRequest, color: OK, label: "open pull request" };
|
|
1195
|
+
const review = pick(REVIEW_LABEL, item.reviewDecision, REVIEW_NONE);
|
|
1196
|
+
const cells = [
|
|
1197
|
+
e(Column, { key: "i", width: 3, color: prIcon.color, label: prIcon.label }, `${cursor ? ">" : " "}${prIcon.icon}`),
|
|
1198
|
+
e(Column, { key: "t", grow: true }, `#${item.number} ${item.title}`),
|
|
1199
|
+
];
|
|
1200
|
+
if (!compact) {
|
|
1201
|
+
cells.push(e(Column, { key: "a", width: 12, color: IDENTIFIER }, item.author));
|
|
1202
|
+
cells.push(
|
|
1203
|
+
e(Column, { key: "b", width: 14, color: REF, wrap: "truncate-middle" }, item.headRefName),
|
|
1204
|
+
);
|
|
1205
|
+
}
|
|
1206
|
+
cells.push(e(Column, { key: "r", width: 10, color: review.color }, review.label));
|
|
1207
|
+
if (!compact) {
|
|
1208
|
+
cells.push(
|
|
1209
|
+
e(Column, { key: "u", width: 8, dim: true }, formatAge(new Date(item.updatedAt), now)),
|
|
1210
|
+
);
|
|
1211
|
+
}
|
|
1212
|
+
return e(Box, { flexDirection: "row" }, ...cells);
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
// ---------- Security tab ----------
|
|
1216
|
+
|
|
1217
|
+
// Severity used to be carried by the shield glyph's colour alone, which made a
|
|
1218
|
+
// critical alert and a high one pixel-identical (both redBright) and erased the
|
|
1219
|
+
// distinction entirely under NO_COLOR or for a colour-blind reader. A 4-wide
|
|
1220
|
+
// text column states it outright; the width comes out of PACKAGE / FILE, whose
|
|
1221
|
+
// contents were already being truncated.
|
|
1222
|
+
const SECURITY_HEADER = [
|
|
1223
|
+
{ label: "", props: { width: 3 } },
|
|
1224
|
+
{ label: "SEV", props: { width: 4 } },
|
|
1225
|
+
{ label: "PACKAGE / FILE", props: { width: 16 } },
|
|
1226
|
+
{ label: "SUMMARY", props: { grow: true } },
|
|
1227
|
+
{ label: "AGE", props: { width: 8 } },
|
|
1228
|
+
];
|
|
1229
|
+
|
|
1230
|
+
// SEV is the last thing to drop on this tab: it is the whole point of the pane
|
|
1231
|
+
// and the only non-colour severity channel.
|
|
1232
|
+
const SECURITY_HEADER_COMPACT = [
|
|
1233
|
+
{ label: "", props: { width: 3 } },
|
|
1234
|
+
{ label: "SEV", props: { width: 4 } },
|
|
1235
|
+
{ label: "SUMMARY", props: { grow: true } },
|
|
1236
|
+
];
|
|
1237
|
+
|
|
1238
|
+
const SEVERITY_STYLE = {
|
|
1239
|
+
critical: { color: BAD, short: "crit" },
|
|
1240
|
+
high: { color: BAD, short: "high" },
|
|
1241
|
+
medium: { color: ATTENTION, short: "med" },
|
|
1242
|
+
moderate: { color: ATTENTION, short: "med" },
|
|
1243
|
+
low: { color: INERT, short: "low" },
|
|
1244
|
+
unknown: { color: INERT, short: "?" },
|
|
1245
|
+
};
|
|
1246
|
+
const SEVERITY_UNKNOWN = SEVERITY_STYLE.unknown;
|
|
1247
|
+
|
|
1248
|
+
function SecurityRow({ item, now, compact, cursor }) {
|
|
1249
|
+
const sev = pick(SEVERITY_STYLE, item.severity, SEVERITY_UNKNOWN);
|
|
1250
|
+
const cells = [
|
|
1251
|
+
e(Column, { key: "i", width: 3, color: sev.color, label: `${sev.short} severity` }, `${cursor ? ">" : " "}${OCT.shield}`),
|
|
1252
|
+
e(Column, { key: "s", width: 4, color: sev.color }, sev.short),
|
|
1253
|
+
];
|
|
1254
|
+
if (!compact) {
|
|
1255
|
+
cells.push(e(Column, { key: "p", width: 16, color: IDENTIFIER }, item.detail || item.kind));
|
|
1256
|
+
}
|
|
1257
|
+
cells.push(e(Column, { key: "t", grow: true }, item.title));
|
|
1258
|
+
if (!compact) {
|
|
1259
|
+
cells.push(
|
|
1260
|
+
e(Column, { key: "a", width: 8, dim: true }, formatAge(new Date(item.createdAt), now)),
|
|
1261
|
+
);
|
|
1262
|
+
}
|
|
1263
|
+
return e(Box, { flexDirection: "row" }, ...cells);
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// ---------- Tabs ----------
|
|
1267
|
+
|
|
1268
|
+
// Memoised because their inputs are stable by construction. Two things must
|
|
1269
|
+
// stay true for that to hold: `now` must keep being replaced rather than
|
|
1270
|
+
// mutated, and the raw-payload bail-out must keep producing fresh item objects
|
|
1271
|
+
// when data genuinely changes. In-place mutation of a parsed item would make a
|
|
1272
|
+
// memoised row silently stop updating.
|
|
1273
|
+
const MemoActionsRow = React.memo(ActionsRow);
|
|
1274
|
+
const MemoIssueRow = React.memo(IssueRow);
|
|
1275
|
+
const MemoPRRow = React.memo(PRRow);
|
|
1276
|
+
const MemoSecurityRow = React.memo(SecurityRow);
|
|
1277
|
+
|
|
1278
|
+
const TABS = [
|
|
1279
|
+
{
|
|
1280
|
+
key: "actions",
|
|
1281
|
+
fetch: ({ signal, runLimit }) => fetchActions(runLimit, signal),
|
|
1282
|
+
label: "Actions",
|
|
1283
|
+
short: "Actions",
|
|
1284
|
+
header: ACTIONS_HEADER,
|
|
1285
|
+
compactHeader: ACTIONS_HEADER_COMPACT,
|
|
1286
|
+
Row: MemoActionsRow,
|
|
1287
|
+
countLabel: "runs",
|
|
1288
|
+
},
|
|
1289
|
+
{
|
|
1290
|
+
key: "issues",
|
|
1291
|
+
fetch: ({ signal }) => fetchIssues(signal),
|
|
1292
|
+
label: "Issues",
|
|
1293
|
+
short: "Issues",
|
|
1294
|
+
header: ISSUES_HEADER,
|
|
1295
|
+
compactHeader: ISSUES_HEADER_COMPACT,
|
|
1296
|
+
Row: MemoIssueRow,
|
|
1297
|
+
countLabel: "open issues",
|
|
1298
|
+
},
|
|
1299
|
+
{
|
|
1300
|
+
key: "prs",
|
|
1301
|
+
fetch: ({ signal }) => fetchPRs(signal),
|
|
1302
|
+
label: "Pull requests",
|
|
1303
|
+
short: "PRs",
|
|
1304
|
+
header: PRS_HEADER,
|
|
1305
|
+
compactHeader: PRS_HEADER_COMPACT,
|
|
1306
|
+
Row: MemoPRRow,
|
|
1307
|
+
countLabel: "open PRs",
|
|
1308
|
+
},
|
|
1309
|
+
{
|
|
1310
|
+
key: "security",
|
|
1311
|
+
fetch: ({ signal }) => fetchSecurity(signal),
|
|
1312
|
+
label: "Security",
|
|
1313
|
+
short: "Security",
|
|
1314
|
+
header: SECURITY_HEADER,
|
|
1315
|
+
compactHeader: SECURITY_HEADER_COMPACT,
|
|
1316
|
+
Row: MemoSecurityRow,
|
|
1317
|
+
countLabel: "alerts",
|
|
1318
|
+
},
|
|
1319
|
+
];
|
|
1320
|
+
|
|
1321
|
+
// The narrowest width each tab's table can render without its fixed columns
|
|
1322
|
+
// overflowing the frame. Derived from the header descriptors rather than
|
|
1323
|
+
// hard-coded, so adding or resizing a column cannot silently invalidate it.
|
|
1324
|
+
// Below this the fixed columns (which deliberately do not shrink, so that
|
|
1325
|
+
// BRANCH and TIME stay readable at ordinary widths) push past the terminal edge,
|
|
1326
|
+
// the rows hard-wrap, and ink switches to clearing and repainting the whole
|
|
1327
|
+
// screen every frame.
|
|
1328
|
+
function minimumWidthFor(header) {
|
|
1329
|
+
const fixed = header.reduce((sum, cell) => sum + (cell.props.width ?? 0) + 1, 0);
|
|
1330
|
+
// + 2 border verticals, + 2 paddingX, + at least 4 columns for TITLE.
|
|
1331
|
+
return fixed + 8;
|
|
1332
|
+
}
|
|
1333
|
+
// Below this the full column set cannot render without its fixed columns
|
|
1334
|
+
// overflowing the frame -- rows hard-wrap and ink switches to clearing and
|
|
1335
|
+
// repainting the whole screen every frame. Worse, in the band just above the
|
|
1336
|
+
// hard failure the frame still looked correct while the TITLE column silently
|
|
1337
|
+
// rendered empty (measured: completely blank at 52 columns). So switch to the
|
|
1338
|
+
// compact column set rather than letting either happen.
|
|
1339
|
+
const MIN_TABLE_WIDTH = Math.max(...TABS.map((t) => minimumWidthFor(t.header)));
|
|
1340
|
+
// The compact set needs roughly a third as much, so the frame, tab bar and
|
|
1341
|
+
// status line survive at any width a terminal is realistically set to.
|
|
1342
|
+
const MIN_COMPACT_WIDTH = Math.max(...TABS.map((t) => minimumWidthFor(t.compactHeader)));
|
|
1343
|
+
|
|
1344
|
+
// The tab bar is laid out independently of the table and wraps at its own
|
|
1345
|
+
// width. Full labels need ~77 columns; short labels ~57. Hysteresis on the
|
|
1346
|
+
// breakpoint so a pane parked exactly on it does not flip labels on every
|
|
1347
|
+
// resize event and defeat the unchanged-frame optimization.
|
|
1348
|
+
const TAB_LABEL_FULL_WIDTH = 78;
|
|
1349
|
+
const TAB_LABEL_HYSTERESIS = 4;
|
|
1350
|
+
|
|
1351
|
+
function TabBar({ activeIndex, counts, firstLoad, failed, spin, useShort }) {
|
|
1352
|
+
return e(
|
|
1353
|
+
Box,
|
|
1354
|
+
{ flexDirection: "row" },
|
|
1355
|
+
...TABS.map((tab, i) => {
|
|
1356
|
+
const active = i === activeIndex;
|
|
1357
|
+
const count = counts[tab.key];
|
|
1358
|
+
// A tab that has never resolved shows the spinner where its count will
|
|
1359
|
+
// go, so the first load reads as "working" rather than "empty".
|
|
1360
|
+
const suffix = count == null ? (firstLoad[tab.key] ? ` ${spin}` : "") : ` (${count})`;
|
|
1361
|
+
const name = useShort ? tab.short : tab.label;
|
|
1362
|
+
// Bold and inverse are both stripped at chalk level 0 (NO_COLOR, or a
|
|
1363
|
+
// dumb terminal), which left no indication at all of which tab was
|
|
1364
|
+
// selected. Brackets survive; the padding they replace keeps the width
|
|
1365
|
+
// identical, so the bar does not shift between the two modes.
|
|
1366
|
+
const label = active ? `[${i + 1}:${name}${suffix}]` : ` ${i + 1}:${name}${suffix} `;
|
|
1367
|
+
return e(
|
|
1368
|
+
Box,
|
|
1369
|
+
{ key: tab.key, marginRight: 2 },
|
|
1370
|
+
e(
|
|
1371
|
+
Text,
|
|
1372
|
+
{
|
|
1373
|
+
bold: active,
|
|
1374
|
+
inverse: active,
|
|
1375
|
+
// A tab whose fetches are failing kept rendering its last good
|
|
1376
|
+
// count at full confidence, and the error banner is only visible on
|
|
1377
|
+
// the tab you have selected -- so a background tab could fail for an
|
|
1378
|
+
// hour with nothing on screen to say so.
|
|
1379
|
+
color: failed[tab.key] ? "redBright" : undefined,
|
|
1380
|
+
dimColor: !active && !failed[tab.key],
|
|
1381
|
+
wrap: "truncate-end",
|
|
1382
|
+
},
|
|
1383
|
+
label,
|
|
1384
|
+
),
|
|
1385
|
+
);
|
|
1386
|
+
}),
|
|
1387
|
+
);
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
// ---------- Status bar ----------
|
|
1391
|
+
|
|
1392
|
+
// Every glyph here is width-1 ASCII, deliberately. The arrows, the return
|
|
1393
|
+
// symbol and the box-drawing separator are all East-Asian-Ambiguous: ink
|
|
1394
|
+
// measures them as two columns in its width model, and a status bar built from
|
|
1395
|
+
// them overflowed an 80-column terminal by six columns once selection added a
|
|
1396
|
+
// hint. Same trap the unicode icon table already documents -- prefer strictly
|
|
1397
|
+
// narrow ASCII over pretty-but-ambiguous.
|
|
1398
|
+
//
|
|
1399
|
+
// Tab switching is not listed: the tab bar already renders "1:Actions", so the
|
|
1400
|
+
// digits document themselves, and the arrow keys are in --help. The hints that
|
|
1401
|
+
// survive are the ones nothing else on screen reveals.
|
|
1402
|
+
const KEY_HINTS = [
|
|
1403
|
+
{ label: "Move", keys: "jk" },
|
|
1404
|
+
{ label: "Open", keys: "Ent" },
|
|
1405
|
+
{ label: "Refresh", keys: "r" },
|
|
1406
|
+
{ label: "Quit", keys: "q" },
|
|
1407
|
+
];
|
|
1408
|
+
|
|
1409
|
+
// Reserved so the hints don't shift sideways every time a refresh starts and
|
|
1410
|
+
// finishes. Wide enough for the spinner, a space and "Fetching".
|
|
1411
|
+
const FETCHING_WIDTH = 12;
|
|
1412
|
+
// Likewise for the staleness slot, which is empty most of the time.
|
|
1413
|
+
const STALE_WIDTH = 10;
|
|
1414
|
+
|
|
1415
|
+
// Two tones rather than one flat gray: the keys you press are the part worth
|
|
1416
|
+
// finding at a glance, so they get the accent colour and the words describing
|
|
1417
|
+
// them stay dim. The accent is the panel-title cyan rather than the amber used
|
|
1418
|
+
// for in-progress status, so amber means exactly one thing across the product.
|
|
1419
|
+
function StatusBar({ fetching, spin, stale, interactive }) {
|
|
1420
|
+
// Without raw mode none of the key handlers run, so advertising them would be
|
|
1421
|
+
// telling the user something untrue about what the app can do. Ctrl+C still
|
|
1422
|
+
// works there, because the tty delivers a real SIGINT.
|
|
1423
|
+
const hints = interactive
|
|
1424
|
+
? KEY_HINTS
|
|
1425
|
+
: [{ label: "Quit", keys: "^C" }];
|
|
1426
|
+
return e(
|
|
1427
|
+
Box,
|
|
1428
|
+
{ flexDirection: "row" },
|
|
1429
|
+
e(
|
|
1430
|
+
Box,
|
|
1431
|
+
{ width: FETCHING_WIDTH, flexShrink: 0 },
|
|
1432
|
+
// Pinned to the resting frame when idle: the counter also drives the run
|
|
1433
|
+
// icons, so borrowing it here would set this turning while a workflow
|
|
1434
|
+
// executes -- saying "fetching" at a moment when nothing is being
|
|
1435
|
+
// fetched.
|
|
1436
|
+
e(
|
|
1437
|
+
Text,
|
|
1438
|
+
{ color: fetching ? TITLE_COLOR : undefined, dimColor: !fetching },
|
|
1439
|
+
`${fetching && spin ? spin : SPINNER[0]} Fetching`,
|
|
1440
|
+
),
|
|
1441
|
+
),
|
|
1442
|
+
e(
|
|
1443
|
+
Box,
|
|
1444
|
+
{ width: STALE_WIDTH, flexShrink: 0 },
|
|
1445
|
+
stale ? e(Text, { color: ATTENTION }, stale) : null,
|
|
1446
|
+
),
|
|
1447
|
+
...hints
|
|
1448
|
+
.flatMap((hint, i) => [
|
|
1449
|
+
i > 0 && e(Text, { key: `sep${i}`, color: BORDER_COLOR }, " | "),
|
|
1450
|
+
e(
|
|
1451
|
+
Text,
|
|
1452
|
+
{ key: hint.label, wrap: "truncate-end" },
|
|
1453
|
+
e(Text, { dimColor: true }, `${hint.label}: `),
|
|
1454
|
+
e(Text, { color: TITLE_COLOR, bold: true }, hint.keys),
|
|
1455
|
+
),
|
|
1456
|
+
])
|
|
1457
|
+
.filter(Boolean),
|
|
1458
|
+
);
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// ---------- Layout ----------
|
|
1462
|
+
|
|
1463
|
+
// Terminal size, and the label breakpoint that depends on it.
|
|
1464
|
+
//
|
|
1465
|
+
// The usableSize guard is applied on every read path, not just the first: pty
|
|
1466
|
+
// wrappers and a terminal mid-resize report 0 or undefined, and taking either
|
|
1467
|
+
// literally collapses the table. ink's own useWindowSize does not apply that
|
|
1468
|
+
// fallback, which is why this stays hand-rolled.
|
|
1469
|
+
function useTerminalSize(stdout) {
|
|
1470
|
+
const [size, setSize] = useState(() => ({
|
|
1471
|
+
rows: usableSize(stdout?.rows, DEFAULT_ROWS),
|
|
1472
|
+
cols: usableSize(stdout?.columns, DEFAULT_COLS),
|
|
1473
|
+
useShortLabels: usableSize(stdout?.columns, DEFAULT_COLS) < TAB_LABEL_FULL_WIDTH,
|
|
1474
|
+
}));
|
|
1475
|
+
|
|
1476
|
+
useEffect(() => {
|
|
1477
|
+
if (!stdout) return;
|
|
1478
|
+
function onResize() {
|
|
1479
|
+
const cols = usableSize(stdout.columns, DEFAULT_COLS);
|
|
1480
|
+
const rows = usableSize(stdout.rows, DEFAULT_ROWS);
|
|
1481
|
+
setSize((previous) => {
|
|
1482
|
+
// Hysteresis on the label breakpoint: switch to short labels below it,
|
|
1483
|
+
// back to full only once comfortably above, so a pane dragged along the
|
|
1484
|
+
// boundary does not emit a different frame on every resize event.
|
|
1485
|
+
const useShortLabels = previous.useShortLabels
|
|
1486
|
+
? cols < TAB_LABEL_FULL_WIDTH + TAB_LABEL_HYSTERESIS
|
|
1487
|
+
: cols < TAB_LABEL_FULL_WIDTH;
|
|
1488
|
+
// Same object when nothing moved, so a resize event that changes
|
|
1489
|
+
// nothing cannot cost a redraw.
|
|
1490
|
+
return previous.rows === rows &&
|
|
1491
|
+
previous.cols === cols &&
|
|
1492
|
+
previous.useShortLabels === useShortLabels
|
|
1493
|
+
? previous
|
|
1494
|
+
: { rows, cols, useShortLabels };
|
|
1495
|
+
});
|
|
1496
|
+
}
|
|
1497
|
+
stdout.on("resize", onResize);
|
|
1498
|
+
return () => stdout.off("resize", onResize);
|
|
1499
|
+
}, [stdout]);
|
|
1500
|
+
|
|
1501
|
+
return size;
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
// ---------- App ----------
|
|
1505
|
+
|
|
1506
|
+
function App() {
|
|
1507
|
+
const { stdout } = useStdout();
|
|
1508
|
+
const { isRawModeSupported } = useStdin();
|
|
1509
|
+
const { exit } = useApp();
|
|
1510
|
+
const [activeIndex, setActiveIndex] = useState(runtime.initialTabIndex);
|
|
1511
|
+
// `null` means "never resolved" -- distinct from `[]`, which means "resolved
|
|
1512
|
+
// and genuinely empty". The tab bar and the body render those differently.
|
|
1513
|
+
const [data, setData] = useState({ actions: null, issues: null, prs: null, security: null });
|
|
1514
|
+
const [meta, setMeta] = useState({
|
|
1515
|
+
actions: null,
|
|
1516
|
+
issues: null,
|
|
1517
|
+
prs: null,
|
|
1518
|
+
security: null,
|
|
1519
|
+
});
|
|
1520
|
+
const [securityNotes, setSecurityNotes] = useState([]);
|
|
1521
|
+
const [errors, setErrors] = useState({ actions: null, issues: null, prs: null, security: null });
|
|
1522
|
+
const [loading, setLoading] = useState({ actions: true, issues: true, prs: true, security: true });
|
|
1523
|
+
const [now, setNow] = useState(new Date());
|
|
1524
|
+
const { rows, cols, useShortLabels } = useTerminalSize(stdout);
|
|
1525
|
+
const [frame, setFrame] = useState(0);
|
|
1526
|
+
// Per tab, so switching away and back keeps your place. Keyed by item, and
|
|
1527
|
+
// both are plain state: they change only on a keypress, so an idle repo still
|
|
1528
|
+
// renders byte-identical frames and ink still writes nothing.
|
|
1529
|
+
const [selected, setSelected] = useState({});
|
|
1530
|
+
const [offset, setOffset] = useState({});
|
|
1531
|
+
|
|
1532
|
+
const tab = TABS[activeIndex];
|
|
1533
|
+
const extraLines = (errors[tab.key] ? 1 : 0) + (tab.key === "security" ? securityNotes.length : 0);
|
|
1534
|
+
// Reserve lines for: the panel's top and bottom edges (2), the column header
|
|
1535
|
+
// and its separator (2), the tab bar (1) and the status line (1), plus a
|
|
1536
|
+
// 1-line safety margin. Slack is absorbed by the spacer in the tree below.
|
|
1537
|
+
const bodyRows = Math.max(1, rows - 7 - extraLines);
|
|
1538
|
+
|
|
1539
|
+
// Read at fetch time rather than being a hook dependency, so dragging the
|
|
1540
|
+
// pane wider doesn't cancel and restart in-flight requests -- the next tick
|
|
1541
|
+
// simply asks for the new size.
|
|
1542
|
+
const runLimitRef = useRef(0);
|
|
1543
|
+
runLimitRef.current = Math.min(Math.max(bodyRows + 1, MIN_RUN_LIMIT), MAX_RUN_LIMIT);
|
|
1544
|
+
|
|
1545
|
+
// Read inside the polling closure, which is created once on mount and must
|
|
1546
|
+
// not be torn down and rebuilt every time you press a tab key.
|
|
1547
|
+
const activeIndexRef = useRef(activeIndex);
|
|
1548
|
+
activeIndexRef.current = activeIndex;
|
|
1549
|
+
|
|
1550
|
+
// An unfinished run's elapsed time is the only thing on screen that changes
|
|
1551
|
+
// faster than once a minute, so it decides how often `now` has to advance.
|
|
1552
|
+
const hasInProgressRef = useRef(false);
|
|
1553
|
+
hasInProgressRef.current = (data.actions ?? []).some((r) => r.status !== "completed");
|
|
1554
|
+
|
|
1555
|
+
// Queued runs don't spin -- standing still is how they read as queued -- and
|
|
1556
|
+
// the icons only exist on the Actions tab, so a run turning behind another
|
|
1557
|
+
// tab would be ten redraws a second nobody can see.
|
|
1558
|
+
const hasRunningVisible =
|
|
1559
|
+
ANIMATE && tab.key === "actions" && (data.actions ?? []).some((r) => r.status === "in_progress");
|
|
1560
|
+
|
|
1561
|
+
const inFlightRef = useRef({});
|
|
1562
|
+
const rawRef = useRef({});
|
|
1563
|
+
const cleanRef = useRef({});
|
|
1564
|
+
const fetchTabRef = useRef(null);
|
|
1565
|
+
const abortRef = useRef(null);
|
|
1566
|
+
|
|
1567
|
+
const interactive = Boolean(isRawModeSupported);
|
|
1568
|
+
|
|
1569
|
+
// useInput's handler is created before the render body computes the visible
|
|
1570
|
+
// slice, so the movement handlers read through a ref -- the same pattern the
|
|
1571
|
+
// poll loop uses for runLimitRef and activeIndexRef, and for the same reason:
|
|
1572
|
+
// the closure must see current values without being rebuilt on every change.
|
|
1573
|
+
const navRef = useRef({ items: [], key: null, bodyRows: 1, tabKey: "actions" });
|
|
1574
|
+
navRef.current = {
|
|
1575
|
+
items: data[tab.key] ?? [],
|
|
1576
|
+
key: selected[tab.key] ?? null,
|
|
1577
|
+
bodyRows,
|
|
1578
|
+
tabKey: tab.key,
|
|
1579
|
+
};
|
|
1580
|
+
const pageStep = Math.max(1, bodyRows - 1);
|
|
1581
|
+
|
|
1582
|
+
const abortSelectionRef = useRef(null);
|
|
1583
|
+
|
|
1584
|
+
function moveSelection(delta) {
|
|
1585
|
+
const { items, key: currentKey, bodyRows: rows_, tabKey } = navRef.current;
|
|
1586
|
+
if (items.length === 0) return;
|
|
1587
|
+
const current = currentKey == null ? -1 : items.findIndex((i) => itemKey(i) === currentKey);
|
|
1588
|
+
// From "nothing selected", down lands on the first row and up on the last,
|
|
1589
|
+
// which is what every list in this category does.
|
|
1590
|
+
const next =
|
|
1591
|
+
current === -1
|
|
1592
|
+
? delta > 0
|
|
1593
|
+
? 0
|
|
1594
|
+
: items.length - 1
|
|
1595
|
+
: Math.min(items.length - 1, Math.max(0, current + delta));
|
|
1596
|
+
setSelected((s) => ({ ...s, [tabKey]: itemKey(items[next]) }));
|
|
1597
|
+
// Keep the cursor on screen. Only the offset needed to reveal it changes,
|
|
1598
|
+
// so scrolling never jumps further than it has to.
|
|
1599
|
+
setOffset((o) => {
|
|
1600
|
+
const start = o[tabKey] ?? 0;
|
|
1601
|
+
const maxStart = Math.max(0, items.length - rows_);
|
|
1602
|
+
let nextStart = Math.min(start, maxStart);
|
|
1603
|
+
if (next < nextStart) nextStart = next;
|
|
1604
|
+
else if (next >= nextStart + rows_) nextStart = next - rows_ + 1;
|
|
1605
|
+
return nextStart === (o[tabKey] ?? 0) ? o : { ...o, [tabKey]: nextStart };
|
|
1606
|
+
});
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
function openSelected() {
|
|
1610
|
+
const { items, key: currentKey, tabKey } = navRef.current;
|
|
1611
|
+
const item = items.find((i) => itemKey(i) === currentKey);
|
|
1612
|
+
if (!item) return;
|
|
1613
|
+
const controller = new AbortController();
|
|
1614
|
+
abortSelectionRef.current = controller;
|
|
1615
|
+
// Fire and forget: a browser launch must not block the render loop, and a
|
|
1616
|
+
// failure surfaces through the tab's normal error line rather than as an
|
|
1617
|
+
// unhandled rejection.
|
|
1618
|
+
openInBrowser(tabKey, item, controller.signal).catch((err) => {
|
|
1619
|
+
setErrors((x) => ({ ...x, [tabKey]: shortErr(err) }));
|
|
1620
|
+
});
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
useInput(
|
|
1624
|
+
(input, key) => {
|
|
1625
|
+
if (input === "q" || key.escape) {
|
|
1626
|
+
exit();
|
|
1627
|
+
} else if (key.downArrow || input === "j") {
|
|
1628
|
+
moveSelection(1);
|
|
1629
|
+
} else if (key.upArrow || input === "k") {
|
|
1630
|
+
moveSelection(-1);
|
|
1631
|
+
} else if (key.pageDown) {
|
|
1632
|
+
moveSelection(pageStep);
|
|
1633
|
+
} else if (key.pageUp) {
|
|
1634
|
+
moveSelection(-pageStep);
|
|
1635
|
+
} else if (key.return) {
|
|
1636
|
+
openSelected();
|
|
1637
|
+
} else if (input === "r") {
|
|
1638
|
+
// Goes through the same per-tab in-flight guard as the poll loop, so
|
|
1639
|
+
// holding the key down cannot stack concurrent subprocesses.
|
|
1640
|
+
fetchTabRef.current?.(TABS[activeIndexRef.current].key);
|
|
1641
|
+
} else if (input >= "1" && input <= String(TABS.length)) {
|
|
1642
|
+
setActiveIndex(Number(input) - 1);
|
|
1643
|
+
} else if (key.tab && key.shift) {
|
|
1644
|
+
setActiveIndex((i) => (i - 1 + TABS.length) % TABS.length);
|
|
1645
|
+
} else if (key.tab || key.rightArrow) {
|
|
1646
|
+
setActiveIndex((i) => (i + 1) % TABS.length);
|
|
1647
|
+
} else if (key.leftArrow) {
|
|
1648
|
+
setActiveIndex((i) => (i - 1 + TABS.length) % TABS.length);
|
|
1649
|
+
}
|
|
1650
|
+
},
|
|
1651
|
+
// `isActive` has to be coerced: ink only skips raw mode when the flag is
|
|
1652
|
+
// strictly `false`, and Node reports `stdin.isTTY` as `undefined` -- not
|
|
1653
|
+
// `false` -- when stdin isn't a terminal. Passing the raw value through
|
|
1654
|
+
// would let ink call setRawMode() on a non-TTY stdin and throw on startup.
|
|
1655
|
+
{ isActive: interactive },
|
|
1656
|
+
);
|
|
1657
|
+
|
|
1658
|
+
// Deliberately mount-only. Every value this closure needs is read through a
|
|
1659
|
+
// ref (runLimitRef, activeIndexRef, hasInProgressRef) precisely so the
|
|
1660
|
+
// interval is created exactly once. Adding `activeIndex`, `bodyRows` or
|
|
1661
|
+
// `data` to the dependency array would tear down and rebuild the poll loop on
|
|
1662
|
+
// every tab keypress and every terminal resize, cancelling in-flight requests
|
|
1663
|
+
// -- the behaviour the per-tab guard and the fetch-time limit read were
|
|
1664
|
+
// written to prevent. Do not "fix" this by adding them.
|
|
1665
|
+
//
|
|
1666
|
+
// exhaustive-deps is satisfied as written -- every captured value is either a
|
|
1667
|
+
// ref or a setState function, both of which the rule treats as stable -- so
|
|
1668
|
+
// this needs no suppression, and if one ever becomes necessary that is the
|
|
1669
|
+
// signal that a real dependency crept in.
|
|
1670
|
+
useEffect(() => {
|
|
1671
|
+
let cancelled = false;
|
|
1672
|
+
let ticks = 0;
|
|
1673
|
+
const controller = new AbortController();
|
|
1674
|
+
abortRef.current = controller;
|
|
1675
|
+
|
|
1676
|
+
// Each tab commits its own result the moment it lands instead of waiting on
|
|
1677
|
+
// a Promise.allSettled barrier. Actions is by far the slowest fetch, so
|
|
1678
|
+
// barrelling everything together meant the three fast tabs sat invisible
|
|
1679
|
+
// behind it and nothing at all appeared until the slowest call returned.
|
|
1680
|
+
function commit(key, run) {
|
|
1681
|
+
// Per-tab rather than one flag for the whole tick, so switching tabs can
|
|
1682
|
+
// refresh the tab you just landed on without waiting on an unrelated
|
|
1683
|
+
// background fetch -- and so a slow repo can't stack refreshes.
|
|
1684
|
+
if (inFlightRef.current[key]) return Promise.resolve();
|
|
1685
|
+
inFlightRef.current[key] = true;
|
|
1686
|
+
setLoading((l) => (l[key] ? l : { ...l, [key]: true }));
|
|
1687
|
+
return run()
|
|
1688
|
+
.then((result) => {
|
|
1689
|
+
if (cancelled) return;
|
|
1690
|
+
const { raw, parse, limit } = result;
|
|
1691
|
+
// Identical payload: skip the parse *and* the state update. Returning
|
|
1692
|
+
// the same state object makes React bail out of the re-render, so an
|
|
1693
|
+
// idle repo stops redrawing the pane entirely.
|
|
1694
|
+
//
|
|
1695
|
+
// The cache write used to happen *before* parse(), so a payload that
|
|
1696
|
+
// threw was still cached -- and on the next tick the error was cleared
|
|
1697
|
+
// at the top of this handler and the early return fired before parse()
|
|
1698
|
+
// could be retried, leaving the tab permanently showing "no runs" with
|
|
1699
|
+
// no error at all. Both now happen only after a successful parse.
|
|
1700
|
+
if (rawRef.current[key] === raw && cleanRef.current[key]) {
|
|
1701
|
+
setErrors((x) => (x[key] === null ? x : { ...x, [key]: null }));
|
|
1702
|
+
return;
|
|
1703
|
+
}
|
|
1704
|
+
const value = parse();
|
|
1705
|
+
rawRef.current[key] = raw;
|
|
1706
|
+
cleanRef.current[key] = true;
|
|
1707
|
+
setErrors((x) => (x[key] === null ? x : { ...x, [key]: null }));
|
|
1708
|
+
setData((d) => ({ ...d, [key]: value.alerts ?? value }));
|
|
1709
|
+
setMeta((m) => ({
|
|
1710
|
+
...m,
|
|
1711
|
+
[key]: {
|
|
1712
|
+
at: Date.now(),
|
|
1713
|
+
// Compare against the limit this payload was actually fetched
|
|
1714
|
+
// with. Comparing against the live pane-height ref made the "n+"
|
|
1715
|
+
// marker lie for one cycle after every resize.
|
|
1716
|
+
truncated: value.truncated ?? (value.alerts ?? value).length >= limit,
|
|
1717
|
+
},
|
|
1718
|
+
}));
|
|
1719
|
+
if (value.notes) setSecurityNotes(value.notes);
|
|
1720
|
+
})
|
|
1721
|
+
.catch((err) => {
|
|
1722
|
+
if (cancelled || err?.name === "AbortError") return;
|
|
1723
|
+
cleanRef.current[key] = false;
|
|
1724
|
+
setErrors((x) => ({ ...x, [key]: shortErr(err) }));
|
|
1725
|
+
})
|
|
1726
|
+
.finally(() => {
|
|
1727
|
+
inFlightRef.current[key] = false;
|
|
1728
|
+
if (!cancelled) setLoading((l) => (l[key] ? { ...l, [key]: false } : l));
|
|
1729
|
+
});
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
function fetchTab(key) {
|
|
1733
|
+
const signal = controller.signal;
|
|
1734
|
+
const descriptor = TABS.find((t) => t.key === key);
|
|
1735
|
+
// Every tab carries its own fetcher on the registry, so adding a tab is a
|
|
1736
|
+
// TABS entry plus a fetcher rather than an entry plus an edit to a chain
|
|
1737
|
+
// in a different part of the file.
|
|
1738
|
+
return commit(key, () => descriptor.fetch({ signal, runLimit: runLimitRef.current }));
|
|
1739
|
+
}
|
|
1740
|
+
fetchTabRef.current = fetchTab;
|
|
1741
|
+
|
|
1742
|
+
async function tick() {
|
|
1743
|
+
// Only the tab you're looking at needs to keep up with REFRESH_MS. The
|
|
1744
|
+
// other three exist to keep the tab-bar counts honest, which tolerates
|
|
1745
|
+
// being a minute behind -- so they refresh every BACKGROUND_EVERY ticks
|
|
1746
|
+
// instead, which is what keeps steady-state API usage off the rate limit.
|
|
1747
|
+
const active = TABS[activeIndexRef.current].key;
|
|
1748
|
+
const due = ticks % BACKGROUND_EVERY === 0 ? TABS.map((t) => t.key) : [active];
|
|
1749
|
+
ticks += 1;
|
|
1750
|
+
await Promise.allSettled(due.map(fetchTab));
|
|
1751
|
+
if (cancelled) return;
|
|
1752
|
+
// Advancing `now` is what forces a redraw, so only do it when it can
|
|
1753
|
+
// actually change what's on screen: durations of in-progress runs tick
|
|
1754
|
+
// every second, but every other age is minute-granular.
|
|
1755
|
+
setNow((prev) =>
|
|
1756
|
+
hasInProgressRef.current || Date.now() - prev.getTime() >= 60_000 ? new Date() : prev,
|
|
1757
|
+
);
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
tick();
|
|
1761
|
+
const id = setInterval(tick, runtime.refreshMs);
|
|
1762
|
+
return () => {
|
|
1763
|
+
cancelled = true;
|
|
1764
|
+
clearInterval(id);
|
|
1765
|
+
// `cancelled` stops state updates from a promise that already resolved;
|
|
1766
|
+
// the signal stops the subprocess itself, so quitting doesn't orphan up to
|
|
1767
|
+
// six `gh` children mid-request. They cover different windows and both are
|
|
1768
|
+
// needed.
|
|
1769
|
+
controller.abort();
|
|
1770
|
+
};
|
|
1771
|
+
}, []);
|
|
1772
|
+
|
|
1773
|
+
// Background tabs can be up to BACKGROUND_EVERY ticks stale, so the tab you
|
|
1774
|
+
// switch to refreshes straight away rather than showing old data until its
|
|
1775
|
+
// slot next comes round. On mount this is a no-op: the initial tick already
|
|
1776
|
+
// has every tab in flight, and the per-tab guard rejects the duplicate.
|
|
1777
|
+
useEffect(() => {
|
|
1778
|
+
fetchTabRef.current?.(TABS[activeIndex].key);
|
|
1779
|
+
}, [activeIndex]);
|
|
1780
|
+
|
|
1781
|
+
// Animate only when something on screen is genuinely moving. `loading` flips
|
|
1782
|
+
// true then false on *every* tick, including one that changed nothing, so
|
|
1783
|
+
// driving the spinner from it meant the 100ms interval restarted every 5
|
|
1784
|
+
// seconds forever -- roughly 44MB of terminal writes and 27 CPU-seconds per
|
|
1785
|
+
// idle hour, and the exact opposite of the redraw suppression above. The
|
|
1786
|
+
// first load genuinely is worth animating, because an empty pane with no
|
|
1787
|
+
// motion reads as broken; after that, only a run actually executing is.
|
|
1788
|
+
const firstLoad = Object.fromEntries(TABS.map((t) => [t.key, data[t.key] == null]));
|
|
1789
|
+
const anyFirstLoad = Object.values(firstLoad).some(Boolean);
|
|
1790
|
+
const showSpinner = ANIMATE && (anyFirstLoad || hasRunningVisible);
|
|
1791
|
+
useEffect(() => {
|
|
1792
|
+
if (!showSpinner) {
|
|
1793
|
+
// Park on a fixed frame rather than freezing wherever the animation
|
|
1794
|
+
// happened to stop: the resting glyph is then the same every time, so
|
|
1795
|
+
// consecutive idle frames stay byte-identical and Ink writes nothing.
|
|
1796
|
+
setFrame(0);
|
|
1797
|
+
return;
|
|
1798
|
+
}
|
|
1799
|
+
const id = setInterval(() => setFrame((f) => (f + 1) % SPINNER.length), SPINNER_MS);
|
|
1800
|
+
return () => clearInterval(id);
|
|
1801
|
+
}, [showSpinner]);
|
|
1802
|
+
|
|
1803
|
+
const items = data[tab.key];
|
|
1804
|
+
const error = errors[tab.key];
|
|
1805
|
+
const spin = SPINNER[frame % SPINNER.length];
|
|
1806
|
+
|
|
1807
|
+
const counts = Object.fromEntries(
|
|
1808
|
+
TABS.map((t) => {
|
|
1809
|
+
const list = data[t.key];
|
|
1810
|
+
if (list == null) return [t.key, null];
|
|
1811
|
+
// Every tab can be truncated, not just Actions: issues and PRs cap at
|
|
1812
|
+
// LIST_LIMIT and alerts at one page of 100. Reporting those as exact made
|
|
1813
|
+
// the count stop moving through a genuine change on any repo big enough
|
|
1814
|
+
// for this tool to matter.
|
|
1815
|
+
const suffix = meta[t.key]?.truncated ? "+" : "";
|
|
1816
|
+
// Newest first, so the head of the list is the run that decides whether
|
|
1817
|
+
// CI is currently red.
|
|
1818
|
+
const broken =
|
|
1819
|
+
t.key === "actions" &&
|
|
1820
|
+
list.length > 0 &&
|
|
1821
|
+
list[0].status === "completed" &&
|
|
1822
|
+
list[0].conclusion !== "success" &&
|
|
1823
|
+
list[0].conclusion !== "skipped";
|
|
1824
|
+
return [t.key, `${list.length}${suffix}${broken ? "!" : ""}`];
|
|
1825
|
+
}),
|
|
1826
|
+
);
|
|
1827
|
+
const failed = Object.fromEntries(TABS.map((t) => [t.key, Boolean(errors[t.key])]));
|
|
1828
|
+
|
|
1829
|
+
// Data can be arbitrarily old without anything on screen saying so: a failing
|
|
1830
|
+
// poll only surfaces on the tab you have selected, and after a laptop sleep
|
|
1831
|
+
// every pane is plausible and wrong. Threshold-gated and minute-granular on
|
|
1832
|
+
// purpose -- see STALE_AFTER_MS.
|
|
1833
|
+
const lastOk = meta[tab.key]?.at;
|
|
1834
|
+
const staleFor = lastOk == null ? null : now.getTime() - lastOk;
|
|
1835
|
+
const staleThreshold = Math.max(STALE_AFTER_MS, runtime.refreshMs * 6);
|
|
1836
|
+
const staleLabel =
|
|
1837
|
+
staleFor != null && staleFor > staleThreshold
|
|
1838
|
+
? `stale ${formatDuration(Math.min(staleFor, 359_999_000))}`
|
|
1839
|
+
: null;
|
|
1840
|
+
|
|
1841
|
+
const allItems = items ?? [];
|
|
1842
|
+
const tabOffsetRaw = offset[tab.key] ?? 0;
|
|
1843
|
+
// Re-clamped on every render rather than only on resize: the payload can
|
|
1844
|
+
// shrink under us between ticks, and a stale offset would render an empty
|
|
1845
|
+
// body while the count in the frame said otherwise.
|
|
1846
|
+
const maxOffset = Math.max(0, allItems.length - bodyRows);
|
|
1847
|
+
const tabOffset = Math.min(tabOffsetRaw, maxOffset);
|
|
1848
|
+
const visibleItems = allItems.slice(tabOffset, tabOffset + bodyRows);
|
|
1849
|
+
|
|
1850
|
+
// Matched by key, never by position. If the selected item is gone -- closed,
|
|
1851
|
+
// merged, or aged out of the fetch window -- no row matches and nothing is
|
|
1852
|
+
// highlighted, which is the honest state; the next arrow key selects from the
|
|
1853
|
+
// top again. Resolving to a neighbouring index instead would silently move
|
|
1854
|
+
// the cursor onto an unrelated row.
|
|
1855
|
+
const selectedKey = selected[tab.key] ?? null;
|
|
1856
|
+
// Bottom-right of the frame, lazygit style: how much of the tab you can
|
|
1857
|
+
// currently see out of how much there is.
|
|
1858
|
+
const countLabel =
|
|
1859
|
+
items == null
|
|
1860
|
+
? null
|
|
1861
|
+
: tabOffset > 0
|
|
1862
|
+
? `${tabOffset + 1}-${tabOffset + visibleItems.length} of ${counts[tab.key]}`
|
|
1863
|
+
: `${visibleItems.length} of ${counts[tab.key]}`;
|
|
1864
|
+
|
|
1865
|
+
// Fixed columns deliberately do not shrink, so BRANCH and TIME stay readable
|
|
1866
|
+
// at ordinary widths. Narrow panes drop columns instead, which keeps the
|
|
1867
|
+
// frame, the tab bar and the quit hint on screen -- the previous behaviour
|
|
1868
|
+
// pushed all three off the bottom.
|
|
1869
|
+
const compact = cols < MIN_TABLE_WIDTH;
|
|
1870
|
+
const header = compact ? tab.compactHeader : tab.header;
|
|
1871
|
+
|
|
1872
|
+
return e(
|
|
1873
|
+
Box,
|
|
1874
|
+
{ flexDirection: "column", width: "100%", height: Math.min(rows, usableSize(stdout?.rows, rows)) },
|
|
1875
|
+
e(PanelEdge, { width: cols, top: true, label: tab.label, labelColor: TITLE_COLOR }),
|
|
1876
|
+
e(
|
|
1877
|
+
Box,
|
|
1878
|
+
{
|
|
1879
|
+
flexDirection: "column",
|
|
1880
|
+
flexGrow: 1,
|
|
1881
|
+
paddingX: 1,
|
|
1882
|
+
// Only the verticals: the labelled edges above and below are drawn as
|
|
1883
|
+
// text, and a border here too would double them up.
|
|
1884
|
+
borderStyle: "round",
|
|
1885
|
+
borderColor: BORDER_COLOR,
|
|
1886
|
+
borderTop: false,
|
|
1887
|
+
borderBottom: false,
|
|
1888
|
+
},
|
|
1889
|
+
// truncate-end is what makes the one-line reservation in `extraLines`
|
|
1890
|
+
// true by construction. Predicting the wrapped height instead would mean
|
|
1891
|
+
// duplicating ink's width model, and would still be wrong on resize.
|
|
1892
|
+
error && e(Text, { color: ERROR_TEXT, wrap: "truncate-end" }, error),
|
|
1893
|
+
tab.key === "security" &&
|
|
1894
|
+
securityNotes.map((note, i) =>
|
|
1895
|
+
e(Text, { key: i, dimColor: true, wrap: "truncate-end" }, note),
|
|
1896
|
+
),
|
|
1897
|
+
e(HeaderCells, { cells: header }),
|
|
1898
|
+
...visibleItems.map((item) => {
|
|
1899
|
+
const key = itemKey(item);
|
|
1900
|
+
return e(
|
|
1901
|
+
RowBoundary,
|
|
1902
|
+
{ key, resetKey: key },
|
|
1903
|
+
e(tab.Row, {
|
|
1904
|
+
item,
|
|
1905
|
+
now,
|
|
1906
|
+
compact,
|
|
1907
|
+
cursor: key === selectedKey,
|
|
1908
|
+
// Only the Actions rows read this. Passing it to the others would
|
|
1909
|
+
// change a prop ten times a second on every tab and defeat the
|
|
1910
|
+
// memoisation below for no visible effect.
|
|
1911
|
+
spin: tab.key === "actions" && showSpinner ? spin : null,
|
|
1912
|
+
}),
|
|
1913
|
+
);
|
|
1914
|
+
}),
|
|
1915
|
+
// Distinguishes "still fetching" from "resolved and empty" in the body,
|
|
1916
|
+
// which is the difference between a dashboard that looks hung and one
|
|
1917
|
+
// that looks correct. Driven by "never resolved" rather than by the
|
|
1918
|
+
// loading flag, which toggles on every tick and made a settled-empty tab
|
|
1919
|
+
// swap its message every five seconds.
|
|
1920
|
+
visibleItems.length === 0 &&
|
|
1921
|
+
e(
|
|
1922
|
+
Text,
|
|
1923
|
+
{ dimColor: true },
|
|
1924
|
+
firstLoad[tab.key]
|
|
1925
|
+
? `${showSpinner ? spin : SPINNER[0]} loading ${tab.label.toLowerCase()}…`
|
|
1926
|
+
: tab.key === "security" && securityNotes.length === ALERT_SOURCES.length
|
|
1927
|
+
? "no alert sources available"
|
|
1928
|
+
: `no ${tab.countLabel}`,
|
|
1929
|
+
),
|
|
1930
|
+
// Pushes the panel's bottom edge down to the foot of the pane, so the
|
|
1931
|
+
// frame stays put instead of closing up under the column headers on a
|
|
1932
|
+
// tab that only has a handful of rows.
|
|
1933
|
+
e(Box, { flexGrow: 1 }),
|
|
1934
|
+
),
|
|
1935
|
+
e(PanelEdge, { width: cols, top: false, label: countLabel, labelColor: BORDER_COLOR }),
|
|
1936
|
+
e(TabBar, {
|
|
1937
|
+
activeIndex,
|
|
1938
|
+
counts,
|
|
1939
|
+
firstLoad,
|
|
1940
|
+
failed,
|
|
1941
|
+
spin,
|
|
1942
|
+
useShort: useShortLabels,
|
|
1943
|
+
}),
|
|
1944
|
+
e(StatusBar, {
|
|
1945
|
+
fetching: Object.values(loading).some(Boolean),
|
|
1946
|
+
spin: showSpinner ? spin : null,
|
|
1947
|
+
stale: staleLabel,
|
|
1948
|
+
interactive,
|
|
1949
|
+
}),
|
|
1950
|
+
);
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
// ---------- Terminal lifecycle ----------
|
|
1954
|
+
|
|
1955
|
+
// Enter the alternate screen buffer, same as lazygit/htop/vim, so the shell
|
|
1956
|
+
// prompt and the command that launched this script scroll out of view instead
|
|
1957
|
+
// of sitting above the dashboard.
|
|
1958
|
+
function enterAlternateScreen() {
|
|
1959
|
+
process.stdout.write("\x1b[?1049h\x1b[2J\x1b[H");
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
// Idempotent by construction: writing the exit sequence while already on the
|
|
1963
|
+
// primary buffer is a no-op, which matters because this runs both from the
|
|
1964
|
+
// crash handlers and from the `exit` listener. `?25h` is here because ink only
|
|
1965
|
+
// restores cursor visibility through its own unmount path, which an explicit
|
|
1966
|
+
// process.exit() skips.
|
|
1967
|
+
let screenRestored = false;
|
|
1968
|
+
function restoreScreen() {
|
|
1969
|
+
if (screenRestored) return;
|
|
1970
|
+
screenRestored = true;
|
|
1971
|
+
process.stdout.write("\x1b[?25h\x1b[?1049l");
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
// A crash used to be indistinguishable from a clean quit. Ink catches render
|
|
1975
|
+
// errors and draws them -- into the alternate screen, which the exit handler
|
|
1976
|
+
// then discarded -- and nothing ever set a non-zero exit code, so the dashboard
|
|
1977
|
+
// simply vanished and any wrapper saw success. Restore the primary buffer
|
|
1978
|
+
// *first*, then write, or the fix reproduces the problem it is fixing.
|
|
1979
|
+
function installCrashHandlers() {
|
|
1980
|
+
const fail = (label) => (err) => {
|
|
1981
|
+
restoreScreen();
|
|
1982
|
+
console.error(`gh-glance: ${label}`);
|
|
1983
|
+
console.error(err instanceof Error ? (err.stack ?? err.message) : String(err));
|
|
1984
|
+
process.exit(1);
|
|
1985
|
+
};
|
|
1986
|
+
process.on("uncaughtException", fail("crashed"));
|
|
1987
|
+
process.on("unhandledRejection", fail("unhandled promise rejection"));
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
if (IS_MAIN) {
|
|
1991
|
+
disarmDevBuildLeak();
|
|
1992
|
+
installCrashHandlers();
|
|
1993
|
+
enterAlternateScreen();
|
|
1994
|
+
process.on("exit", restoreScreen);
|
|
1995
|
+
|
|
1996
|
+
// Ink's default renderer erases and rewrites the whole viewport on every
|
|
1997
|
+
// change; incremental mode updates only the lines that differ. Measured on a
|
|
1998
|
+
// settled 80x24 pane: 13,918 bytes of terminal traffic down to the figure in
|
|
1999
|
+
// the commit message.
|
|
2000
|
+
//
|
|
2001
|
+
// PE-M1 flagged the risk: ink's own source notes a Windows-console desync for
|
|
2002
|
+
// frames that exactly fill the viewport, which this app always does since the
|
|
2003
|
+
// root box is the terminal height. Windows is already documented as untested
|
|
2004
|
+
// (README Limitations), and the pty harness covers the two platforms that are
|
|
2005
|
+
// supported, so the flag is verified where it is claimed to work.
|
|
2006
|
+
const app = render(e(App), { incrementalRendering: true });
|
|
2007
|
+
|
|
2008
|
+
// 128 + signal number, so a supervisor or `timeout` can tell an interrupted
|
|
2009
|
+
// run from a clean one. These fire on external `kill` and when raw mode is
|
|
2010
|
+
// unavailable; the ordinary Ctrl+C path goes through ink's own handler.
|
|
2011
|
+
//
|
|
2012
|
+
// Unmounting before restoring is load-bearing, not tidiness. signal-exit runs
|
|
2013
|
+
// our own `exit` listener ahead of ink's teardown, so simply exiting here let
|
|
2014
|
+
// restoreScreen hand the terminal back to the primary buffer *first* and ink
|
|
2015
|
+
// then repainted onto it -- and because the root Box is `height: rows` the
|
|
2016
|
+
// frame always exactly fills the viewport, so that repaint takes ink's
|
|
2017
|
+
// `isUnmounting && previousOutputHeight >= viewportRows` branch and is
|
|
2018
|
+
// preceded by \x1b[2J\x1b[3J. \x1b[3J erases the scrollback, so a `kill` threw
|
|
2019
|
+
// away the user's terminal history and left a dead dashboard behind it
|
|
2020
|
+
// (measured: 2,728 bytes on an 80x24 pane).
|
|
2021
|
+
//
|
|
2022
|
+
// Unmounting first puts that final repaint inside the alternate screen, where
|
|
2023
|
+
// restoreScreen discards it -- which is the ordering the q/Esc path already
|
|
2024
|
+
// gets for free through ink's own handleExit. ink's final layout and render
|
|
2025
|
+
// are synchronous, so the repaint has landed by the time we restore, and
|
|
2026
|
+
// exiting immediately afterwards keeps the prompt-exit guarantee: waiting for
|
|
2027
|
+
// the event loop to drain would let a hung `gh` turn Ctrl+C into an apparent
|
|
2028
|
+
// hang.
|
|
2029
|
+
const bySignal = (code) => () => {
|
|
2030
|
+
try {
|
|
2031
|
+
app.unmount();
|
|
2032
|
+
} catch {
|
|
2033
|
+
// Teardown is best effort. restoreScreen below, and the `exit` listener
|
|
2034
|
+
// above, both still run, so the terminal is handed back either way.
|
|
2035
|
+
}
|
|
2036
|
+
restoreScreen();
|
|
2037
|
+
process.exit(code);
|
|
2038
|
+
};
|
|
2039
|
+
process.on("SIGINT", bySignal(130));
|
|
2040
|
+
process.on("SIGTERM", bySignal(143));
|
|
2041
|
+
process.on("SIGHUP", bySignal(129));
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
// Exported for unit tests. The dashboard itself is still one file; these are
|
|
2045
|
+
// the pure functions worth pinning, and nothing here is part of the public API.
|
|
2046
|
+
export {
|
|
2047
|
+
parseArgs,
|
|
2048
|
+
validateArgs,
|
|
2049
|
+
REPO_PATTERN,
|
|
2050
|
+
MIN_REFRESH_SECONDS,
|
|
2051
|
+
MAX_REFRESH_SECONDS,
|
|
2052
|
+
TAB_KEYS,
|
|
2053
|
+
safe,
|
|
2054
|
+
shortErr,
|
|
2055
|
+
isUnavailable,
|
|
2056
|
+
isRateLimited,
|
|
2057
|
+
formatAge,
|
|
2058
|
+
formatDuration,
|
|
2059
|
+
usableSize,
|
|
2060
|
+
severityRank,
|
|
2061
|
+
pick,
|
|
2062
|
+
minimumWidthFor,
|
|
2063
|
+
runStatusIcon,
|
|
2064
|
+
RUN_STATUS_ICON,
|
|
2065
|
+
SEVERITY_STYLE,
|
|
2066
|
+
REVIEW_LABEL,
|
|
2067
|
+
MIN_TABLE_WIDTH,
|
|
2068
|
+
MIN_COMPACT_WIDTH,
|
|
2069
|
+
OCT_NERD,
|
|
2070
|
+
OCT_UNICODE,
|
|
2071
|
+
};
|