@d3lm/pr-stats 0.2.13 → 0.2.15
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/README.md +46 -1
- package/dist/tui-app.mjs +564 -84
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ An interactive terminal UI for GitHub PR statistics, built with [OpenTUI](https:
|
|
|
7
7
|
## Requirements
|
|
8
8
|
|
|
9
9
|
- Node 26.4+ or Bun 1.3+. The TUI renders through native FFI. Node gates that behind `--experimental-ffi`, and the launcher re-executes itself with the flag, so a plain `pr-stats` works on both runtimes.
|
|
10
|
-
- Either an authenticated gh CLI (`gh auth login`) or a GitHub access token via `--token`, `GITHUB_TOKEN`, or `GH_TOKEN`. With a token, the tool calls the GitHub API directly and does not need the gh CLI.
|
|
10
|
+
- Either an authenticated gh CLI (`gh auth login`) or a GitHub access token via `--token`, `GITHUB_TOKEN`, or `GH_TOKEN`. With a token, the tool calls the GitHub API directly and does not need the gh CLI. See [Tokens](#tokens) for the access a token needs.
|
|
11
11
|
|
|
12
12
|
## Install
|
|
13
13
|
|
|
@@ -70,6 +70,21 @@ pr-stats --work-hours 9-17 --target 1d
|
|
|
70
70
|
pr-stats --review-types approve,request-changes
|
|
71
71
|
```
|
|
72
72
|
|
|
73
|
+
## Tokens
|
|
74
|
+
|
|
75
|
+
The `--token` flag and the `GITHUB_TOKEN` and `GH_TOKEN` environment variables switch the tool from the gh CLI to direct GitHub API calls. The PR list comes from the GitHub search API, which silently leaves out every repository the token cannot see, so a token with too little access shows an empty view instead of an error.
|
|
76
|
+
|
|
77
|
+
A classic token needs the `repo` scope, or `public_repo` when every repository is public.
|
|
78
|
+
|
|
79
|
+
A fine-grained token needs more care, because it only covers one resource owner and only the repositories you select for it.
|
|
80
|
+
|
|
81
|
+
- Pick the organization as the resource owner when the PRs live in an organization's repositories. A token owned by your user account never sees them.
|
|
82
|
+
- Grant access to all repositories or select the ones you care about. The public repositories option hides every private one.
|
|
83
|
+
- Grant read access to `Metadata`, `Pull requests`, and `Contents`. Searching pull requests in private repositories needs `Contents` on top of `Pull requests`, even though GitHub's permission tables do not mention it.
|
|
84
|
+
- Ask an organization owner to approve the token when the organization requires approval for fine-grained tokens. Until then, the token sees nothing in that organization.
|
|
85
|
+
|
|
86
|
+
To check a token, run `pr-stats --token <token> --repo owner/name` against a repository you expect to see. Naming the repository makes the search fail with a `422 Validation Failed` error when the token cannot see it, where a search without `--repo` would just return nothing.
|
|
87
|
+
|
|
73
88
|
## JSON export
|
|
74
89
|
|
|
75
90
|
The `--json` flag prints the full report to stdout instead of starting the TUI. Every other flag applies the same way, and progress renders on stderr, so a piped stdout stays pure JSON.
|
|
@@ -88,6 +103,36 @@ The cache lives in `~/Library/Caches/pr-stats` on macOS and in `$XDG_CACHE_HOME/
|
|
|
88
103
|
|
|
89
104
|
The options modal saves the current options to the cache directory with the `s` key. Later runs start from the saved options wherever no flag was given, and flags always take precedence.
|
|
90
105
|
|
|
106
|
+
## Auto Reload
|
|
107
|
+
|
|
108
|
+
The `r` key reloads the data by hand, and `R` refetches everything past the cache. The TUI can also keep itself fresh while it stays open. The Auto reload row in the settings dialog turns background reloads on, and the Reload interval row below it sets how long the TUI waits after one load finishes before the next one starts, as a value in seconds, minutes, or hours like `30s`, `10m`, or `2h`. The header shows the interval next to the refresh time while it is on. Both settings persist in `settings.json` in the cache directory.
|
|
109
|
+
|
|
110
|
+
```json
|
|
111
|
+
{
|
|
112
|
+
"autoReload": true,
|
|
113
|
+
"reloadInterval": "10m"
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Notifications
|
|
118
|
+
|
|
119
|
+
The Desktop notifications row in the settings dialog makes the TUI send a desktop notification whenever a load finds a PR newly awaiting your review or a review re-requested from you after you already reviewed the PR. Every load diffs its review requests against the load before it, so the first load of a session only records what is already waiting and never notifies about it. Manual reloads count as well, but the feature pairs naturally with auto reload, which lets the TUI watch your review queue from a spare terminal. The setting persists in `settings.json` in the cache directory.
|
|
120
|
+
|
|
121
|
+
```json
|
|
122
|
+
{
|
|
123
|
+
"notifications": true,
|
|
124
|
+
"notifyChannel": "auto"
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Notifications go through the terminal itself when it supports a notification escape sequence, which covers iTerm2, Kitty, Ghostty, WezTerm, and most VTE-based terminals like GNOME Terminal. The terminal posts the notification under its own notification permission and the sequence travels through SSH, so this path needs no setup beyond what the terminal asks for. Some terminals only show the banner while their window is unfocused. Terminals without such support get the platform's own command instead, `osascript` on macOS and `notify-send` on Linux. A notification that fails to send reports in the footer, for example when `notify-send` is missing on a Linux machine.
|
|
129
|
+
|
|
130
|
+
The "Notification channel" row picks the path, and the choice persists as `notifyChannel` with the values `auto`, `terminal`, `command`, and `bell`. On `auto` the TUI tries the terminal first and falls back to the platform command, `terminal` and `command` force one of those paths, and `bell` rings the terminal bell instead of posting any text. The dialog shows the `command` value as the command's name on this platform. Forcing the command pays off inside editor terminals like the one in VS Code, which render the terminal path as a small in-editor toast instead of a system notification. The bell reaches every terminal, including the ones that swallow notifications, and each terminal turns it into its own signal, a sound, a flashing screen, a bell icon on the tab, or a badge on the dock icon. The "Send test notification" row below the channel names the channel the next send takes and sends a sample notification, so you can check that your desktop displays it before relying on it.
|
|
131
|
+
|
|
132
|
+
Two terminals accept the notification sequence without showing anything, and the test row warns about both. iTerm2 drops it until the profile has "Notification Center Alerts" turned on under Settings → Profiles → Terminal, which is off by default. Behind the "Filter Alerts…" button next to it, "Send escape sequence-generated alerts" has to stay on as well, and macOS asks for iTerm2's notification permission on the first send. iTerm2 prefixes the banner with the session name unless its advanced setting "Omit session identifier from notifications" is on. Apple Terminal never implemented the sequence at all, so `auto` skips the terminal there and goes straight to the platform command.
|
|
133
|
+
|
|
134
|
+
The macOS command posts through the built-in Script Editor, and since macOS 15 those notifications stay invisible until Script Editor holds notification permission, without any prompt appearing. To grant it once, open the Script Editor app, run the one-line script `display notification "test"`, and allow the prompt that appears. Script Editor then shows up under Notifications in the System Settings like any other app.
|
|
135
|
+
|
|
91
136
|
## Theming
|
|
92
137
|
|
|
93
138
|
The TUI ships five built-in themes, the warm amber default plus green, blue, purple, and yellow variants. The Theme row in the settings dialog cycles through them, and the Edit colors row below it edits individual colors, which creates a custom theme that joins the cycle as a sixth entry. Everything persists in a `theme` object in `settings.json` in the cache directory, which you can also edit by hand.
|
package/dist/tui-app.mjs
CHANGED
|
@@ -135,8 +135,8 @@ import { createCliRenderer } from "@opentui/core";
|
|
|
135
135
|
import { createRoot } from "@opentui/react";
|
|
136
136
|
|
|
137
137
|
// src/tui/App.tsx
|
|
138
|
-
import { useKeyboard, useRenderer as
|
|
139
|
-
import { useEffect as
|
|
138
|
+
import { useKeyboard, useRenderer as useRenderer3, useTerminalDimensions as useTerminalDimensions3 } from "@opentui/react";
|
|
139
|
+
import { useEffect as useEffect7, useMemo as useMemo2, useReducer, useRef as useRef6, useState as useState4 } from "react";
|
|
140
140
|
|
|
141
141
|
// src/settings.ts
|
|
142
142
|
import { readFileSync as readFileSync2, rmSync as rmSync2 } from "node:fs";
|
|
@@ -279,10 +279,36 @@ function percentile(sorted, percent) {
|
|
|
279
279
|
}
|
|
280
280
|
|
|
281
281
|
// src/settings.ts
|
|
282
|
+
var NOTIFY_CHANNELS = ["auto", "terminal", "command", "bell"];
|
|
283
|
+
var DEFAULT_RELOAD_INTERVAL = "10m";
|
|
284
|
+
var MAX_RELOAD_INTERVAL_MS = 24 * 60 * 60 * 1e3;
|
|
285
|
+
function reloadIntervalMs(input) {
|
|
286
|
+
const match = /^(\d+)([smh])$/.exec(input);
|
|
287
|
+
if (match === null) {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
const ms = Number(match[1]) * (match[2] === "h" ? 36e5 : match[2] === "m" ? 6e4 : 1e3);
|
|
291
|
+
return ms >= 1e3 && ms <= MAX_RELOAD_INTERVAL_MS ? ms : null;
|
|
292
|
+
}
|
|
293
|
+
function parseReloadInterval(input) {
|
|
294
|
+
const ms = reloadIntervalMs(input);
|
|
295
|
+
if (ms === null) {
|
|
296
|
+
throw new CliError(`invalid reload interval "${input}", use a value from 1s to 24h like 30s, 10m, or 2h`);
|
|
297
|
+
}
|
|
298
|
+
return ms;
|
|
299
|
+
}
|
|
282
300
|
function settingsFile() {
|
|
283
301
|
return join2(cacheDir(), "settings.json");
|
|
284
302
|
}
|
|
285
303
|
var current = {};
|
|
304
|
+
function writeCurrent() {
|
|
305
|
+
if (!cacheEnabled()) {
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
writeFileAtomic(settingsFile(), `${JSON.stringify(current, null, 2)}
|
|
309
|
+
`);
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
286
312
|
function loadSettings() {
|
|
287
313
|
current = {};
|
|
288
314
|
if (!cacheEnabled()) {
|
|
@@ -310,26 +336,46 @@ function loadSettings() {
|
|
|
310
336
|
if (settings.copyLinks !== void 0 && typeof settings.copyLinks !== "boolean") {
|
|
311
337
|
throw new CliError(`"copyLinks" in ${settingsFile()} must be true or false`);
|
|
312
338
|
}
|
|
339
|
+
if (settings.autoReload !== void 0 && typeof settings.autoReload !== "boolean") {
|
|
340
|
+
throw new CliError(`"autoReload" in ${settingsFile()} must be true or false`);
|
|
341
|
+
}
|
|
342
|
+
if (settings.reloadInterval !== void 0 && (typeof settings.reloadInterval !== "string" || reloadIntervalMs(settings.reloadInterval) === null)) {
|
|
343
|
+
throw new CliError(
|
|
344
|
+
`"reloadInterval" in ${settingsFile()} must be an interval from 1s to 24h like "30s", "10m", or "2h"`
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
if (settings.notifications !== void 0 && typeof settings.notifications !== "boolean") {
|
|
348
|
+
throw new CliError(`"notifications" in ${settingsFile()} must be true or false`);
|
|
349
|
+
}
|
|
350
|
+
if (settings.notifyChannel !== void 0 && !NOTIFY_CHANNELS.includes(settings.notifyChannel)) {
|
|
351
|
+
throw new CliError(`"notifyChannel" in ${settingsFile()} must be "auto", "terminal", "command", or "bell"`);
|
|
352
|
+
}
|
|
313
353
|
current = settings;
|
|
314
354
|
return current;
|
|
315
355
|
}
|
|
316
356
|
function saveNoCache(on) {
|
|
317
357
|
current = { ...current, noCache: on };
|
|
318
|
-
|
|
319
|
-
return false;
|
|
320
|
-
}
|
|
321
|
-
writeFileAtomic(settingsFile(), `${JSON.stringify(current, null, 2)}
|
|
322
|
-
`);
|
|
323
|
-
return true;
|
|
358
|
+
return writeCurrent();
|
|
324
359
|
}
|
|
325
360
|
function saveCopyLinks(on) {
|
|
326
361
|
current = { ...current, copyLinks: on };
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
362
|
+
return writeCurrent();
|
|
363
|
+
}
|
|
364
|
+
function saveAutoReload(on) {
|
|
365
|
+
current = { ...current, autoReload: on };
|
|
366
|
+
return writeCurrent();
|
|
367
|
+
}
|
|
368
|
+
function saveReloadInterval(value2) {
|
|
369
|
+
current = { ...current, reloadInterval: value2 };
|
|
370
|
+
return writeCurrent();
|
|
371
|
+
}
|
|
372
|
+
function saveNotifications(on) {
|
|
373
|
+
current = { ...current, notifications: on };
|
|
374
|
+
return writeCurrent();
|
|
375
|
+
}
|
|
376
|
+
function saveNotifyChannel(channel) {
|
|
377
|
+
current = { ...current, notifyChannel: channel };
|
|
378
|
+
return writeCurrent();
|
|
333
379
|
}
|
|
334
380
|
function saveTheme(value2) {
|
|
335
381
|
current = { ...current };
|
|
@@ -338,12 +384,7 @@ function saveTheme(value2) {
|
|
|
338
384
|
} else {
|
|
339
385
|
current.theme = value2;
|
|
340
386
|
}
|
|
341
|
-
|
|
342
|
-
return false;
|
|
343
|
-
}
|
|
344
|
-
writeFileAtomic(settingsFile(), `${JSON.stringify(current, null, 2)}
|
|
345
|
-
`);
|
|
346
|
-
return true;
|
|
387
|
+
return writeCurrent();
|
|
347
388
|
}
|
|
348
389
|
function resetSettings() {
|
|
349
390
|
if (!cacheEnabled()) {
|
|
@@ -598,7 +639,7 @@ function hintsFor(modal, editing, tab, authoredTab, views, copyLinks2) {
|
|
|
598
639
|
return editing ? "enter apply \xB7 esc cancel" : "\u2191/\u2193 select \xB7 enter edit \xB7 \u2190/\u2192 toggle \xB7 s save \xB7 esc close \xB7 q quit";
|
|
599
640
|
}
|
|
600
641
|
if (modal === "settings") {
|
|
601
|
-
return "\u2191/\u2193 select \xB7 enter apply \xB7 \u2190/\u2192 toggle \xB7 esc close \xB7 q quit";
|
|
642
|
+
return editing ? "enter apply \xB7 esc cancel" : "\u2191/\u2193 select \xB7 enter apply \xB7 \u2190/\u2192 toggle \xB7 esc close \xB7 q quit";
|
|
602
643
|
}
|
|
603
644
|
if (modal === "theme") {
|
|
604
645
|
return editing ? "enter apply \xB7 esc cancel" : "\u2191/\u2193 select \xB7 enter edit hex \xB7 esc back \xB7 q quit";
|
|
@@ -1684,7 +1725,8 @@ function Header({
|
|
|
1684
1725
|
options,
|
|
1685
1726
|
raw,
|
|
1686
1727
|
error,
|
|
1687
|
-
spinning
|
|
1728
|
+
spinning,
|
|
1729
|
+
reloadEvery
|
|
1688
1730
|
}) {
|
|
1689
1731
|
const context = [
|
|
1690
1732
|
raw ? `@${raw.user}` : options.user !== "" ? `@${options.user}` : "@...",
|
|
@@ -1692,7 +1734,7 @@ function Header({
|
|
|
1692
1734
|
raw && raw.repos.length > 0 ? raw.repos.join(", ") : options.repos !== "" ? options.repos : "all repos",
|
|
1693
1735
|
timeModeLabel(options)
|
|
1694
1736
|
].join(" \xB7 ");
|
|
1695
|
-
const rightStatus = raw ? error !== null ? "reload failed \xB7 press r to retry" : `refreshed ${raw.fetchedAt.toLocaleTimeString()}` : "";
|
|
1737
|
+
const rightStatus = raw ? error !== null ? "reload failed \xB7 press r to retry" : `refreshed ${raw.fetchedAt.toLocaleTimeString()}${reloadEvery === null ? "" : ` \xB7 every ${reloadEvery}`}` : "";
|
|
1696
1738
|
return /* @__PURE__ */ jsxs2("box", { flexDirection: "row", height: 1, paddingLeft: 1, paddingRight: 1, justifyContent: "space-between", children: [
|
|
1697
1739
|
/* @__PURE__ */ jsxs2("text", { wrapMode: "none", children: [
|
|
1698
1740
|
/* @__PURE__ */ jsx3("b", { fg: theme.accent, children: "pr-stats" }),
|
|
@@ -3005,6 +3047,31 @@ function ModalRow({ label, isSelected, children }) {
|
|
|
3005
3047
|
}
|
|
3006
3048
|
);
|
|
3007
3049
|
}
|
|
3050
|
+
function ModalInput({
|
|
3051
|
+
width,
|
|
3052
|
+
value: value2,
|
|
3053
|
+
onDraft,
|
|
3054
|
+
onSubmit
|
|
3055
|
+
}) {
|
|
3056
|
+
return /* @__PURE__ */ jsx10(
|
|
3057
|
+
"input",
|
|
3058
|
+
{
|
|
3059
|
+
width,
|
|
3060
|
+
value: value2,
|
|
3061
|
+
focused: true,
|
|
3062
|
+
onInput: (next) => {
|
|
3063
|
+
onDraft(String(next));
|
|
3064
|
+
},
|
|
3065
|
+
onSubmit: () => {
|
|
3066
|
+
onSubmit();
|
|
3067
|
+
},
|
|
3068
|
+
backgroundColor: theme.inputBg,
|
|
3069
|
+
focusedBackgroundColor: theme.inputFocusedBg,
|
|
3070
|
+
textColor: theme.text,
|
|
3071
|
+
cursorColor: theme.accent
|
|
3072
|
+
}
|
|
3073
|
+
);
|
|
3074
|
+
}
|
|
3008
3075
|
|
|
3009
3076
|
// src/tui/components/OptionsModal.tsx
|
|
3010
3077
|
import { jsx as jsx11, jsxs as jsxs10 } from "@opentui/react/jsx-runtime";
|
|
@@ -3118,24 +3185,7 @@ function FieldRow({
|
|
|
3118
3185
|
)
|
|
3119
3186
|
] });
|
|
3120
3187
|
}
|
|
3121
|
-
return /* @__PURE__ */ jsx11(ModalRow, { label: field.label, isSelected, children: isEditing ? /* @__PURE__ */ jsx11(
|
|
3122
|
-
"input",
|
|
3123
|
-
{
|
|
3124
|
-
width: 32,
|
|
3125
|
-
value: String(options[field.key]),
|
|
3126
|
-
focused: true,
|
|
3127
|
-
onInput: (next) => {
|
|
3128
|
-
onDraft(String(next));
|
|
3129
|
-
},
|
|
3130
|
-
onSubmit: () => {
|
|
3131
|
-
onSubmit();
|
|
3132
|
-
},
|
|
3133
|
-
backgroundColor: theme.inputBg,
|
|
3134
|
-
focusedBackgroundColor: theme.inputFocusedBg,
|
|
3135
|
-
textColor: theme.text,
|
|
3136
|
-
cursorColor: theme.accent
|
|
3137
|
-
}
|
|
3138
|
-
) : isSelected && field.kind === "toggle" ? /* @__PURE__ */ jsxs10("text", { wrapMode: "none", children: [
|
|
3188
|
+
return /* @__PURE__ */ jsx11(ModalRow, { label: field.label, isSelected, children: isEditing ? /* @__PURE__ */ jsx11(ModalInput, { width: 32, value: String(options[field.key]), onDraft, onSubmit }) : isSelected && field.kind === "toggle" ? /* @__PURE__ */ jsxs10("text", { wrapMode: "none", children: [
|
|
3139
3189
|
/* @__PURE__ */ jsx11("span", { fg: theme.muted, children: "\u2039 " }),
|
|
3140
3190
|
/* @__PURE__ */ jsx11("b", { fg: theme.text, children: value2.text }),
|
|
3141
3191
|
/* @__PURE__ */ jsx11("span", { fg: theme.muted, children: " \u203A" })
|
|
@@ -3175,6 +3225,7 @@ function Checklist({
|
|
|
3175
3225
|
}
|
|
3176
3226
|
|
|
3177
3227
|
// src/tui/components/SettingsModal.tsx
|
|
3228
|
+
import { useRenderer as useRenderer2 } from "@opentui/react";
|
|
3178
3229
|
import { homedir as homedir2 } from "node:os";
|
|
3179
3230
|
|
|
3180
3231
|
// src/tui/data/export.ts
|
|
@@ -4022,6 +4073,36 @@ var SETTINGS = [
|
|
|
4022
4073
|
label: "Clear cache",
|
|
4023
4074
|
hint: "deletes the cached PR data at this path, so the next reload refetches everything"
|
|
4024
4075
|
},
|
|
4076
|
+
{
|
|
4077
|
+
key: "autoReload",
|
|
4078
|
+
section: "Reload",
|
|
4079
|
+
label: "Auto reload",
|
|
4080
|
+
hint: "reloads the data in the background on the interval below, so the charts stay fresh while the TUI keeps running"
|
|
4081
|
+
},
|
|
4082
|
+
{
|
|
4083
|
+
key: "reloadInterval",
|
|
4084
|
+
section: "Reload",
|
|
4085
|
+
label: "Reload interval",
|
|
4086
|
+
hint: "time between the background reloads while auto reload is on, like 30s, 10m, or 2h \xB7 enter edits the value"
|
|
4087
|
+
},
|
|
4088
|
+
{
|
|
4089
|
+
key: "notifications",
|
|
4090
|
+
section: "Notifications",
|
|
4091
|
+
label: "Desktop notifications",
|
|
4092
|
+
hint: "notifies you when a load finds a PR newly awaiting your review or a review re-requested from you"
|
|
4093
|
+
},
|
|
4094
|
+
{
|
|
4095
|
+
key: "notifyChannel",
|
|
4096
|
+
section: "Notifications",
|
|
4097
|
+
label: "Notification channel",
|
|
4098
|
+
hint: "auto tries the terminal, then the platform command \xB7 the others force one path \xB7 bell rings the terminal bell"
|
|
4099
|
+
},
|
|
4100
|
+
{
|
|
4101
|
+
key: "testNotification",
|
|
4102
|
+
section: "Notifications",
|
|
4103
|
+
label: "Send test notification",
|
|
4104
|
+
hint: "sends a sample notification through the channel shown, so you can check that your desktop displays it"
|
|
4105
|
+
},
|
|
4025
4106
|
{
|
|
4026
4107
|
key: "copyLinks",
|
|
4027
4108
|
section: "Links",
|
|
@@ -4082,9 +4163,140 @@ var CACHE_MESSAGES = {
|
|
|
4082
4163
|
resetDisabled: { text: "the cache is disabled for this session \xB7 nothing to reset" },
|
|
4083
4164
|
exported: { text: "stats exported \xB7 the same report prints to stdout with the --json flag" },
|
|
4084
4165
|
exportFailed: { text: "the export failed \xB7 the file could not be written", warn: true },
|
|
4085
|
-
exportNoData: { text: "no loaded stats to export yet \xB7 export again once the load finishes", warn: true }
|
|
4166
|
+
exportNoData: { text: "no loaded stats to export yet \xB7 export again once the load finishes", warn: true },
|
|
4167
|
+
notified: { text: "test notification sent \xB7 a failure to deliver it reports in the footer" }
|
|
4086
4168
|
};
|
|
4087
4169
|
|
|
4170
|
+
// src/tui/utils/notify.ts
|
|
4171
|
+
import { spawn } from "node:child_process";
|
|
4172
|
+
function notificationBoundary(renderer2) {
|
|
4173
|
+
const raw = renderer2;
|
|
4174
|
+
return {
|
|
4175
|
+
get capabilities() {
|
|
4176
|
+
return renderer2.capabilities;
|
|
4177
|
+
},
|
|
4178
|
+
triggerNotification: (message, title) => renderer2.triggerNotification(message, title),
|
|
4179
|
+
writeOut: (data) => {
|
|
4180
|
+
if (typeof raw.writeOut === "function") {
|
|
4181
|
+
raw.writeOut(data);
|
|
4182
|
+
} else {
|
|
4183
|
+
process.stdout.write(data);
|
|
4184
|
+
}
|
|
4185
|
+
}
|
|
4186
|
+
};
|
|
4187
|
+
}
|
|
4188
|
+
var BELL = "\x07";
|
|
4189
|
+
function terminalQuirk(renderer2) {
|
|
4190
|
+
const name = renderer2.capabilities?.terminal.name ?? "";
|
|
4191
|
+
if (/iterm/i.test(name)) {
|
|
4192
|
+
return "iterm";
|
|
4193
|
+
}
|
|
4194
|
+
if (/apple_terminal|terminal\.app/i.test(name)) {
|
|
4195
|
+
return "apple-terminal";
|
|
4196
|
+
}
|
|
4197
|
+
return null;
|
|
4198
|
+
}
|
|
4199
|
+
function createNotifier(renderer2, channel) {
|
|
4200
|
+
return (title, body, onError) => {
|
|
4201
|
+
if (channel === "bell") {
|
|
4202
|
+
renderer2.writeOut(BELL);
|
|
4203
|
+
return;
|
|
4204
|
+
}
|
|
4205
|
+
const tryTerminal = channel === "terminal" || channel === "auto" && terminalQuirk(renderer2) !== "apple-terminal";
|
|
4206
|
+
if (tryTerminal && renderer2.triggerNotification(body, title)) {
|
|
4207
|
+
return;
|
|
4208
|
+
}
|
|
4209
|
+
if (channel === "terminal") {
|
|
4210
|
+
onError("the terminal has no notification support, switch the channel to auto, the platform command, or bell");
|
|
4211
|
+
return;
|
|
4212
|
+
}
|
|
4213
|
+
sendNotification(title, body, onError);
|
|
4214
|
+
};
|
|
4215
|
+
}
|
|
4216
|
+
function notificationChannel(renderer2, channel) {
|
|
4217
|
+
if (channel === "bell" || channel === "terminal") {
|
|
4218
|
+
return channel;
|
|
4219
|
+
}
|
|
4220
|
+
if (channel === "auto" && renderer2.capabilities?.notifications === true && terminalQuirk(renderer2) !== "apple-terminal") {
|
|
4221
|
+
return "terminal";
|
|
4222
|
+
}
|
|
4223
|
+
return notificationTool();
|
|
4224
|
+
}
|
|
4225
|
+
function notificationCaveat(renderer2, channel) {
|
|
4226
|
+
if (channel === "command" || channel === "bell") {
|
|
4227
|
+
return null;
|
|
4228
|
+
}
|
|
4229
|
+
switch (terminalQuirk(renderer2)) {
|
|
4230
|
+
case "iterm": {
|
|
4231
|
+
return "iTerm2 shows these only once Notification Center Alerts is on under Settings \u203A Profiles \u203A Terminal";
|
|
4232
|
+
}
|
|
4233
|
+
case "apple-terminal": {
|
|
4234
|
+
return channel === "auto" ? "Terminal.app ignores terminal notifications, so auto takes the platform command here" : "Terminal.app ignores terminal notifications \xB7 switch the channel to auto, command, or bell";
|
|
4235
|
+
}
|
|
4236
|
+
default: {
|
|
4237
|
+
return null;
|
|
4238
|
+
}
|
|
4239
|
+
}
|
|
4240
|
+
}
|
|
4241
|
+
function notificationTool() {
|
|
4242
|
+
switch (process.platform) {
|
|
4243
|
+
case "darwin": {
|
|
4244
|
+
return "osascript";
|
|
4245
|
+
}
|
|
4246
|
+
case "linux": {
|
|
4247
|
+
return "notify-send";
|
|
4248
|
+
}
|
|
4249
|
+
default: {
|
|
4250
|
+
return null;
|
|
4251
|
+
}
|
|
4252
|
+
}
|
|
4253
|
+
}
|
|
4254
|
+
function escapeMarkup(text) {
|
|
4255
|
+
return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
4256
|
+
}
|
|
4257
|
+
function notifyCommand(title, body) {
|
|
4258
|
+
switch (process.platform) {
|
|
4259
|
+
case "darwin": {
|
|
4260
|
+
return {
|
|
4261
|
+
command: "osascript",
|
|
4262
|
+
args: [
|
|
4263
|
+
"-e",
|
|
4264
|
+
"on run argv",
|
|
4265
|
+
"-e",
|
|
4266
|
+
'display notification (item 2 of argv) with title (item 1 of argv) sound name "default"',
|
|
4267
|
+
"-e",
|
|
4268
|
+
"end run",
|
|
4269
|
+
title,
|
|
4270
|
+
body
|
|
4271
|
+
]
|
|
4272
|
+
};
|
|
4273
|
+
}
|
|
4274
|
+
case "linux": {
|
|
4275
|
+
return { command: "notify-send", args: ["--app-name=pr-stats", "--", title, escapeMarkup(body)] };
|
|
4276
|
+
}
|
|
4277
|
+
default: {
|
|
4278
|
+
return null;
|
|
4279
|
+
}
|
|
4280
|
+
}
|
|
4281
|
+
}
|
|
4282
|
+
function sendNotification(title, body, onError) {
|
|
4283
|
+
const spec = notifyCommand(title, body);
|
|
4284
|
+
if (spec === null) {
|
|
4285
|
+
onError("desktop notifications are not supported on this platform");
|
|
4286
|
+
return;
|
|
4287
|
+
}
|
|
4288
|
+
const child = spawn(spec.command, spec.args, { stdio: "ignore", detached: true });
|
|
4289
|
+
child.on("error", (error) => {
|
|
4290
|
+
onError(`could not send the notification (${error.message})`);
|
|
4291
|
+
});
|
|
4292
|
+
child.on("exit", (code) => {
|
|
4293
|
+
if (code !== null && code !== 0) {
|
|
4294
|
+
onError(`could not send the notification (${spec.command} exited with ${code})`);
|
|
4295
|
+
}
|
|
4296
|
+
});
|
|
4297
|
+
child.unref();
|
|
4298
|
+
}
|
|
4299
|
+
|
|
4088
4300
|
// src/tui/components/SettingsModal.tsx
|
|
4089
4301
|
import { jsx as jsx12, jsxs as jsxs11 } from "@opentui/react/jsx-runtime";
|
|
4090
4302
|
var SECTIONS2 = [];
|
|
@@ -4098,12 +4310,25 @@ for (const setting of SETTINGS) {
|
|
|
4098
4310
|
}
|
|
4099
4311
|
function SettingsModal({
|
|
4100
4312
|
selected,
|
|
4313
|
+
editing,
|
|
4314
|
+
error,
|
|
4101
4315
|
cacheAction,
|
|
4102
4316
|
noCache: noCache2,
|
|
4317
|
+
autoReload: autoReload2,
|
|
4318
|
+
reloadInterval: reloadInterval2,
|
|
4319
|
+
notifications: notifications2,
|
|
4320
|
+
notifyChannel: notifyChannel2,
|
|
4103
4321
|
copyLinks: copyLinks2,
|
|
4104
|
-
preset
|
|
4322
|
+
preset,
|
|
4323
|
+
onDraft,
|
|
4324
|
+
onSubmit
|
|
4105
4325
|
}) {
|
|
4106
4326
|
const message = cacheAction === null ? null : CACHE_MESSAGES[cacheAction];
|
|
4327
|
+
const renderer2 = notificationBoundary(useRenderer2());
|
|
4328
|
+
const channelValue = notifyChannel2 === "command" ? notificationTool() ?? "unsupported" : notifyChannel2;
|
|
4329
|
+
const deliveryValue = notificationChannel(renderer2, notifyChannel2) ?? "unsupported";
|
|
4330
|
+
const caveat = SETTINGS[selected].key === "testNotification" ? notificationCaveat(renderer2, notifyChannel2) : null;
|
|
4331
|
+
const bottomLine = error !== null ? { text: error, fg: theme.error } : message !== null ? { text: message.text, fg: message.warn ? theme.warn : theme.muted } : caveat !== null ? { text: caveat, fg: theme.warn } : { text: SETTINGS[selected].hint, fg: theme.muted };
|
|
4107
4332
|
return /* @__PURE__ */ jsxs11(ModalFrame, { title: "Settings", children: [
|
|
4108
4333
|
SECTIONS2.map((section) => /* @__PURE__ */ jsxs11("box", { flexDirection: "column", marginBottom: 1, children: [
|
|
4109
4334
|
/* @__PURE__ */ jsx12("text", { wrapMode: "none", fg: theme.accent, marginLeft: 2, children: section.title }),
|
|
@@ -4114,24 +4339,40 @@ function SettingsModal({
|
|
|
4114
4339
|
{
|
|
4115
4340
|
setting,
|
|
4116
4341
|
isSelected,
|
|
4342
|
+
isEditing: isSelected && editing,
|
|
4117
4343
|
cacheAction,
|
|
4118
4344
|
noCache: noCache2,
|
|
4345
|
+
autoReload: autoReload2,
|
|
4346
|
+
reloadInterval: reloadInterval2,
|
|
4347
|
+
notifications: notifications2,
|
|
4348
|
+
channelValue,
|
|
4349
|
+
deliveryValue,
|
|
4119
4350
|
copyLinks: copyLinks2,
|
|
4120
|
-
preset
|
|
4351
|
+
preset,
|
|
4352
|
+
onDraft,
|
|
4353
|
+
onSubmit
|
|
4121
4354
|
}
|
|
4122
4355
|
) }, setting.key);
|
|
4123
4356
|
})
|
|
4124
4357
|
] }, section.title)),
|
|
4125
|
-
/* @__PURE__ */ jsx12("text", { wrapMode: "word", height: 2, fg:
|
|
4358
|
+
/* @__PURE__ */ jsx12("text", { wrapMode: "word", height: 2, fg: bottomLine.fg, marginLeft: 2, marginRight: 2, children: bottomLine.text })
|
|
4126
4359
|
] });
|
|
4127
4360
|
}
|
|
4128
4361
|
function SettingValue({
|
|
4129
4362
|
setting,
|
|
4130
4363
|
isSelected,
|
|
4364
|
+
isEditing,
|
|
4131
4365
|
cacheAction,
|
|
4132
4366
|
noCache: noCache2,
|
|
4367
|
+
autoReload: autoReload2,
|
|
4368
|
+
reloadInterval: reloadInterval2,
|
|
4369
|
+
notifications: notifications2,
|
|
4370
|
+
channelValue,
|
|
4371
|
+
deliveryValue,
|
|
4133
4372
|
copyLinks: copyLinks2,
|
|
4134
|
-
preset
|
|
4373
|
+
preset,
|
|
4374
|
+
onDraft,
|
|
4375
|
+
onSubmit
|
|
4135
4376
|
}) {
|
|
4136
4377
|
switch (setting.key) {
|
|
4137
4378
|
case "noCache": {
|
|
@@ -4140,6 +4381,24 @@ function SettingValue({
|
|
|
4140
4381
|
case "clearCache": {
|
|
4141
4382
|
return /* @__PURE__ */ jsx12(PathValue, { path: cacheDir(), confirming: cacheAction === "confirm", isSelected });
|
|
4142
4383
|
}
|
|
4384
|
+
case "autoReload": {
|
|
4385
|
+
return /* @__PURE__ */ jsx12(ToggleValue, { value: autoReload2 ? "yes" : "no", isSelected });
|
|
4386
|
+
}
|
|
4387
|
+
case "reloadInterval": {
|
|
4388
|
+
if (isEditing) {
|
|
4389
|
+
return /* @__PURE__ */ jsx12(ModalInput, { width: 16, value: reloadInterval2, onDraft, onSubmit });
|
|
4390
|
+
}
|
|
4391
|
+
return /* @__PURE__ */ jsx12(IntervalValue, { value: reloadInterval2, active: autoReload2, isSelected });
|
|
4392
|
+
}
|
|
4393
|
+
case "notifications": {
|
|
4394
|
+
return /* @__PURE__ */ jsx12(ToggleValue, { value: notifications2 ? "yes" : "no", isSelected });
|
|
4395
|
+
}
|
|
4396
|
+
case "notifyChannel": {
|
|
4397
|
+
return /* @__PURE__ */ jsx12(ToggleValue, { value: channelValue, isSelected });
|
|
4398
|
+
}
|
|
4399
|
+
case "testNotification": {
|
|
4400
|
+
return /* @__PURE__ */ jsx12("text", { wrapMode: "none", fg: isSelected ? theme.text : theme.muted, children: deliveryValue });
|
|
4401
|
+
}
|
|
4143
4402
|
case "copyLinks": {
|
|
4144
4403
|
return /* @__PURE__ */ jsx12(ToggleValue, { value: copyLinks2 ? "yes" : "no", isSelected });
|
|
4145
4404
|
}
|
|
@@ -4170,6 +4429,15 @@ function ToggleValue({ value: value2, isSelected }) {
|
|
|
4170
4429
|
}
|
|
4171
4430
|
return /* @__PURE__ */ jsx12("text", { wrapMode: "none", fg: theme.muted, children: value2 });
|
|
4172
4431
|
}
|
|
4432
|
+
function IntervalValue({ value: value2, active, isSelected }) {
|
|
4433
|
+
if (!active) {
|
|
4434
|
+
return /* @__PURE__ */ jsx12("text", { wrapMode: "none", fg: isSelected ? theme.muted : theme.dim, children: value2 });
|
|
4435
|
+
}
|
|
4436
|
+
if (isSelected) {
|
|
4437
|
+
return /* @__PURE__ */ jsx12("text", { wrapMode: "none", children: /* @__PURE__ */ jsx12("b", { fg: theme.text, children: value2 }) });
|
|
4438
|
+
}
|
|
4439
|
+
return /* @__PURE__ */ jsx12("text", { wrapMode: "none", fg: theme.muted, children: value2 });
|
|
4440
|
+
}
|
|
4173
4441
|
function PathValue({ path, confirming, isSelected }) {
|
|
4174
4442
|
if (confirming) {
|
|
4175
4443
|
return /* @__PURE__ */ jsx12("text", { wrapMode: "none", children: /* @__PURE__ */ jsx12("b", { fg: theme.warn, children: "enter to confirm" }) });
|
|
@@ -4217,24 +4485,7 @@ function ColorRow({
|
|
|
4217
4485
|
}) {
|
|
4218
4486
|
const value2 = themeColorText(color.key);
|
|
4219
4487
|
const swatch = theme[color.key];
|
|
4220
|
-
return /* @__PURE__ */ jsx13(ModalRow, { label: color.key, isSelected, children: isEditing ? /* @__PURE__ */ jsx13(
|
|
4221
|
-
"input",
|
|
4222
|
-
{
|
|
4223
|
-
width: 36,
|
|
4224
|
-
value: value2,
|
|
4225
|
-
focused: true,
|
|
4226
|
-
onInput: (next) => {
|
|
4227
|
-
onDraft(String(next));
|
|
4228
|
-
},
|
|
4229
|
-
onSubmit: () => {
|
|
4230
|
-
onSubmit();
|
|
4231
|
-
},
|
|
4232
|
-
backgroundColor: theme.inputBg,
|
|
4233
|
-
focusedBackgroundColor: theme.inputFocusedBg,
|
|
4234
|
-
textColor: theme.text,
|
|
4235
|
-
cursorColor: theme.accent
|
|
4236
|
-
}
|
|
4237
|
-
) : /* @__PURE__ */ jsxs12("text", { wrapMode: "none", children: [
|
|
4488
|
+
return /* @__PURE__ */ jsx13(ModalRow, { label: color.key, isSelected, children: isEditing ? /* @__PURE__ */ jsx13(ModalInput, { width: 36, value: value2, onDraft, onSubmit }) : /* @__PURE__ */ jsxs12("text", { wrapMode: "none", children: [
|
|
4238
4489
|
Array.isArray(swatch) ? /* @__PURE__ */ jsxs12(Fragment4, { children: [
|
|
4239
4490
|
/* @__PURE__ */ jsx13("span", { fg: swatch[0], children: "\u2588" }),
|
|
4240
4491
|
/* @__PURE__ */ jsx13("span", { fg: swatch[1], children: "\u2588" }),
|
|
@@ -4253,10 +4504,15 @@ function Modals({
|
|
|
4253
4504
|
options,
|
|
4254
4505
|
saved: saved2,
|
|
4255
4506
|
noCache: noCache2,
|
|
4507
|
+
autoReload: autoReload2,
|
|
4508
|
+
reloadInterval: reloadInterval2,
|
|
4509
|
+
notifications: notifications2,
|
|
4510
|
+
notifyChannel: notifyChannel2,
|
|
4256
4511
|
copyLinks: copyLinks2,
|
|
4257
4512
|
themeState,
|
|
4258
4513
|
onDraft,
|
|
4259
4514
|
onSubmitField,
|
|
4515
|
+
onSubmitReloadInterval,
|
|
4260
4516
|
onSubmitThemeColor,
|
|
4261
4517
|
onToggleReviewType,
|
|
4262
4518
|
onToggleWorkDay
|
|
@@ -4282,10 +4538,18 @@ function Modals({
|
|
|
4282
4538
|
SettingsModal,
|
|
4283
4539
|
{
|
|
4284
4540
|
selected: ui.selectedSetting,
|
|
4541
|
+
editing: ui.editing,
|
|
4542
|
+
error: ui.settingError,
|
|
4285
4543
|
cacheAction: ui.cacheAction,
|
|
4286
4544
|
noCache: noCache2,
|
|
4545
|
+
autoReload: autoReload2,
|
|
4546
|
+
reloadInterval: reloadInterval2,
|
|
4547
|
+
notifications: notifications2,
|
|
4548
|
+
notifyChannel: notifyChannel2,
|
|
4287
4549
|
copyLinks: copyLinks2,
|
|
4288
|
-
preset: themeState.preset
|
|
4550
|
+
preset: themeState.preset,
|
|
4551
|
+
onDraft,
|
|
4552
|
+
onSubmit: onSubmitReloadInterval
|
|
4289
4553
|
}
|
|
4290
4554
|
);
|
|
4291
4555
|
}
|
|
@@ -4306,12 +4570,31 @@ function Modals({
|
|
|
4306
4570
|
return null;
|
|
4307
4571
|
}
|
|
4308
4572
|
|
|
4573
|
+
// src/tui/hooks/useAutoReload.ts
|
|
4574
|
+
import { useEffect as useEffect4, useEffectEvent } from "react";
|
|
4575
|
+
function useAutoReload(intervalMs, loading, reload) {
|
|
4576
|
+
const tick = useEffectEvent(() => {
|
|
4577
|
+
reload();
|
|
4578
|
+
});
|
|
4579
|
+
useEffect4(() => {
|
|
4580
|
+
if (intervalMs === null || loading) {
|
|
4581
|
+
return void 0;
|
|
4582
|
+
}
|
|
4583
|
+
const timer = setTimeout(() => {
|
|
4584
|
+
tick();
|
|
4585
|
+
}, intervalMs);
|
|
4586
|
+
return () => {
|
|
4587
|
+
clearTimeout(timer);
|
|
4588
|
+
};
|
|
4589
|
+
}, [intervalMs, loading]);
|
|
4590
|
+
}
|
|
4591
|
+
|
|
4309
4592
|
// src/tui/hooks/useDeferredLoading.ts
|
|
4310
|
-
import { useEffect as
|
|
4593
|
+
import { useEffect as useEffect5, useRef as useRef3, useState as useState2 } from "react";
|
|
4311
4594
|
function useDeferredLoading(isLoading, { showDelay = 300, minDuration = 500 } = {}) {
|
|
4312
4595
|
const [visible, setVisible] = useState2(isLoading && showDelay === 0);
|
|
4313
4596
|
const shownAtRef = useRef3(null);
|
|
4314
|
-
|
|
4597
|
+
useEffect5(() => {
|
|
4315
4598
|
if (isLoading) {
|
|
4316
4599
|
const timer2 = setTimeout(() => {
|
|
4317
4600
|
shownAtRef.current = Date.now();
|
|
@@ -4335,7 +4618,7 @@ function useDeferredLoading(isLoading, { showDelay = 300, minDuration = 500 } =
|
|
|
4335
4618
|
}
|
|
4336
4619
|
|
|
4337
4620
|
// src/tui/hooks/useLoader.ts
|
|
4338
|
-
import { useEffect as
|
|
4621
|
+
import { useEffect as useEffect6, useRef as useRef4, useState as useState3 } from "react";
|
|
4339
4622
|
function useLoader(options, noCache2, onLoaded) {
|
|
4340
4623
|
const [startupSnapshot] = useState3(() => noCache2 ? null : loadSnapshot(options));
|
|
4341
4624
|
const [raw, setRaw] = useState3(startupSnapshot);
|
|
@@ -4385,7 +4668,7 @@ function useLoader(options, noCache2, onLoaded) {
|
|
|
4385
4668
|
void run(bypassCache || noCache2);
|
|
4386
4669
|
};
|
|
4387
4670
|
const startedRef = useRef4(false);
|
|
4388
|
-
|
|
4671
|
+
useEffect6(() => {
|
|
4389
4672
|
disposedRef.current = false;
|
|
4390
4673
|
if (!startedRef.current) {
|
|
4391
4674
|
startedRef.current = true;
|
|
@@ -4406,6 +4689,85 @@ function useLoader(options, noCache2, onLoaded) {
|
|
|
4406
4689
|
};
|
|
4407
4690
|
}
|
|
4408
4691
|
|
|
4692
|
+
// src/tui/hooks/useReviewNotifications.ts
|
|
4693
|
+
import { useRef as useRef5 } from "react";
|
|
4694
|
+
|
|
4695
|
+
// src/tui/data/notifications.ts
|
|
4696
|
+
function diffReviewRequests(previous, results) {
|
|
4697
|
+
const baseline = /* @__PURE__ */ new Map();
|
|
4698
|
+
const reviewedKeys = /* @__PURE__ */ new Set();
|
|
4699
|
+
const pending = [];
|
|
4700
|
+
for (const result of results) {
|
|
4701
|
+
const key = `${result.pr.repo}#${result.pr.number}`;
|
|
4702
|
+
if (result.kind === "reviewed") {
|
|
4703
|
+
reviewedKeys.add(key);
|
|
4704
|
+
} else if (result.kind === "pending" && result.pr.state === "open") {
|
|
4705
|
+
const requestedAt = result.requestedAt.getTime();
|
|
4706
|
+
baseline.set(key, requestedAt);
|
|
4707
|
+
pending.push({ key, pr: result.pr, requestedAt });
|
|
4708
|
+
}
|
|
4709
|
+
}
|
|
4710
|
+
if (previous === null) {
|
|
4711
|
+
return { baseline, newRequests: [], reRequests: [] };
|
|
4712
|
+
}
|
|
4713
|
+
const newRequests = [];
|
|
4714
|
+
const reRequests = [];
|
|
4715
|
+
for (const { key, pr, requestedAt } of pending) {
|
|
4716
|
+
const before = previous.get(key);
|
|
4717
|
+
if (before !== void 0 && before >= requestedAt) {
|
|
4718
|
+
continue;
|
|
4719
|
+
}
|
|
4720
|
+
(reviewedKeys.has(key) ? reRequests : newRequests).push(pr);
|
|
4721
|
+
}
|
|
4722
|
+
return { baseline, newRequests, reRequests };
|
|
4723
|
+
}
|
|
4724
|
+
var MAX_LISTED = 3;
|
|
4725
|
+
var TEST_NOTIFICATION = {
|
|
4726
|
+
title: "pr-stats",
|
|
4727
|
+
body: "Desktop notifications are working. New review requests show up like this."
|
|
4728
|
+
};
|
|
4729
|
+
function describeReviewRequests(changes) {
|
|
4730
|
+
const notifications2 = [];
|
|
4731
|
+
if (changes.newRequests.length > 0) {
|
|
4732
|
+
notifications2.push(describe(changes.newRequests, "Review requested on", "new PRs awaiting your review"));
|
|
4733
|
+
}
|
|
4734
|
+
if (changes.reRequests.length > 0) {
|
|
4735
|
+
notifications2.push(describe(changes.reRequests, "Review re-requested on", "PRs came back for review"));
|
|
4736
|
+
}
|
|
4737
|
+
return notifications2;
|
|
4738
|
+
}
|
|
4739
|
+
function describe(prs, singleTitle, pluralTitle) {
|
|
4740
|
+
if (prs.length === 1) {
|
|
4741
|
+
const [pr] = prs;
|
|
4742
|
+
return { title: `${singleTitle} ${refOf(pr)}`, body: pr.title };
|
|
4743
|
+
}
|
|
4744
|
+
const lines = prs.slice(0, MAX_LISTED).map((pr) => `${refOf(pr)} ${pr.title}`);
|
|
4745
|
+
const rest = prs.length - lines.length;
|
|
4746
|
+
if (rest > 0) {
|
|
4747
|
+
lines.push(`and ${rest} more`);
|
|
4748
|
+
}
|
|
4749
|
+
return { title: `${prs.length} ${pluralTitle}`, body: lines.join("\n") };
|
|
4750
|
+
}
|
|
4751
|
+
function refOf(pr) {
|
|
4752
|
+
return `${pr.repo}#${pr.number}`;
|
|
4753
|
+
}
|
|
4754
|
+
|
|
4755
|
+
// src/tui/hooks/useReviewNotifications.ts
|
|
4756
|
+
function useReviewNotifications(enabled2, notify, onError) {
|
|
4757
|
+
const baselineRef = useRef5(null);
|
|
4758
|
+
return (key, results) => {
|
|
4759
|
+
const previous = baselineRef.current?.key === key ? baselineRef.current.baseline : null;
|
|
4760
|
+
const changes = diffReviewRequests(previous, results);
|
|
4761
|
+
baselineRef.current = { key, baseline: changes.baseline };
|
|
4762
|
+
if (!enabled2) {
|
|
4763
|
+
return;
|
|
4764
|
+
}
|
|
4765
|
+
for (const notification of describeReviewRequests(changes)) {
|
|
4766
|
+
notify(notification.title, notification.body, onError);
|
|
4767
|
+
}
|
|
4768
|
+
};
|
|
4769
|
+
}
|
|
4770
|
+
|
|
4409
4771
|
// src/tui/hooks/useViewModel.ts
|
|
4410
4772
|
import { useMemo } from "react";
|
|
4411
4773
|
|
|
@@ -6081,6 +6443,43 @@ function handleSettingsModalKey(key, context) {
|
|
|
6081
6443
|
}
|
|
6082
6444
|
break;
|
|
6083
6445
|
}
|
|
6446
|
+
case "autoReload": {
|
|
6447
|
+
const next = !context.autoReload;
|
|
6448
|
+
context.setAutoReload(next);
|
|
6449
|
+
context.dispatchUi({ type: "cacheActionReported", action: saveAutoReload(next) ? "saved" : "notSaved" });
|
|
6450
|
+
break;
|
|
6451
|
+
}
|
|
6452
|
+
case "reloadInterval": {
|
|
6453
|
+
if (key.name !== "return") {
|
|
6454
|
+
break;
|
|
6455
|
+
}
|
|
6456
|
+
context.beginEdit(context.reloadInterval);
|
|
6457
|
+
break;
|
|
6458
|
+
}
|
|
6459
|
+
case "notifications": {
|
|
6460
|
+
const next = !context.notifications;
|
|
6461
|
+
context.setNotifications(next);
|
|
6462
|
+
context.dispatchUi({ type: "cacheActionReported", action: saveNotifications(next) ? "saved" : "notSaved" });
|
|
6463
|
+
break;
|
|
6464
|
+
}
|
|
6465
|
+
case "notifyChannel": {
|
|
6466
|
+
const index = NOTIFY_CHANNELS.indexOf(context.notifyChannel);
|
|
6467
|
+
const step = key.name === "left" ? -1 : 1;
|
|
6468
|
+
const next = NOTIFY_CHANNELS[(index + step + NOTIFY_CHANNELS.length) % NOTIFY_CHANNELS.length];
|
|
6469
|
+
context.setNotifyChannel(next);
|
|
6470
|
+
context.dispatchUi({ type: "cacheActionReported", action: saveNotifyChannel(next) ? "saved" : "notSaved" });
|
|
6471
|
+
break;
|
|
6472
|
+
}
|
|
6473
|
+
case "testNotification": {
|
|
6474
|
+
if (key.name !== "return") {
|
|
6475
|
+
break;
|
|
6476
|
+
}
|
|
6477
|
+
context.notify(TEST_NOTIFICATION.title, TEST_NOTIFICATION.body, (message) => {
|
|
6478
|
+
context.dispatchUi({ type: "openErrorReported", message });
|
|
6479
|
+
});
|
|
6480
|
+
context.dispatchUi({ type: "cacheActionReported", action: "notified" });
|
|
6481
|
+
break;
|
|
6482
|
+
}
|
|
6084
6483
|
case "copyLinks": {
|
|
6085
6484
|
const next = !context.copyLinks;
|
|
6086
6485
|
context.setCopyLinks(next);
|
|
@@ -6355,6 +6754,7 @@ var initialUiState = {
|
|
|
6355
6754
|
selectedSetting: 0,
|
|
6356
6755
|
selectedThemeColor: 0,
|
|
6357
6756
|
fieldError: null,
|
|
6757
|
+
settingError: null,
|
|
6358
6758
|
themeColorError: null,
|
|
6359
6759
|
cacheAction: null,
|
|
6360
6760
|
openError: null,
|
|
@@ -6381,7 +6781,14 @@ function uiReducer(state, action) {
|
|
|
6381
6781
|
return { ...state, successNotice: null };
|
|
6382
6782
|
}
|
|
6383
6783
|
case "modalOpened": {
|
|
6384
|
-
return {
|
|
6784
|
+
return {
|
|
6785
|
+
...state,
|
|
6786
|
+
modal: action.modal,
|
|
6787
|
+
fieldError: null,
|
|
6788
|
+
settingError: null,
|
|
6789
|
+
themeColorError: null,
|
|
6790
|
+
cacheAction: null
|
|
6791
|
+
};
|
|
6385
6792
|
}
|
|
6386
6793
|
case "optionsModalClosed": {
|
|
6387
6794
|
return { ...state, modal: null, fieldError: null };
|
|
@@ -6390,7 +6797,7 @@ function uiReducer(state, action) {
|
|
|
6390
6797
|
if (state.cacheAction === "confirm" || state.cacheAction === "resetConfirm") {
|
|
6391
6798
|
return { ...state, cacheAction: null };
|
|
6392
6799
|
}
|
|
6393
|
-
return { ...state, modal: null, cacheAction: null };
|
|
6800
|
+
return { ...state, modal: null, settingError: null, cacheAction: null };
|
|
6394
6801
|
}
|
|
6395
6802
|
case "themeModalClosed": {
|
|
6396
6803
|
return { ...state, modal: "settings", themeColorError: null, cacheAction: null };
|
|
@@ -6402,6 +6809,7 @@ function uiReducer(state, action) {
|
|
|
6402
6809
|
return {
|
|
6403
6810
|
...state,
|
|
6404
6811
|
selectedSetting: cycled2(state.selectedSetting, action.delta, SETTINGS.length),
|
|
6812
|
+
settingError: null,
|
|
6405
6813
|
cacheAction: null
|
|
6406
6814
|
};
|
|
6407
6815
|
}
|
|
@@ -6414,10 +6822,10 @@ function uiReducer(state, action) {
|
|
|
6414
6822
|
};
|
|
6415
6823
|
}
|
|
6416
6824
|
case "editStarted": {
|
|
6417
|
-
return { ...state, editing: true, fieldError: null, themeColorError: null };
|
|
6825
|
+
return { ...state, editing: true, fieldError: null, settingError: null, themeColorError: null };
|
|
6418
6826
|
}
|
|
6419
6827
|
case "editCancelled": {
|
|
6420
|
-
return { ...state, editing: false, fieldError: null, themeColorError: null };
|
|
6828
|
+
return { ...state, editing: false, fieldError: null, settingError: null, themeColorError: null };
|
|
6421
6829
|
}
|
|
6422
6830
|
case "fieldCommitted": {
|
|
6423
6831
|
return { ...state, editing: false, fieldError: null };
|
|
@@ -6431,6 +6839,12 @@ function uiReducer(state, action) {
|
|
|
6431
6839
|
}
|
|
6432
6840
|
return { ...state, fieldError: "the cache is disabled for this session \xB7 options not saved" };
|
|
6433
6841
|
}
|
|
6842
|
+
case "settingCommitted": {
|
|
6843
|
+
return { ...state, editing: false, settingError: null, cacheAction: action.action };
|
|
6844
|
+
}
|
|
6845
|
+
case "settingErrorReported": {
|
|
6846
|
+
return { ...state, settingError: action.message };
|
|
6847
|
+
}
|
|
6434
6848
|
case "themeColorCommitted": {
|
|
6435
6849
|
return { ...state, editing: false, themeColorError: null, cacheAction: action.action };
|
|
6436
6850
|
}
|
|
@@ -6445,7 +6859,7 @@ function uiReducer(state, action) {
|
|
|
6445
6859
|
}
|
|
6446
6860
|
|
|
6447
6861
|
// src/tui/utils/browser.ts
|
|
6448
|
-
import { spawn } from "node:child_process";
|
|
6862
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
6449
6863
|
function openCommand(url) {
|
|
6450
6864
|
switch (process.platform) {
|
|
6451
6865
|
case "darwin": {
|
|
@@ -6461,7 +6875,7 @@ function openCommand(url) {
|
|
|
6461
6875
|
}
|
|
6462
6876
|
function openInBrowser(url, onError) {
|
|
6463
6877
|
const { command, args } = openCommand(url);
|
|
6464
|
-
const child =
|
|
6878
|
+
const child = spawn2(command, args, { stdio: "ignore", detached: true });
|
|
6465
6879
|
child.on("error", (error) => {
|
|
6466
6880
|
onError(`could not open the browser (${error.message})`);
|
|
6467
6881
|
});
|
|
@@ -6508,14 +6922,19 @@ function App({
|
|
|
6508
6922
|
initial: initial2,
|
|
6509
6923
|
initialSaved = null,
|
|
6510
6924
|
initialNoCache = false,
|
|
6925
|
+
initialAutoReload = false,
|
|
6926
|
+
initialReloadInterval = DEFAULT_RELOAD_INTERVAL,
|
|
6927
|
+
initialNotifications = false,
|
|
6928
|
+
initialNotifyChannel = "auto",
|
|
6511
6929
|
initialCopyLinks = false,
|
|
6512
6930
|
initialTheme = defaultThemeState(),
|
|
6513
6931
|
openUrl = openInBrowser,
|
|
6514
6932
|
copyUrl,
|
|
6933
|
+
notify,
|
|
6515
6934
|
onQuit
|
|
6516
6935
|
}) {
|
|
6517
6936
|
const { width } = useTerminalDimensions3();
|
|
6518
|
-
const renderer2 =
|
|
6937
|
+
const renderer2 = useRenderer3();
|
|
6519
6938
|
const defaultCopyUrl = useMemo2(() => createClipboardCopier(renderer2), [renderer2]);
|
|
6520
6939
|
const copyLink = copyUrl ?? defaultCopyUrl;
|
|
6521
6940
|
const [ui, dispatchUi] = useReducer(uiReducer, initialUiState);
|
|
@@ -6523,15 +6942,24 @@ function App({
|
|
|
6523
6942
|
const [options, setOptions] = useState4(initial2);
|
|
6524
6943
|
const [saved2, setSaved] = useState4(initialSaved);
|
|
6525
6944
|
const [noCache2, setNoCache] = useState4(initialNoCache);
|
|
6945
|
+
const [autoReload2, setAutoReload] = useState4(initialAutoReload);
|
|
6946
|
+
const [reloadInterval2, setReloadInterval] = useState4(initialReloadInterval);
|
|
6947
|
+
const [notifications2, setNotifications] = useState4(initialNotifications);
|
|
6948
|
+
const [notifyChannel2, setNotifyChannel] = useState4(initialNotifyChannel);
|
|
6526
6949
|
const [copyLinks2, setCopyLinks] = useState4(initialCopyLinks);
|
|
6527
6950
|
const [themeState, setThemeState] = useState4(initialTheme);
|
|
6951
|
+
const defaultNotify = useMemo2(
|
|
6952
|
+
() => createNotifier(notificationBoundary(renderer2), notifyChannel2),
|
|
6953
|
+
[renderer2, notifyChannel2]
|
|
6954
|
+
);
|
|
6955
|
+
const notifier = notify ?? defaultNotify;
|
|
6528
6956
|
const copyRow = (row) => {
|
|
6529
6957
|
copyLink(row.url, (message) => {
|
|
6530
6958
|
dispatchUi({ type: "openErrorReported", message });
|
|
6531
6959
|
});
|
|
6532
6960
|
dispatchUi({ type: "copyReported", message: `copied ${row.ref} to the clipboard` });
|
|
6533
6961
|
};
|
|
6534
|
-
|
|
6962
|
+
useEffect7(() => {
|
|
6535
6963
|
if (ui.successNotice === null) {
|
|
6536
6964
|
return void 0;
|
|
6537
6965
|
}
|
|
@@ -6542,10 +6970,13 @@ function App({
|
|
|
6542
6970
|
clearTimeout(timer);
|
|
6543
6971
|
};
|
|
6544
6972
|
}, [ui.successNotice]);
|
|
6545
|
-
const reviewScrollRef =
|
|
6546
|
-
const sizeScrollRef =
|
|
6547
|
-
const commentScrollRef =
|
|
6548
|
-
const mergedScrollRef =
|
|
6973
|
+
const reviewScrollRef = useRef6(null);
|
|
6974
|
+
const sizeScrollRef = useRef6(null);
|
|
6975
|
+
const commentScrollRef = useRef6(null);
|
|
6976
|
+
const mergedScrollRef = useRef6(null);
|
|
6977
|
+
const notifyReviewChanges = useReviewNotifications(notifications2, notifier, (message) => {
|
|
6978
|
+
dispatchUi({ type: "openErrorReported", message });
|
|
6979
|
+
});
|
|
6549
6980
|
const { raw, isSnapshot, loading, load, error, stale, reload } = useLoader(options, noCache2, (data) => {
|
|
6550
6981
|
dispatchBrowse({
|
|
6551
6982
|
type: "dataLoaded",
|
|
@@ -6558,10 +6989,12 @@ function App({
|
|
|
6558
6989
|
merged: buildMergedRepoOptions(data)
|
|
6559
6990
|
}
|
|
6560
6991
|
});
|
|
6992
|
+
notifyReviewChanges(fetchParamsKey(options), data.reviewResults);
|
|
6561
6993
|
});
|
|
6994
|
+
useAutoReload(autoReload2 ? reloadIntervalMs(reloadInterval2) : null, loading, reload);
|
|
6562
6995
|
const views = useViewModel(raw, options, width, browse.scopes, browse.grouped, browse.expanded, themeState);
|
|
6563
6996
|
const showLoad = useDeferredLoading(loading, isSnapshot ? { showDelay: 0 } : void 0);
|
|
6564
|
-
const draftRef =
|
|
6997
|
+
const draftRef = useRef6("");
|
|
6565
6998
|
const commitField = () => {
|
|
6566
6999
|
const field = FIELDS[ui.selectedField];
|
|
6567
7000
|
const value2 = draftRef.current.trim();
|
|
@@ -6579,6 +7012,20 @@ function App({
|
|
|
6579
7012
|
throw error2;
|
|
6580
7013
|
}
|
|
6581
7014
|
};
|
|
7015
|
+
const commitReloadInterval = () => {
|
|
7016
|
+
const value2 = draftRef.current.trim();
|
|
7017
|
+
try {
|
|
7018
|
+
parseReloadInterval(value2);
|
|
7019
|
+
setReloadInterval(value2);
|
|
7020
|
+
dispatchUi({ type: "settingCommitted", action: saveReloadInterval(value2) ? "saved" : "notSaved" });
|
|
7021
|
+
} catch (error2) {
|
|
7022
|
+
if (error2 instanceof CliError) {
|
|
7023
|
+
dispatchUi({ type: "settingErrorReported", message: error2.message });
|
|
7024
|
+
return;
|
|
7025
|
+
}
|
|
7026
|
+
throw error2;
|
|
7027
|
+
}
|
|
7028
|
+
};
|
|
6582
7029
|
const commitThemeColor = () => {
|
|
6583
7030
|
try {
|
|
6584
7031
|
const next = withColorOverride(themeState, THEME_COLORS[ui.selectedThemeColor].key, draftRef.current.trim());
|
|
@@ -6594,10 +7041,17 @@ function App({
|
|
|
6594
7041
|
}
|
|
6595
7042
|
};
|
|
6596
7043
|
useKeyboard((key) => {
|
|
7044
|
+
if (ui.editing && key.name === "escape") {
|
|
7045
|
+
renderer2.currentFocusedRenderable?.blur();
|
|
7046
|
+
}
|
|
6597
7047
|
handleAppKey(key, {
|
|
6598
7048
|
ui,
|
|
6599
7049
|
browse,
|
|
6600
7050
|
noCache: noCache2,
|
|
7051
|
+
autoReload: autoReload2,
|
|
7052
|
+
reloadInterval: reloadInterval2,
|
|
7053
|
+
notifications: notifications2,
|
|
7054
|
+
notifyChannel: notifyChannel2,
|
|
6601
7055
|
copyLinks: copyLinks2,
|
|
6602
7056
|
themeState,
|
|
6603
7057
|
options,
|
|
@@ -6608,11 +7062,15 @@ function App({
|
|
|
6608
7062
|
setOptions,
|
|
6609
7063
|
setSaved,
|
|
6610
7064
|
setNoCache,
|
|
7065
|
+
setAutoReload,
|
|
7066
|
+
setNotifications,
|
|
7067
|
+
setNotifyChannel,
|
|
6611
7068
|
setCopyLinks,
|
|
6612
7069
|
setThemeState,
|
|
6613
7070
|
quit: onQuit,
|
|
6614
7071
|
reload,
|
|
6615
7072
|
openUrl,
|
|
7073
|
+
notify: notifier,
|
|
6616
7074
|
copyRow,
|
|
6617
7075
|
beginEdit: (value2) => {
|
|
6618
7076
|
draftRef.current = value2;
|
|
@@ -6626,7 +7084,16 @@ function App({
|
|
|
6626
7084
|
});
|
|
6627
7085
|
const capWarning = raw?.searchCapped ? "Warning, a search hit the 1000 result cap, so data may be incomplete. Narrow since or repos." : null;
|
|
6628
7086
|
return /* @__PURE__ */ jsxs13("box", { flexDirection: "column", width: "100%", height: "100%", backgroundColor: theme.bg, children: [
|
|
6629
|
-
/* @__PURE__ */ jsx15(
|
|
7087
|
+
/* @__PURE__ */ jsx15(
|
|
7088
|
+
Header,
|
|
7089
|
+
{
|
|
7090
|
+
options,
|
|
7091
|
+
raw,
|
|
7092
|
+
error,
|
|
7093
|
+
spinning: showLoad,
|
|
7094
|
+
reloadEvery: autoReload2 ? reloadInterval2 : null
|
|
7095
|
+
}
|
|
7096
|
+
),
|
|
6630
7097
|
/* @__PURE__ */ jsx15(TabBar, { tab: browse.tab }),
|
|
6631
7098
|
/* @__PURE__ */ jsx15(
|
|
6632
7099
|
MainPanel,
|
|
@@ -6669,12 +7136,17 @@ function App({
|
|
|
6669
7136
|
options,
|
|
6670
7137
|
saved: saved2,
|
|
6671
7138
|
noCache: noCache2,
|
|
7139
|
+
autoReload: autoReload2,
|
|
7140
|
+
reloadInterval: reloadInterval2,
|
|
7141
|
+
notifications: notifications2,
|
|
7142
|
+
notifyChannel: notifyChannel2,
|
|
6672
7143
|
copyLinks: copyLinks2,
|
|
6673
7144
|
themeState,
|
|
6674
7145
|
onDraft: (value2) => {
|
|
6675
7146
|
draftRef.current = value2;
|
|
6676
7147
|
},
|
|
6677
7148
|
onSubmitField: commitField,
|
|
7149
|
+
onSubmitReloadInterval: commitReloadInterval,
|
|
6678
7150
|
onSubmitThemeColor: commitThemeColor,
|
|
6679
7151
|
onToggleReviewType: (type) => {
|
|
6680
7152
|
setOptions((previous) => {
|
|
@@ -6728,6 +7200,10 @@ function bootstrap() {
|
|
|
6728
7200
|
initial: initial2,
|
|
6729
7201
|
saved: saved2,
|
|
6730
7202
|
noCache: values["no-cache"],
|
|
7203
|
+
autoReload: settings.autoReload === true,
|
|
7204
|
+
reloadInterval: settings.reloadInterval ?? DEFAULT_RELOAD_INTERVAL,
|
|
7205
|
+
notifications: settings.notifications === true,
|
|
7206
|
+
notifyChannel: settings.notifyChannel ?? "auto",
|
|
6731
7207
|
copyLinks: settings.copyLinks === true,
|
|
6732
7208
|
theme: theme3,
|
|
6733
7209
|
json: values.json
|
|
@@ -6742,7 +7218,7 @@ function bootstrap() {
|
|
|
6742
7218
|
|
|
6743
7219
|
// src/tui/main.tsx
|
|
6744
7220
|
import { jsx as jsx16 } from "@opentui/react/jsx-runtime";
|
|
6745
|
-
var { initial, saved, noCache, copyLinks, theme: theme2, json } = bootstrap();
|
|
7221
|
+
var { initial, saved, noCache, autoReload, reloadInterval, notifications, notifyChannel, copyLinks, theme: theme2, json } = bootstrap();
|
|
6746
7222
|
if (json) {
|
|
6747
7223
|
await runJsonStats(initial, noCache);
|
|
6748
7224
|
}
|
|
@@ -6761,6 +7237,10 @@ createRoot(renderer).render(
|
|
|
6761
7237
|
initial,
|
|
6762
7238
|
initialSaved: saved,
|
|
6763
7239
|
initialNoCache: noCache,
|
|
7240
|
+
initialAutoReload: autoReload,
|
|
7241
|
+
initialReloadInterval: reloadInterval,
|
|
7242
|
+
initialNotifications: notifications,
|
|
7243
|
+
initialNotifyChannel: notifyChannel,
|
|
6764
7244
|
initialCopyLinks: copyLinks,
|
|
6765
7245
|
initialTheme: theme2,
|
|
6766
7246
|
onQuit: () => {
|