gh-glance 0.7.0 → 0.8.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 CHANGED
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.0] - 2026-08-11
11
+
12
+ ### Added
13
+
14
+ - **Last-known-good dashboard rows now survive process restarts.** Successfully
15
+ parsed Actions, Issues, Pull Requests, and Security state is stored in a
16
+ private, target-scoped cache. It retains five recent targets and 60 rows per
17
+ tab, ignores corrupt or unwritable state, and never stores credentials.
18
+
19
+ ### Changed
20
+
21
+ - **Development tooling was refreshed.** The paired CodeQL actions now use
22
+ 4.37.6, and the development-only `globals` lock entry is 17.9.0.
23
+
24
+ ### Fixed
25
+
26
+ - **Status updates no longer accumulate old footer lines during prolonged rate
27
+ limiting.** The dashboard leaves the terminal's physical bottom row as a
28
+ scroll guard while preserving incremental rendering.
29
+ - **Restarting during a live rate limit no longer shows an empty dashboard.**
30
+ Cached rows remain visible below the current error with an explicit `stale`
31
+ age, while repository targets remain isolated.
32
+ - **Blind Security refreshes preserve known alerts.** A failed observation
33
+ cannot replace last-known-good Security rows with a false empty result, and an
34
+ identical healthy response clears the warning after recovery.
35
+
10
36
  ## [0.7.0] - 2026-08-10
11
37
 
12
38
  ### Added
@@ -621,7 +647,8 @@ engineering, security, QA and UX. What follows is what changed as a result.
621
647
  - The `main` field from `package.json`. It advertised the file as importable,
622
648
  but importing it took over the terminal or exited the host process.
623
649
 
624
- [Unreleased]: https://github.com/juan294/gh-glance/compare/v0.7.0...HEAD
650
+ [Unreleased]: https://github.com/juan294/gh-glance/compare/v0.8.0...HEAD
651
+ [0.8.0]: https://github.com/juan294/gh-glance/compare/v0.7.0...v0.8.0
625
652
  [0.7.0]: https://github.com/juan294/gh-glance/compare/v0.6.1...v0.7.0
626
653
  [0.6.1]: https://github.com/juan294/gh-glance/releases/tag/v0.6.1
627
654
  [0.6.0]: https://github.com/juan294/gh-glance/releases/tag/v0.6.0
package/README.md CHANGED
@@ -30,7 +30,7 @@ without switching to the browser.
30
30
  │ │
31
31
  │ │
32
32
  ╰───────────────────────────────────────────────────────────────── 4 of 4 ─╯
33
- ⣾ Fetching Move: ↑↓ | Open: Ent | Refresh: r | Width: w | Quit: q 0.7.0
33
+ ⣾ Fetching Move: ↑↓ | Open: Ent | Refresh: r | Width: w | Quit: q 0.8.0
34
34
  ```
35
35
 
36
36
  > Captured from a real run at 76 columns with `GH_GLANCE_ICONS=unicode`, so the
@@ -90,10 +90,13 @@ current.
90
90
  answers "is CI red" without colour
91
91
  - Says when it is stale rather than showing old data silently, and marks a tab
92
92
  whose last refresh failed
93
+ - Keeps the last successfully parsed rows visible during temporary failures and
94
+ across a restart, scoped to the repository being watched
93
95
  - Adapts row count to the terminal pane's height live, on resize
94
96
  - Enters the terminal's alternate screen buffer on launch (like `lazygit`,
95
97
  `htop`, `vim`) so the shell prompt that launched it stays out of view, and
96
- is restored cleanly on exit
98
+ is restored cleanly on exit without old status lines accumulating below the
99
+ frame
97
100
 
98
101
  ## Prerequisites
99
102
 
@@ -344,6 +347,32 @@ the live session and width mode reports `Widths not saved`; a later successful
344
347
  write clears the warning. The UI owns this file, so direct editing is not the
345
348
  primary configuration workflow.
346
349
 
350
+ ### Last-known-good dashboard cache
351
+
352
+ Successfully parsed dashboard rows are also saved automatically, in
353
+ `dashboard-cache.json` beside `preferences.json`:
354
+
355
+ ```text
356
+ $XDG_CONFIG_HOME/gh-glance/dashboard-cache.json
357
+ ~/Library/Application Support/gh-glance/dashboard-cache.json
358
+ ~/.config/gh-glance/dashboard-cache.json
359
+ ```
360
+
361
+ The cache is scoped to the repository and host being watched. In inferred mode,
362
+ the host and current working directory form the identity, so rows from one
363
+ checkout cannot appear in another. It retains at most five recent targets and
364
+ 60 rows per tab; a shortened cached tab keeps its `+` marker rather than
365
+ presenting the saved count as complete.
366
+
367
+ Only successfully parsed, non-blind observations replace saved rows. A failed
368
+ request can add a live error or `?` marker, but it cannot turn known Security
369
+ alerts or other last-known-good rows into an empty result. Missing, corrupt,
370
+ unknown-version, or unwritable cache state is ignored rather than blocking the
371
+ dashboard. On POSIX systems, gh-glance restricts its config directory to `0700`
372
+ and the cache file to `0600`. The file contains repository data, including
373
+ titles, authors, branches, and Security findings, but never GitHub credentials.
374
+ It is recovery state owned by the UI, not a configuration interface.
375
+
347
376
  ## GitHub Enterprise and EMU
348
377
 
349
378
  **The common case needs no configuration.** Authenticate once --
@@ -443,6 +472,13 @@ hour, an authorization failure every 30 seconds, a rate limit every minute.
443
472
  Pressing `r` clears that backoff and retries immediately. And `--refresh`
444
473
  scales the whole figure: doubling the interval halves it.
445
474
 
475
+ When a refresh fails, the last successful rows stay visible below the live
476
+ error instead of disappearing. If gh-glance is restarted while GitHub is still
477
+ rate-limiting, it loads the saved rows for that same target immediately. Their
478
+ age remains explicit through the `stale` label, and the rate-limit banner still
479
+ describes the current failed request; cached data never turns a failure into a
480
+ false success.
481
+
446
482
  One pane on Actions costs about a third of an hourly REST budget, so roughly
447
483
  three panes fill it. Past that, `gh-glance` widens its own poll interval: it
448
484
  reads your remaining budget once a minute, works out how much of it belongs to
@@ -544,7 +580,7 @@ in some terminals, which would shift every column to their right.
544
580
  | Security tab shows `?` instead of a number | The alert endpoints could not be read at all -- an expired SAML session, a token without `security_events`, or an org OAuth restriction. `?` means "unknown", not "zero"; run `gh-glance --doctor` to see which probe failed and how it was classified. |
545
581
  | A failing tab seems to have stopped retrying | It backs off deliberately, rather than re-spawning `gh` every five seconds against an endpoint that is refusing. Press `r` to clear the backoff and retry now. |
546
582
  | `unknown argument: -v` | `-v` used to mean `--version` and no longer does, because this CLI also has `--verbose`. Use `--version` or `--verbose` explicitly. |
547
- | `stale 2m` in the status bar | The visible tab has not refreshed successfully for a while — usually a network drop. |
583
+ | `stale 2m` in the status bar | The visible tab has not refreshed successfully for a while — usually a network drop or rate limit. Last-known-good rows can remain visible, including after restart; the live error above the table explains why they are stale. |
548
584
  | It exits immediately when piped | Intentional. It is a full-screen dashboard, not a reporting command. |
549
585
  | It stopped updating and you cannot tell why | Run `gh-glance --verbose 2>gh-glance.log`, reproduce, then read the log: one line per `gh` call with its duration and outcome. It is redacted the same way `--doctor` is, so it is safe to attach to a bug report. `--doctor --verbose` logs the probes too. |
550
586
  | `--verbose` refuses to start | stderr is still your terminal, where the log would draw over the dashboard. Redirect it to a file. |
package/index.mjs CHANGED
@@ -24,6 +24,7 @@ process.env.NODE_ENV ??= "production";
24
24
 
25
25
  import { execFile, spawn } from "node:child_process";
26
26
  import {
27
+ chmodSync,
27
28
  mkdirSync,
28
29
  readFileSync,
29
30
  realpathSync,
@@ -2981,8 +2982,8 @@ function saveWidthPreferences(path, overrides, tabs = TABS) {
2981
2982
  }
2982
2983
  }
2983
2984
 
2984
- function createWidthPreferenceWriter({ write, delay = 200, onResult = () => {} }) {
2985
- let latest = EMPTY_WIDTH_OVERRIDES;
2985
+ function createCoalescedWriter({ write, delay = 200, onResult = () => {} }) {
2986
+ let latest;
2986
2987
  let generation = 0;
2987
2988
  let persistedGeneration = 0;
2988
2989
  let timer = null;
@@ -3083,6 +3084,227 @@ function createWidthPreferenceWriter({ write, delay = 200, onResult = () => {} }
3083
3084
  };
3084
3085
  }
3085
3086
 
3087
+ const createWidthPreferenceWriter = createCoalescedWriter;
3088
+
3089
+ // ---------- Last-known-good dashboard cache ----------
3090
+
3091
+ const DASHBOARD_CACHE_VERSION = 1;
3092
+ const MAX_DASHBOARD_CACHE_TARGETS = 5;
3093
+ const MAX_DASHBOARD_CACHE_ROWS_PER_TAB = 60;
3094
+ let dashboardCacheTempSequence = 0;
3095
+
3096
+ function dashboardCachePath(options = {}) {
3097
+ return join(dirname(widthPreferencesPath(options)), "dashboard-cache.json");
3098
+ }
3099
+
3100
+ // Explicit targets are stable across working directories. In inferred mode the
3101
+ // directory is the only repository identity available without making another
3102
+ // GitHub call -- exactly the call that cannot succeed when this cache is needed.
3103
+ // JSON encoding avoids delimiter ambiguity in host, repo, and path strings.
3104
+ function dashboardCacheTarget({ repo = null, ghRepo = null, host = null, cwd = process.cwd() } = {}) {
3105
+ const target = repo || ghRepo;
3106
+ return target
3107
+ ? JSON.stringify({ kind: "repo", host: String(host ?? ""), repo: String(target) })
3108
+ : JSON.stringify({ kind: "cwd", host: String(host ?? ""), cwd: String(cwd) });
3109
+ }
3110
+
3111
+ function cacheTimestamp(value) {
3112
+ return Number.isFinite(value) && value >= 0 ? value : null;
3113
+ }
3114
+
3115
+ function normalizeCachedItem(tabKey, item) {
3116
+ if (!isRecord(item)) return null;
3117
+ if (tabKey === "actions") {
3118
+ if (!Number.isSafeInteger(item.databaseId)) return null;
3119
+ return {
3120
+ databaseId: item.databaseId,
3121
+ displayTitle: safe(item.displayTitle),
3122
+ workflowName: safe(item.workflowName),
3123
+ number: Number.isSafeInteger(item.number) ? item.number : null,
3124
+ headBranch: safe(item.headBranch),
3125
+ status: safe(item.status),
3126
+ conclusion: item.conclusion == null ? null : safe(item.conclusion),
3127
+ startedAt: safe(item.startedAt),
3128
+ updatedAt: safe(item.updatedAt),
3129
+ };
3130
+ }
3131
+ if (tabKey === "issues") {
3132
+ if (!Number.isSafeInteger(item.number)) return null;
3133
+ return {
3134
+ number: item.number,
3135
+ title: safe(item.title),
3136
+ author: safe(item.author),
3137
+ label: safe(item.label),
3138
+ updatedAt: safe(item.updatedAt),
3139
+ };
3140
+ }
3141
+ if (tabKey === "prs") {
3142
+ if (!Number.isSafeInteger(item.number)) return null;
3143
+ return {
3144
+ number: item.number,
3145
+ title: safe(item.title),
3146
+ author: safe(item.author),
3147
+ headRefName: safe(item.headRefName),
3148
+ isDraft: Boolean(item.isDraft),
3149
+ reviewDecision: safe(item.reviewDecision),
3150
+ updatedAt: safe(item.updatedAt),
3151
+ };
3152
+ }
3153
+ if (tabKey === "security") {
3154
+ const id = safe(item.id);
3155
+ if (!id) return null;
3156
+ return {
3157
+ id,
3158
+ kind: safe(item.kind),
3159
+ severity: safe(item.severity),
3160
+ title: safe(item.title),
3161
+ detail: safe(item.detail),
3162
+ createdAt: safe(item.createdAt),
3163
+ };
3164
+ }
3165
+ return null;
3166
+ }
3167
+
3168
+ function normalizeDashboardCacheEntry(entry) {
3169
+ if (!isRecord(entry) || !isRecord(entry.tabs)) return null;
3170
+ const tabs = {};
3171
+ for (const tabKey of TAB_KEYS) {
3172
+ const tab = entry.tabs[tabKey];
3173
+ if (!isRecord(tab) || !Array.isArray(tab.data)) continue;
3174
+ const lastOk = cacheTimestamp(tab.lastOk);
3175
+ if (
3176
+ lastOk === null ||
3177
+ !isRecord(tab.meta) ||
3178
+ cacheTimestamp(tab.meta.at) === null ||
3179
+ typeof tab.meta.truncated !== "boolean"
3180
+ ) {
3181
+ continue;
3182
+ }
3183
+ const normalizedData = tab.data.map((item) => normalizeCachedItem(tabKey, item)).filter(Boolean);
3184
+ // A malformed row means this tab was not written by the current schema.
3185
+ // Reject the tab rather than turning a corrupt non-empty payload into a
3186
+ // confident empty state.
3187
+ if (normalizedData.length !== tab.data.length) continue;
3188
+ const data = normalizedData.slice(0, MAX_DASHBOARD_CACHE_ROWS_PER_TAB);
3189
+ const meta = {
3190
+ at: tab.meta.at,
3191
+ truncated: tab.meta.truncated || normalizedData.length > MAX_DASHBOARD_CACHE_ROWS_PER_TAB,
3192
+ };
3193
+ tabs[tabKey] = { data, meta, lastOk };
3194
+ }
3195
+ if (Object.keys(tabs).length === 0) return null;
3196
+ const latestTab = Math.max(...Object.values(tabs).map((tab) => tab.lastOk));
3197
+ return {
3198
+ tabs,
3199
+ securityNotes: Array.isArray(entry.securityNotes)
3200
+ ? entry.securityNotes
3201
+ .filter((note) => typeof note === "string")
3202
+ .slice(0, ALERT_SOURCES.length)
3203
+ .map((note) => safe(note))
3204
+ : [],
3205
+ securityBlind: typeof entry.securityBlind === "boolean" ? entry.securityBlind : false,
3206
+ updatedAt: cacheTimestamp(entry.updatedAt) ?? latestTab,
3207
+ };
3208
+ }
3209
+
3210
+ function normalizeDashboardCache(cache) {
3211
+ if (!isRecord(cache)) return {};
3212
+ return limitDashboardCache(
3213
+ Object.fromEntries(
3214
+ Object.entries(cache)
3215
+ .map(([target, entry]) => [target, normalizeDashboardCacheEntry(entry)])
3216
+ .filter(([target, entry]) => target.length > 0 && entry !== null),
3217
+ ),
3218
+ );
3219
+ }
3220
+
3221
+ function limitDashboardCache(cache) {
3222
+ return Object.fromEntries(
3223
+ Object.entries(cache)
3224
+ .sort((left, right) => right[1].updatedAt - left[1].updatedAt)
3225
+ .slice(0, MAX_DASHBOARD_CACHE_TARGETS),
3226
+ );
3227
+ }
3228
+
3229
+ function mergeDashboardCacheEntry(cache, target, entry) {
3230
+ const normalized = normalizeDashboardCacheEntry(entry);
3231
+ return normalized === null ? cache : limitDashboardCache({ ...cache, [target]: normalized });
3232
+ }
3233
+
3234
+ function decodeDashboardCache(raw) {
3235
+ let document;
3236
+ try {
3237
+ document = JSON.parse(raw);
3238
+ } catch (error) {
3239
+ return { cache: {}, error };
3240
+ }
3241
+ if (
3242
+ !isRecord(document) ||
3243
+ document.version !== DASHBOARD_CACHE_VERSION ||
3244
+ !isRecord(document.targets)
3245
+ ) {
3246
+ return { cache: {}, error: new Error("Unsupported dashboard cache document") };
3247
+ }
3248
+ return { cache: normalizeDashboardCache(document.targets), error: null };
3249
+ }
3250
+
3251
+ function serializeDashboardCache(cache, { normalized = false } = {}) {
3252
+ return `${JSON.stringify(
3253
+ { version: DASHBOARD_CACHE_VERSION, targets: normalized ? cache : normalizeDashboardCache(cache) },
3254
+ null,
3255
+ 2,
3256
+ )}\n`;
3257
+ }
3258
+
3259
+ function loadDashboardCache(path, target) {
3260
+ let raw;
3261
+ try {
3262
+ raw = readFileSync(path, "utf8");
3263
+ } catch (error) {
3264
+ return error?.code === "ENOENT"
3265
+ ? { cache: {}, entry: null, error: null }
3266
+ : { cache: {}, entry: null, error };
3267
+ }
3268
+ const decoded = decodeDashboardCache(raw);
3269
+ return {
3270
+ cache: decoded.cache,
3271
+ entry: pick(decoded.cache, target, null),
3272
+ error: decoded.error,
3273
+ };
3274
+ }
3275
+
3276
+ function saveDashboardCache(path, cache, { normalized = false } = {}) {
3277
+ let tempPath = null;
3278
+ try {
3279
+ const parent = dirname(path);
3280
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
3281
+ if (process.platform !== "win32") chmodSync(parent, 0o700);
3282
+ dashboardCacheTempSequence += 1;
3283
+ tempPath = `${path}.${process.pid}.${Date.now()}.${dashboardCacheTempSequence}.tmp`;
3284
+ writeFileSync(tempPath, serializeDashboardCache(cache, { normalized }), {
3285
+ encoding: "utf8",
3286
+ mode: 0o600,
3287
+ flag: "wx",
3288
+ });
3289
+ renameSync(tempPath, path);
3290
+ return { ok: true };
3291
+ } catch (error) {
3292
+ if (tempPath !== null) {
3293
+ try {
3294
+ unlinkSync(tempPath);
3295
+ } catch {
3296
+ // The write may have failed before creating this exact temporary file.
3297
+ }
3298
+ }
3299
+ return { ok: false, error };
3300
+ }
3301
+ }
3302
+
3303
+ function nextSecurityRaw(previousRaw, raw, blind) {
3304
+ if (blind === true) return null;
3305
+ return previousRaw === raw ? previousRaw : raw;
3306
+ }
3307
+
3086
3308
  // Below this the full column set cannot render without its fixed columns
3087
3309
  // overflowing the frame -- rows hard-wrap and ink switches to clearing and
3088
3310
  // repainting the whole screen every frame. Worse, in the band just above the
@@ -3409,19 +3631,42 @@ function App({ onCreateRemote = () => {} } = {}) {
3409
3631
  void widthPreferenceWriter.dispose();
3410
3632
  };
3411
3633
  }, [widthPreferenceWriter]);
3634
+ const [cachePath] = useState(() => dashboardCachePath());
3635
+ const [cacheTarget] = useState(() =>
3636
+ dashboardCacheTarget({
3637
+ repo: runtime.repo,
3638
+ ghRepo: process.env.GH_REPO,
3639
+ host: runtime.host ?? process.env.GH_HOST,
3640
+ cwd: process.cwd(),
3641
+ }),
3642
+ );
3643
+ const [loadedCache] = useState(() => loadDashboardCache(cachePath, cacheTarget));
3644
+ const dashboardCacheRef = useRef(loadedCache.cache);
3645
+ const dashboardCacheTargetRef = useRef(cacheTarget);
3646
+ const [dashboardCacheWriter] = useState(() =>
3647
+ createCoalescedWriter({
3648
+ write: (cache) => saveDashboardCache(cachePath, cache, { normalized: true }),
3649
+ }),
3650
+ );
3651
+ useEffect(() => () => {
3652
+ // Production writes start synchronously, so a pending latest snapshot is
3653
+ // not lost on quit. The cache is bounded; ignoring the advisory result keeps
3654
+ // a filesystem error from replacing terminal teardown.
3655
+ void dashboardCacheWriter.dispose();
3656
+ }, [dashboardCacheWriter]);
3657
+ const cachedEntry = loadedCache.entry;
3412
3658
  // `null` means "never resolved" -- distinct from `[]`, which means "resolved
3413
3659
  // and genuinely empty". The tab bar and the body render those differently.
3414
- const [data, setData] = useState({ actions: null, issues: null, prs: null, security: null });
3415
- const [meta, setMeta] = useState({
3416
- actions: null,
3417
- issues: null,
3418
- prs: null,
3419
- security: null,
3420
- });
3421
- const [securityNotes, setSecurityNotes] = useState([]);
3660
+ const [data, setData] = useState(() =>
3661
+ Object.fromEntries(TABS.map((candidate) => [candidate.key, cachedEntry?.tabs[candidate.key]?.data ?? null])),
3662
+ );
3663
+ const [meta, setMeta] = useState(() =>
3664
+ Object.fromEntries(TABS.map((candidate) => [candidate.key, cachedEntry?.tabs[candidate.key]?.meta ?? null])),
3665
+ );
3666
+ const [securityNotes, setSecurityNotes] = useState(() => cachedEntry?.securityNotes ?? []);
3422
3667
  // Whether the Security tab is currently unable to see its endpoints, as
3423
3668
  // opposed to seeing that they are switched off. Drives the count marker.
3424
- const [securityBlind, setSecurityBlind] = useState(false);
3669
+ const [securityBlind, setSecurityBlind] = useState(() => cachedEntry?.securityBlind ?? false);
3425
3670
  // The `?` overlay. Renders only on a keypress, so consecutive idle frames are
3426
3671
  // still byte-identical and the redraw suppression is untouched.
3427
3672
  const [showHelp, setShowHelp] = useState(false);
@@ -3509,12 +3754,18 @@ function App({ onCreateRemote = () => {} } = {}) {
3509
3754
 
3510
3755
  const inFlightRef = useRef({});
3511
3756
  const rawRef = useRef({});
3512
- const cleanRef = useRef({});
3513
3757
  // Last *successful* poll per tab, wall-clock. Wall-clock on purpose: a laptop
3514
3758
  // sleeping is exactly the gap this is meant to report, and a monotonic clock
3515
3759
  // does not advance across suspend. Never written on the failure path, or a
3516
3760
  // persistently failing tab would report itself fresh forever.
3517
- const lastOkRef = useRef({});
3761
+ const lastOkRef = useRef(
3762
+ Object.fromEntries(
3763
+ TABS.flatMap((candidate) => {
3764
+ const lastOk = cachedEntry?.tabs[candidate.key]?.lastOk;
3765
+ return lastOk == null ? [] : [[candidate.key, lastOk]];
3766
+ }),
3767
+ ),
3768
+ );
3518
3769
  const fetchTabRef = useRef(null);
3519
3770
  const contextCoordinatorRef = useRef(null);
3520
3771
 
@@ -3881,10 +4132,10 @@ function App({ onCreateRemote = () => {} } = {}) {
3881
4132
  // -- the behaviour the per-tab guard and the fetch-time limit read were
3882
4133
  // written to prevent. Do not "fix" this by adding them.
3883
4134
  //
3884
- // exhaustive-deps is satisfied as written -- every captured value is either a
3885
- // ref or a setState function, both of which the rule treats as stable -- so
3886
- // this needs no suppression, and if one ever becomes necessary that is the
3887
- // signal that a real dependency crept in.
4135
+ // exhaustive-deps is satisfied as written -- every captured value is a ref,
4136
+ // a setState function, or the stable cache writer created once above. If a
4137
+ // changing dependency ever becomes necessary, that is the signal that the
4138
+ // mount-only contract has been broken.
3888
4139
  //
3889
4140
  // The adaptive throttle re-arms the timer, which is not a contradiction of the
3890
4141
  // above: `rearm` swaps the interval from *inside* this effect, so the effect
@@ -3915,6 +4166,32 @@ function App({ onCreateRemote = () => {} } = {}) {
3915
4166
  });
3916
4167
  contextCoordinatorRef.current = coordinator;
3917
4168
 
4169
+ function cacheSuccessfulTab(key, tabData, tabMeta, lastOk, security = {}) {
4170
+ const target = dashboardCacheTargetRef.current;
4171
+ const currentCache = dashboardCacheRef.current;
4172
+ const currentEntry = pick(currentCache, target, null) ?? {
4173
+ tabs: {},
4174
+ securityNotes: [],
4175
+ securityBlind: false,
4176
+ updatedAt: lastOk,
4177
+ };
4178
+ const nextEntry = {
4179
+ ...currentEntry,
4180
+ tabs: {
4181
+ ...currentEntry.tabs,
4182
+ [key]: { data: tabData, meta: tabMeta, lastOk },
4183
+ },
4184
+ securityNotes:
4185
+ key === "security" ? (security.notes ?? []) : currentEntry.securityNotes,
4186
+ securityBlind:
4187
+ key === "security" ? Boolean(security.blind) : currentEntry.securityBlind,
4188
+ updatedAt: lastOk,
4189
+ };
4190
+ const nextCache = mergeDashboardCacheEntry(currentCache, target, nextEntry);
4191
+ dashboardCacheRef.current = nextCache;
4192
+ dashboardCacheWriter.schedule(nextCache);
4193
+ }
4194
+
3918
4195
  // Each tab commits its own result the moment it lands instead of waiting on
3919
4196
  // a Promise.allSettled barrier. Actions is by far the slowest fetch, so
3920
4197
  // barrelling everything together meant the three fast tabs sat invisible
@@ -3953,32 +4230,50 @@ function App({ onCreateRemote = () => {} } = {}) {
3953
4230
  // completely healthy. A ref rather than state because writing state here
3954
4231
  // would allocate a new object every tick and permanently defeat the
3955
4232
  // React bail-out this early return exists to preserve.
3956
- lastOkRef.current[key] = Date.now();
3957
- // Clear on the first success or a single failure latches the ladder.
3958
- clearBackoff(`tab:${key}`);
3959
- if (rawRef.current[key] === raw && cleanRef.current[key]) {
4233
+ const completedAt = Date.now();
4234
+ if (rawRef.current[key] === raw) {
4235
+ lastOkRef.current[key] = completedAt;
4236
+ // Clear on the first success or a single failure latches the ladder.
4237
+ clearBackoff(`tab:${key}`);
3960
4238
  setErrors((x) => (x[key] === null ? x : { ...x, [key]: null }));
3961
4239
  return;
3962
4240
  }
3963
4241
  const value = parse();
3964
- rawRef.current[key] = raw;
3965
- cleanRef.current[key] = true;
4242
+ // Security fetches resolve each source independently so their notes
4243
+ // remain visible. A blind result is still a failed observation: it
4244
+ // must not replace known alerts with a false empty state or advance
4245
+ // freshness. Do not retain its raw value either, so the next source
4246
+ // retry is parsed instead of taking the identical-payload fast path.
4247
+ if (key === "security" && value.blind) {
4248
+ rawRef.current[key] = nextSecurityRaw(rawRef.current[key], raw, true);
4249
+ setErrors((x) => (x[key] === null ? x : { ...x, [key]: null }));
4250
+ setSecurityNotes(value.notes ?? []);
4251
+ setSecurityBlind(true);
4252
+ return;
4253
+ }
4254
+ const tabData = value.alerts ?? value;
4255
+ const tabMeta = {
4256
+ at: completedAt,
4257
+ // Compare against the limit this payload was actually fetched
4258
+ // with. Comparing against the live pane-height ref made the "n+"
4259
+ // marker lie for one cycle after every resize.
4260
+ truncated: value.truncated ?? tabData.length >= limit,
4261
+ };
4262
+ lastOkRef.current[key] = completedAt;
4263
+ clearBackoff(`tab:${key}`);
4264
+ rawRef.current[key] =
4265
+ key === "security" ? nextSecurityRaw(rawRef.current[key], raw, false) : raw;
3966
4266
  setErrors((x) => (x[key] === null ? x : { ...x, [key]: null }));
3967
- setData((d) => ({ ...d, [key]: value.alerts ?? value }));
3968
- setMeta((m) => ({
3969
- ...m,
3970
- [key]: {
3971
- at: Date.now(),
3972
- // Compare against the limit this payload was actually fetched
3973
- // with. Comparing against the live pane-height ref made the "n+"
3974
- // marker lie for one cycle after every resize.
3975
- truncated: value.truncated ?? (value.alerts ?? value).length >= limit,
3976
- },
3977
- }));
4267
+ setData((d) => ({ ...d, [key]: tabData }));
4268
+ setMeta((m) => ({ ...m, [key]: tabMeta }));
3978
4269
  if (value.notes) setSecurityNotes(value.notes);
3979
4270
  if (key === "security") {
3980
4271
  setSecurityBlind((b) => (b === Boolean(value.blind) ? b : Boolean(value.blind)));
3981
4272
  }
4273
+ cacheSuccessfulTab(key, tabData, tabMeta, completedAt, {
4274
+ notes: value.notes,
4275
+ blind: value.blind,
4276
+ });
3982
4277
  })
3983
4278
  .catch((err) => {
3984
4279
  // A rejected fetch carries no result, so its own spend is unknowable
@@ -3987,7 +4282,6 @@ function App({ onCreateRemote = () => {} } = {}) {
3987
4282
  // errs toward throttling harder, which is the safe direction.
3988
4283
  restSpentTotal += REST_PER_FETCH[key] ?? 0;
3989
4284
  if (cancelled || err?.name === "AbortError") return;
3990
- cleanRef.current[key] = false;
3991
4285
  // Preserve both the verdict and the bounded raw error in state. The
3992
4286
  // renderer translates recognized verdicts at draw time, which lets a
3993
4287
  // later repository/account context refine the one-line remedy without
@@ -4121,7 +4415,7 @@ function App({ onCreateRemote = () => {} } = {}) {
4121
4415
  // needed.
4122
4416
  controller.abort();
4123
4417
  };
4124
- }, []);
4418
+ }, [dashboardCacheWriter]);
4125
4419
 
4126
4420
  // Background tabs can be up to BACKGROUND_EVERY ticks stale, so the tab you
4127
4421
  // switch to refreshes straight away rather than showing old data until its
@@ -4358,9 +4652,19 @@ function App({ onCreateRemote = () => {} } = {}) {
4358
4652
  widthPreferenceWriter,
4359
4653
  ]);
4360
4654
 
4655
+ // Keep one physical row below Ink's output, the vertical counterpart to the
4656
+ // spare column in `frameCols`. Incremental rendering assumes its cursor still
4657
+ // sits at the bottom of the previous frame; if dynamic content occupies the
4658
+ // terminal's last row, a terminal scroll can invalidate that assumption and
4659
+ // leave old status lines behind. A non-fullscreen frame gets a trailing
4660
+ // newline from Ink, so the cursor parks on the unused guard row while the
4661
+ // status bar remains one row above the scroll edge.
4662
+ const liveRows = Math.min(rows, usableSize(stdout?.rows, rows));
4663
+ const frameRows = Math.max(1, liveRows - 1);
4664
+
4361
4665
  return e(
4362
4666
  Box,
4363
- { flexDirection: "column", width: frameCols, height: Math.min(rows, usableSize(stdout?.rows, rows)) },
4667
+ { flexDirection: "column", width: frameCols, height: frameRows },
4364
4668
  e(TabBar, {
4365
4669
  activeIndex,
4366
4670
  counts,
@@ -4818,6 +5122,13 @@ export {
4818
5122
  loadWidthPreferences,
4819
5123
  saveWidthPreferences,
4820
5124
  createWidthPreferenceWriter,
5125
+ DASHBOARD_CACHE_VERSION,
5126
+ dashboardCachePath,
5127
+ dashboardCacheTarget,
5128
+ serializeDashboardCache,
5129
+ loadDashboardCache,
5130
+ saveDashboardCache,
5131
+ nextSecurityRaw,
4821
5132
  runStatusIcon,
4822
5133
  RUN_STATUS_ICON,
4823
5134
  SEVERITY_STYLE,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gh-glance",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "A live-refreshing GitHub dashboard for your terminal: Actions, Issues, Pull Requests, and Security alerts in one narrow pane",
5
5
  "type": "module",
6
6
  "bin": {