@d3lm/pr-stats 0.2.13 → 0.2.14
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 +44 -1
- package/dist/tui-app.mjs +518 -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,34 @@ 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. 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`, and `command`. On `auto` the TUI tries the terminal first and falls back to the platform command, while the other two force one path. 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 "Send test notification" row below it 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
|
+
The macOS fallback 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.
|
|
133
|
+
|
|
91
134
|
## Theming
|
|
92
135
|
|
|
93
136
|
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"];
|
|
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", or "command"`);
|
|
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 posts through the terminal and falls back to the platform command \xB7 the other values force one channel"
|
|
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,89 @@ 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 createNotifier(renderer2, channel) {
|
|
4173
|
+
return (title, body, onError) => {
|
|
4174
|
+
if (channel !== "command" && renderer2.triggerNotification(body, title)) {
|
|
4175
|
+
return;
|
|
4176
|
+
}
|
|
4177
|
+
if (channel === "terminal") {
|
|
4178
|
+
onError("the terminal has no notification support, switch the channel to auto or the platform command");
|
|
4179
|
+
return;
|
|
4180
|
+
}
|
|
4181
|
+
sendNotification(title, body, onError);
|
|
4182
|
+
};
|
|
4183
|
+
}
|
|
4184
|
+
function notificationChannel(renderer2, channel) {
|
|
4185
|
+
if (channel === "terminal" || channel === "auto" && renderer2.capabilities?.notifications === true) {
|
|
4186
|
+
return "terminal";
|
|
4187
|
+
}
|
|
4188
|
+
return notificationTool();
|
|
4189
|
+
}
|
|
4190
|
+
function notificationTool() {
|
|
4191
|
+
switch (process.platform) {
|
|
4192
|
+
case "darwin": {
|
|
4193
|
+
return "osascript";
|
|
4194
|
+
}
|
|
4195
|
+
case "linux": {
|
|
4196
|
+
return "notify-send";
|
|
4197
|
+
}
|
|
4198
|
+
default: {
|
|
4199
|
+
return null;
|
|
4200
|
+
}
|
|
4201
|
+
}
|
|
4202
|
+
}
|
|
4203
|
+
function escapeMarkup(text) {
|
|
4204
|
+
return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
4205
|
+
}
|
|
4206
|
+
function notifyCommand(title, body) {
|
|
4207
|
+
switch (process.platform) {
|
|
4208
|
+
case "darwin": {
|
|
4209
|
+
return {
|
|
4210
|
+
command: "osascript",
|
|
4211
|
+
args: [
|
|
4212
|
+
"-e",
|
|
4213
|
+
"on run argv",
|
|
4214
|
+
"-e",
|
|
4215
|
+
'display notification (item 2 of argv) with title (item 1 of argv) sound name "default"',
|
|
4216
|
+
"-e",
|
|
4217
|
+
"end run",
|
|
4218
|
+
title,
|
|
4219
|
+
body
|
|
4220
|
+
]
|
|
4221
|
+
};
|
|
4222
|
+
}
|
|
4223
|
+
case "linux": {
|
|
4224
|
+
return { command: "notify-send", args: ["--app-name=pr-stats", "--", title, escapeMarkup(body)] };
|
|
4225
|
+
}
|
|
4226
|
+
default: {
|
|
4227
|
+
return null;
|
|
4228
|
+
}
|
|
4229
|
+
}
|
|
4230
|
+
}
|
|
4231
|
+
function sendNotification(title, body, onError) {
|
|
4232
|
+
const spec = notifyCommand(title, body);
|
|
4233
|
+
if (spec === null) {
|
|
4234
|
+
onError("desktop notifications are not supported on this platform");
|
|
4235
|
+
return;
|
|
4236
|
+
}
|
|
4237
|
+
const child = spawn(spec.command, spec.args, { stdio: "ignore", detached: true });
|
|
4238
|
+
child.on("error", (error) => {
|
|
4239
|
+
onError(`could not send the notification (${error.message})`);
|
|
4240
|
+
});
|
|
4241
|
+
child.on("exit", (code) => {
|
|
4242
|
+
if (code !== null && code !== 0) {
|
|
4243
|
+
onError(`could not send the notification (${spec.command} exited with ${code})`);
|
|
4244
|
+
}
|
|
4245
|
+
});
|
|
4246
|
+
child.unref();
|
|
4247
|
+
}
|
|
4248
|
+
|
|
4088
4249
|
// src/tui/components/SettingsModal.tsx
|
|
4089
4250
|
import { jsx as jsx12, jsxs as jsxs11 } from "@opentui/react/jsx-runtime";
|
|
4090
4251
|
var SECTIONS2 = [];
|
|
@@ -4098,12 +4259,23 @@ for (const setting of SETTINGS) {
|
|
|
4098
4259
|
}
|
|
4099
4260
|
function SettingsModal({
|
|
4100
4261
|
selected,
|
|
4262
|
+
editing,
|
|
4263
|
+
error,
|
|
4101
4264
|
cacheAction,
|
|
4102
4265
|
noCache: noCache2,
|
|
4266
|
+
autoReload: autoReload2,
|
|
4267
|
+
reloadInterval: reloadInterval2,
|
|
4268
|
+
notifications: notifications2,
|
|
4269
|
+
notifyChannel: notifyChannel2,
|
|
4103
4270
|
copyLinks: copyLinks2,
|
|
4104
|
-
preset
|
|
4271
|
+
preset,
|
|
4272
|
+
onDraft,
|
|
4273
|
+
onSubmit
|
|
4105
4274
|
}) {
|
|
4106
4275
|
const message = cacheAction === null ? null : CACHE_MESSAGES[cacheAction];
|
|
4276
|
+
const renderer2 = useRenderer2();
|
|
4277
|
+
const channelValue = notifyChannel2 === "command" ? notificationTool() ?? "unsupported" : notifyChannel2;
|
|
4278
|
+
const deliveryValue = notificationChannel(renderer2, notifyChannel2) ?? "unsupported";
|
|
4107
4279
|
return /* @__PURE__ */ jsxs11(ModalFrame, { title: "Settings", children: [
|
|
4108
4280
|
SECTIONS2.map((section) => /* @__PURE__ */ jsxs11("box", { flexDirection: "column", marginBottom: 1, children: [
|
|
4109
4281
|
/* @__PURE__ */ jsx12("text", { wrapMode: "none", fg: theme.accent, marginLeft: 2, children: section.title }),
|
|
@@ -4114,24 +4286,50 @@ function SettingsModal({
|
|
|
4114
4286
|
{
|
|
4115
4287
|
setting,
|
|
4116
4288
|
isSelected,
|
|
4289
|
+
isEditing: isSelected && editing,
|
|
4117
4290
|
cacheAction,
|
|
4118
4291
|
noCache: noCache2,
|
|
4292
|
+
autoReload: autoReload2,
|
|
4293
|
+
reloadInterval: reloadInterval2,
|
|
4294
|
+
notifications: notifications2,
|
|
4295
|
+
channelValue,
|
|
4296
|
+
deliveryValue,
|
|
4119
4297
|
copyLinks: copyLinks2,
|
|
4120
|
-
preset
|
|
4298
|
+
preset,
|
|
4299
|
+
onDraft,
|
|
4300
|
+
onSubmit
|
|
4121
4301
|
}
|
|
4122
4302
|
) }, setting.key);
|
|
4123
4303
|
})
|
|
4124
4304
|
] }, section.title)),
|
|
4125
|
-
/* @__PURE__ */ jsx12(
|
|
4305
|
+
/* @__PURE__ */ jsx12(
|
|
4306
|
+
"text",
|
|
4307
|
+
{
|
|
4308
|
+
wrapMode: "word",
|
|
4309
|
+
height: 2,
|
|
4310
|
+
fg: error !== null ? theme.error : message?.warn ? theme.warn : theme.muted,
|
|
4311
|
+
marginLeft: 2,
|
|
4312
|
+
marginRight: 2,
|
|
4313
|
+
children: error ?? message?.text ?? SETTINGS[selected].hint
|
|
4314
|
+
}
|
|
4315
|
+
)
|
|
4126
4316
|
] });
|
|
4127
4317
|
}
|
|
4128
4318
|
function SettingValue({
|
|
4129
4319
|
setting,
|
|
4130
4320
|
isSelected,
|
|
4321
|
+
isEditing,
|
|
4131
4322
|
cacheAction,
|
|
4132
4323
|
noCache: noCache2,
|
|
4324
|
+
autoReload: autoReload2,
|
|
4325
|
+
reloadInterval: reloadInterval2,
|
|
4326
|
+
notifications: notifications2,
|
|
4327
|
+
channelValue,
|
|
4328
|
+
deliveryValue,
|
|
4133
4329
|
copyLinks: copyLinks2,
|
|
4134
|
-
preset
|
|
4330
|
+
preset,
|
|
4331
|
+
onDraft,
|
|
4332
|
+
onSubmit
|
|
4135
4333
|
}) {
|
|
4136
4334
|
switch (setting.key) {
|
|
4137
4335
|
case "noCache": {
|
|
@@ -4140,6 +4338,24 @@ function SettingValue({
|
|
|
4140
4338
|
case "clearCache": {
|
|
4141
4339
|
return /* @__PURE__ */ jsx12(PathValue, { path: cacheDir(), confirming: cacheAction === "confirm", isSelected });
|
|
4142
4340
|
}
|
|
4341
|
+
case "autoReload": {
|
|
4342
|
+
return /* @__PURE__ */ jsx12(ToggleValue, { value: autoReload2 ? "yes" : "no", isSelected });
|
|
4343
|
+
}
|
|
4344
|
+
case "reloadInterval": {
|
|
4345
|
+
if (isEditing) {
|
|
4346
|
+
return /* @__PURE__ */ jsx12(ModalInput, { width: 16, value: reloadInterval2, onDraft, onSubmit });
|
|
4347
|
+
}
|
|
4348
|
+
return /* @__PURE__ */ jsx12(IntervalValue, { value: reloadInterval2, active: autoReload2, isSelected });
|
|
4349
|
+
}
|
|
4350
|
+
case "notifications": {
|
|
4351
|
+
return /* @__PURE__ */ jsx12(ToggleValue, { value: notifications2 ? "yes" : "no", isSelected });
|
|
4352
|
+
}
|
|
4353
|
+
case "notifyChannel": {
|
|
4354
|
+
return /* @__PURE__ */ jsx12(ToggleValue, { value: channelValue, isSelected });
|
|
4355
|
+
}
|
|
4356
|
+
case "testNotification": {
|
|
4357
|
+
return /* @__PURE__ */ jsx12("text", { wrapMode: "none", fg: isSelected ? theme.text : theme.muted, children: deliveryValue });
|
|
4358
|
+
}
|
|
4143
4359
|
case "copyLinks": {
|
|
4144
4360
|
return /* @__PURE__ */ jsx12(ToggleValue, { value: copyLinks2 ? "yes" : "no", isSelected });
|
|
4145
4361
|
}
|
|
@@ -4170,6 +4386,15 @@ function ToggleValue({ value: value2, isSelected }) {
|
|
|
4170
4386
|
}
|
|
4171
4387
|
return /* @__PURE__ */ jsx12("text", { wrapMode: "none", fg: theme.muted, children: value2 });
|
|
4172
4388
|
}
|
|
4389
|
+
function IntervalValue({ value: value2, active, isSelected }) {
|
|
4390
|
+
if (!active) {
|
|
4391
|
+
return /* @__PURE__ */ jsx12("text", { wrapMode: "none", fg: isSelected ? theme.muted : theme.dim, children: value2 });
|
|
4392
|
+
}
|
|
4393
|
+
if (isSelected) {
|
|
4394
|
+
return /* @__PURE__ */ jsx12("text", { wrapMode: "none", children: /* @__PURE__ */ jsx12("b", { fg: theme.text, children: value2 }) });
|
|
4395
|
+
}
|
|
4396
|
+
return /* @__PURE__ */ jsx12("text", { wrapMode: "none", fg: theme.muted, children: value2 });
|
|
4397
|
+
}
|
|
4173
4398
|
function PathValue({ path, confirming, isSelected }) {
|
|
4174
4399
|
if (confirming) {
|
|
4175
4400
|
return /* @__PURE__ */ jsx12("text", { wrapMode: "none", children: /* @__PURE__ */ jsx12("b", { fg: theme.warn, children: "enter to confirm" }) });
|
|
@@ -4217,24 +4442,7 @@ function ColorRow({
|
|
|
4217
4442
|
}) {
|
|
4218
4443
|
const value2 = themeColorText(color.key);
|
|
4219
4444
|
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: [
|
|
4445
|
+
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
4446
|
Array.isArray(swatch) ? /* @__PURE__ */ jsxs12(Fragment4, { children: [
|
|
4239
4447
|
/* @__PURE__ */ jsx13("span", { fg: swatch[0], children: "\u2588" }),
|
|
4240
4448
|
/* @__PURE__ */ jsx13("span", { fg: swatch[1], children: "\u2588" }),
|
|
@@ -4253,10 +4461,15 @@ function Modals({
|
|
|
4253
4461
|
options,
|
|
4254
4462
|
saved: saved2,
|
|
4255
4463
|
noCache: noCache2,
|
|
4464
|
+
autoReload: autoReload2,
|
|
4465
|
+
reloadInterval: reloadInterval2,
|
|
4466
|
+
notifications: notifications2,
|
|
4467
|
+
notifyChannel: notifyChannel2,
|
|
4256
4468
|
copyLinks: copyLinks2,
|
|
4257
4469
|
themeState,
|
|
4258
4470
|
onDraft,
|
|
4259
4471
|
onSubmitField,
|
|
4472
|
+
onSubmitReloadInterval,
|
|
4260
4473
|
onSubmitThemeColor,
|
|
4261
4474
|
onToggleReviewType,
|
|
4262
4475
|
onToggleWorkDay
|
|
@@ -4282,10 +4495,18 @@ function Modals({
|
|
|
4282
4495
|
SettingsModal,
|
|
4283
4496
|
{
|
|
4284
4497
|
selected: ui.selectedSetting,
|
|
4498
|
+
editing: ui.editing,
|
|
4499
|
+
error: ui.settingError,
|
|
4285
4500
|
cacheAction: ui.cacheAction,
|
|
4286
4501
|
noCache: noCache2,
|
|
4502
|
+
autoReload: autoReload2,
|
|
4503
|
+
reloadInterval: reloadInterval2,
|
|
4504
|
+
notifications: notifications2,
|
|
4505
|
+
notifyChannel: notifyChannel2,
|
|
4287
4506
|
copyLinks: copyLinks2,
|
|
4288
|
-
preset: themeState.preset
|
|
4507
|
+
preset: themeState.preset,
|
|
4508
|
+
onDraft,
|
|
4509
|
+
onSubmit: onSubmitReloadInterval
|
|
4289
4510
|
}
|
|
4290
4511
|
);
|
|
4291
4512
|
}
|
|
@@ -4306,12 +4527,31 @@ function Modals({
|
|
|
4306
4527
|
return null;
|
|
4307
4528
|
}
|
|
4308
4529
|
|
|
4530
|
+
// src/tui/hooks/useAutoReload.ts
|
|
4531
|
+
import { useEffect as useEffect4, useEffectEvent } from "react";
|
|
4532
|
+
function useAutoReload(intervalMs, loading, reload) {
|
|
4533
|
+
const tick = useEffectEvent(() => {
|
|
4534
|
+
reload();
|
|
4535
|
+
});
|
|
4536
|
+
useEffect4(() => {
|
|
4537
|
+
if (intervalMs === null || loading) {
|
|
4538
|
+
return void 0;
|
|
4539
|
+
}
|
|
4540
|
+
const timer = setTimeout(() => {
|
|
4541
|
+
tick();
|
|
4542
|
+
}, intervalMs);
|
|
4543
|
+
return () => {
|
|
4544
|
+
clearTimeout(timer);
|
|
4545
|
+
};
|
|
4546
|
+
}, [intervalMs, loading]);
|
|
4547
|
+
}
|
|
4548
|
+
|
|
4309
4549
|
// src/tui/hooks/useDeferredLoading.ts
|
|
4310
|
-
import { useEffect as
|
|
4550
|
+
import { useEffect as useEffect5, useRef as useRef3, useState as useState2 } from "react";
|
|
4311
4551
|
function useDeferredLoading(isLoading, { showDelay = 300, minDuration = 500 } = {}) {
|
|
4312
4552
|
const [visible, setVisible] = useState2(isLoading && showDelay === 0);
|
|
4313
4553
|
const shownAtRef = useRef3(null);
|
|
4314
|
-
|
|
4554
|
+
useEffect5(() => {
|
|
4315
4555
|
if (isLoading) {
|
|
4316
4556
|
const timer2 = setTimeout(() => {
|
|
4317
4557
|
shownAtRef.current = Date.now();
|
|
@@ -4335,7 +4575,7 @@ function useDeferredLoading(isLoading, { showDelay = 300, minDuration = 500 } =
|
|
|
4335
4575
|
}
|
|
4336
4576
|
|
|
4337
4577
|
// src/tui/hooks/useLoader.ts
|
|
4338
|
-
import { useEffect as
|
|
4578
|
+
import { useEffect as useEffect6, useRef as useRef4, useState as useState3 } from "react";
|
|
4339
4579
|
function useLoader(options, noCache2, onLoaded) {
|
|
4340
4580
|
const [startupSnapshot] = useState3(() => noCache2 ? null : loadSnapshot(options));
|
|
4341
4581
|
const [raw, setRaw] = useState3(startupSnapshot);
|
|
@@ -4385,7 +4625,7 @@ function useLoader(options, noCache2, onLoaded) {
|
|
|
4385
4625
|
void run(bypassCache || noCache2);
|
|
4386
4626
|
};
|
|
4387
4627
|
const startedRef = useRef4(false);
|
|
4388
|
-
|
|
4628
|
+
useEffect6(() => {
|
|
4389
4629
|
disposedRef.current = false;
|
|
4390
4630
|
if (!startedRef.current) {
|
|
4391
4631
|
startedRef.current = true;
|
|
@@ -4406,6 +4646,85 @@ function useLoader(options, noCache2, onLoaded) {
|
|
|
4406
4646
|
};
|
|
4407
4647
|
}
|
|
4408
4648
|
|
|
4649
|
+
// src/tui/hooks/useReviewNotifications.ts
|
|
4650
|
+
import { useRef as useRef5 } from "react";
|
|
4651
|
+
|
|
4652
|
+
// src/tui/data/notifications.ts
|
|
4653
|
+
function diffReviewRequests(previous, results) {
|
|
4654
|
+
const baseline = /* @__PURE__ */ new Map();
|
|
4655
|
+
const reviewedKeys = /* @__PURE__ */ new Set();
|
|
4656
|
+
const pending = [];
|
|
4657
|
+
for (const result of results) {
|
|
4658
|
+
const key = `${result.pr.repo}#${result.pr.number}`;
|
|
4659
|
+
if (result.kind === "reviewed") {
|
|
4660
|
+
reviewedKeys.add(key);
|
|
4661
|
+
} else if (result.kind === "pending" && result.pr.state === "open") {
|
|
4662
|
+
const requestedAt = result.requestedAt.getTime();
|
|
4663
|
+
baseline.set(key, requestedAt);
|
|
4664
|
+
pending.push({ key, pr: result.pr, requestedAt });
|
|
4665
|
+
}
|
|
4666
|
+
}
|
|
4667
|
+
if (previous === null) {
|
|
4668
|
+
return { baseline, newRequests: [], reRequests: [] };
|
|
4669
|
+
}
|
|
4670
|
+
const newRequests = [];
|
|
4671
|
+
const reRequests = [];
|
|
4672
|
+
for (const { key, pr, requestedAt } of pending) {
|
|
4673
|
+
const before = previous.get(key);
|
|
4674
|
+
if (before !== void 0 && before >= requestedAt) {
|
|
4675
|
+
continue;
|
|
4676
|
+
}
|
|
4677
|
+
(reviewedKeys.has(key) ? reRequests : newRequests).push(pr);
|
|
4678
|
+
}
|
|
4679
|
+
return { baseline, newRequests, reRequests };
|
|
4680
|
+
}
|
|
4681
|
+
var MAX_LISTED = 3;
|
|
4682
|
+
var TEST_NOTIFICATION = {
|
|
4683
|
+
title: "pr-stats",
|
|
4684
|
+
body: "Desktop notifications are working. New review requests show up like this."
|
|
4685
|
+
};
|
|
4686
|
+
function describeReviewRequests(changes) {
|
|
4687
|
+
const notifications2 = [];
|
|
4688
|
+
if (changes.newRequests.length > 0) {
|
|
4689
|
+
notifications2.push(describe(changes.newRequests, "Review requested on", "new PRs awaiting your review"));
|
|
4690
|
+
}
|
|
4691
|
+
if (changes.reRequests.length > 0) {
|
|
4692
|
+
notifications2.push(describe(changes.reRequests, "Review re-requested on", "PRs came back for review"));
|
|
4693
|
+
}
|
|
4694
|
+
return notifications2;
|
|
4695
|
+
}
|
|
4696
|
+
function describe(prs, singleTitle, pluralTitle) {
|
|
4697
|
+
if (prs.length === 1) {
|
|
4698
|
+
const [pr] = prs;
|
|
4699
|
+
return { title: `${singleTitle} ${refOf(pr)}`, body: pr.title };
|
|
4700
|
+
}
|
|
4701
|
+
const lines = prs.slice(0, MAX_LISTED).map((pr) => `${refOf(pr)} ${pr.title}`);
|
|
4702
|
+
const rest = prs.length - lines.length;
|
|
4703
|
+
if (rest > 0) {
|
|
4704
|
+
lines.push(`and ${rest} more`);
|
|
4705
|
+
}
|
|
4706
|
+
return { title: `${prs.length} ${pluralTitle}`, body: lines.join("\n") };
|
|
4707
|
+
}
|
|
4708
|
+
function refOf(pr) {
|
|
4709
|
+
return `${pr.repo}#${pr.number}`;
|
|
4710
|
+
}
|
|
4711
|
+
|
|
4712
|
+
// src/tui/hooks/useReviewNotifications.ts
|
|
4713
|
+
function useReviewNotifications(enabled2, notify, onError) {
|
|
4714
|
+
const baselineRef = useRef5(null);
|
|
4715
|
+
return (key, results) => {
|
|
4716
|
+
const previous = baselineRef.current?.key === key ? baselineRef.current.baseline : null;
|
|
4717
|
+
const changes = diffReviewRequests(previous, results);
|
|
4718
|
+
baselineRef.current = { key, baseline: changes.baseline };
|
|
4719
|
+
if (!enabled2) {
|
|
4720
|
+
return;
|
|
4721
|
+
}
|
|
4722
|
+
for (const notification of describeReviewRequests(changes)) {
|
|
4723
|
+
notify(notification.title, notification.body, onError);
|
|
4724
|
+
}
|
|
4725
|
+
};
|
|
4726
|
+
}
|
|
4727
|
+
|
|
4409
4728
|
// src/tui/hooks/useViewModel.ts
|
|
4410
4729
|
import { useMemo } from "react";
|
|
4411
4730
|
|
|
@@ -6081,6 +6400,43 @@ function handleSettingsModalKey(key, context) {
|
|
|
6081
6400
|
}
|
|
6082
6401
|
break;
|
|
6083
6402
|
}
|
|
6403
|
+
case "autoReload": {
|
|
6404
|
+
const next = !context.autoReload;
|
|
6405
|
+
context.setAutoReload(next);
|
|
6406
|
+
context.dispatchUi({ type: "cacheActionReported", action: saveAutoReload(next) ? "saved" : "notSaved" });
|
|
6407
|
+
break;
|
|
6408
|
+
}
|
|
6409
|
+
case "reloadInterval": {
|
|
6410
|
+
if (key.name !== "return") {
|
|
6411
|
+
break;
|
|
6412
|
+
}
|
|
6413
|
+
context.beginEdit(context.reloadInterval);
|
|
6414
|
+
break;
|
|
6415
|
+
}
|
|
6416
|
+
case "notifications": {
|
|
6417
|
+
const next = !context.notifications;
|
|
6418
|
+
context.setNotifications(next);
|
|
6419
|
+
context.dispatchUi({ type: "cacheActionReported", action: saveNotifications(next) ? "saved" : "notSaved" });
|
|
6420
|
+
break;
|
|
6421
|
+
}
|
|
6422
|
+
case "notifyChannel": {
|
|
6423
|
+
const index = NOTIFY_CHANNELS.indexOf(context.notifyChannel);
|
|
6424
|
+
const step = key.name === "left" ? -1 : 1;
|
|
6425
|
+
const next = NOTIFY_CHANNELS[(index + step + NOTIFY_CHANNELS.length) % NOTIFY_CHANNELS.length];
|
|
6426
|
+
context.setNotifyChannel(next);
|
|
6427
|
+
context.dispatchUi({ type: "cacheActionReported", action: saveNotifyChannel(next) ? "saved" : "notSaved" });
|
|
6428
|
+
break;
|
|
6429
|
+
}
|
|
6430
|
+
case "testNotification": {
|
|
6431
|
+
if (key.name !== "return") {
|
|
6432
|
+
break;
|
|
6433
|
+
}
|
|
6434
|
+
context.notify(TEST_NOTIFICATION.title, TEST_NOTIFICATION.body, (message) => {
|
|
6435
|
+
context.dispatchUi({ type: "openErrorReported", message });
|
|
6436
|
+
});
|
|
6437
|
+
context.dispatchUi({ type: "cacheActionReported", action: "notified" });
|
|
6438
|
+
break;
|
|
6439
|
+
}
|
|
6084
6440
|
case "copyLinks": {
|
|
6085
6441
|
const next = !context.copyLinks;
|
|
6086
6442
|
context.setCopyLinks(next);
|
|
@@ -6355,6 +6711,7 @@ var initialUiState = {
|
|
|
6355
6711
|
selectedSetting: 0,
|
|
6356
6712
|
selectedThemeColor: 0,
|
|
6357
6713
|
fieldError: null,
|
|
6714
|
+
settingError: null,
|
|
6358
6715
|
themeColorError: null,
|
|
6359
6716
|
cacheAction: null,
|
|
6360
6717
|
openError: null,
|
|
@@ -6381,7 +6738,14 @@ function uiReducer(state, action) {
|
|
|
6381
6738
|
return { ...state, successNotice: null };
|
|
6382
6739
|
}
|
|
6383
6740
|
case "modalOpened": {
|
|
6384
|
-
return {
|
|
6741
|
+
return {
|
|
6742
|
+
...state,
|
|
6743
|
+
modal: action.modal,
|
|
6744
|
+
fieldError: null,
|
|
6745
|
+
settingError: null,
|
|
6746
|
+
themeColorError: null,
|
|
6747
|
+
cacheAction: null
|
|
6748
|
+
};
|
|
6385
6749
|
}
|
|
6386
6750
|
case "optionsModalClosed": {
|
|
6387
6751
|
return { ...state, modal: null, fieldError: null };
|
|
@@ -6390,7 +6754,7 @@ function uiReducer(state, action) {
|
|
|
6390
6754
|
if (state.cacheAction === "confirm" || state.cacheAction === "resetConfirm") {
|
|
6391
6755
|
return { ...state, cacheAction: null };
|
|
6392
6756
|
}
|
|
6393
|
-
return { ...state, modal: null, cacheAction: null };
|
|
6757
|
+
return { ...state, modal: null, settingError: null, cacheAction: null };
|
|
6394
6758
|
}
|
|
6395
6759
|
case "themeModalClosed": {
|
|
6396
6760
|
return { ...state, modal: "settings", themeColorError: null, cacheAction: null };
|
|
@@ -6402,6 +6766,7 @@ function uiReducer(state, action) {
|
|
|
6402
6766
|
return {
|
|
6403
6767
|
...state,
|
|
6404
6768
|
selectedSetting: cycled2(state.selectedSetting, action.delta, SETTINGS.length),
|
|
6769
|
+
settingError: null,
|
|
6405
6770
|
cacheAction: null
|
|
6406
6771
|
};
|
|
6407
6772
|
}
|
|
@@ -6414,10 +6779,10 @@ function uiReducer(state, action) {
|
|
|
6414
6779
|
};
|
|
6415
6780
|
}
|
|
6416
6781
|
case "editStarted": {
|
|
6417
|
-
return { ...state, editing: true, fieldError: null, themeColorError: null };
|
|
6782
|
+
return { ...state, editing: true, fieldError: null, settingError: null, themeColorError: null };
|
|
6418
6783
|
}
|
|
6419
6784
|
case "editCancelled": {
|
|
6420
|
-
return { ...state, editing: false, fieldError: null, themeColorError: null };
|
|
6785
|
+
return { ...state, editing: false, fieldError: null, settingError: null, themeColorError: null };
|
|
6421
6786
|
}
|
|
6422
6787
|
case "fieldCommitted": {
|
|
6423
6788
|
return { ...state, editing: false, fieldError: null };
|
|
@@ -6431,6 +6796,12 @@ function uiReducer(state, action) {
|
|
|
6431
6796
|
}
|
|
6432
6797
|
return { ...state, fieldError: "the cache is disabled for this session \xB7 options not saved" };
|
|
6433
6798
|
}
|
|
6799
|
+
case "settingCommitted": {
|
|
6800
|
+
return { ...state, editing: false, settingError: null, cacheAction: action.action };
|
|
6801
|
+
}
|
|
6802
|
+
case "settingErrorReported": {
|
|
6803
|
+
return { ...state, settingError: action.message };
|
|
6804
|
+
}
|
|
6434
6805
|
case "themeColorCommitted": {
|
|
6435
6806
|
return { ...state, editing: false, themeColorError: null, cacheAction: action.action };
|
|
6436
6807
|
}
|
|
@@ -6445,7 +6816,7 @@ function uiReducer(state, action) {
|
|
|
6445
6816
|
}
|
|
6446
6817
|
|
|
6447
6818
|
// src/tui/utils/browser.ts
|
|
6448
|
-
import { spawn } from "node:child_process";
|
|
6819
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
6449
6820
|
function openCommand(url) {
|
|
6450
6821
|
switch (process.platform) {
|
|
6451
6822
|
case "darwin": {
|
|
@@ -6461,7 +6832,7 @@ function openCommand(url) {
|
|
|
6461
6832
|
}
|
|
6462
6833
|
function openInBrowser(url, onError) {
|
|
6463
6834
|
const { command, args } = openCommand(url);
|
|
6464
|
-
const child =
|
|
6835
|
+
const child = spawn2(command, args, { stdio: "ignore", detached: true });
|
|
6465
6836
|
child.on("error", (error) => {
|
|
6466
6837
|
onError(`could not open the browser (${error.message})`);
|
|
6467
6838
|
});
|
|
@@ -6508,14 +6879,19 @@ function App({
|
|
|
6508
6879
|
initial: initial2,
|
|
6509
6880
|
initialSaved = null,
|
|
6510
6881
|
initialNoCache = false,
|
|
6882
|
+
initialAutoReload = false,
|
|
6883
|
+
initialReloadInterval = DEFAULT_RELOAD_INTERVAL,
|
|
6884
|
+
initialNotifications = false,
|
|
6885
|
+
initialNotifyChannel = "auto",
|
|
6511
6886
|
initialCopyLinks = false,
|
|
6512
6887
|
initialTheme = defaultThemeState(),
|
|
6513
6888
|
openUrl = openInBrowser,
|
|
6514
6889
|
copyUrl,
|
|
6890
|
+
notify,
|
|
6515
6891
|
onQuit
|
|
6516
6892
|
}) {
|
|
6517
6893
|
const { width } = useTerminalDimensions3();
|
|
6518
|
-
const renderer2 =
|
|
6894
|
+
const renderer2 = useRenderer3();
|
|
6519
6895
|
const defaultCopyUrl = useMemo2(() => createClipboardCopier(renderer2), [renderer2]);
|
|
6520
6896
|
const copyLink = copyUrl ?? defaultCopyUrl;
|
|
6521
6897
|
const [ui, dispatchUi] = useReducer(uiReducer, initialUiState);
|
|
@@ -6523,15 +6899,21 @@ function App({
|
|
|
6523
6899
|
const [options, setOptions] = useState4(initial2);
|
|
6524
6900
|
const [saved2, setSaved] = useState4(initialSaved);
|
|
6525
6901
|
const [noCache2, setNoCache] = useState4(initialNoCache);
|
|
6902
|
+
const [autoReload2, setAutoReload] = useState4(initialAutoReload);
|
|
6903
|
+
const [reloadInterval2, setReloadInterval] = useState4(initialReloadInterval);
|
|
6904
|
+
const [notifications2, setNotifications] = useState4(initialNotifications);
|
|
6905
|
+
const [notifyChannel2, setNotifyChannel] = useState4(initialNotifyChannel);
|
|
6526
6906
|
const [copyLinks2, setCopyLinks] = useState4(initialCopyLinks);
|
|
6527
6907
|
const [themeState, setThemeState] = useState4(initialTheme);
|
|
6908
|
+
const defaultNotify = useMemo2(() => createNotifier(renderer2, notifyChannel2), [renderer2, notifyChannel2]);
|
|
6909
|
+
const notifier = notify ?? defaultNotify;
|
|
6528
6910
|
const copyRow = (row) => {
|
|
6529
6911
|
copyLink(row.url, (message) => {
|
|
6530
6912
|
dispatchUi({ type: "openErrorReported", message });
|
|
6531
6913
|
});
|
|
6532
6914
|
dispatchUi({ type: "copyReported", message: `copied ${row.ref} to the clipboard` });
|
|
6533
6915
|
};
|
|
6534
|
-
|
|
6916
|
+
useEffect7(() => {
|
|
6535
6917
|
if (ui.successNotice === null) {
|
|
6536
6918
|
return void 0;
|
|
6537
6919
|
}
|
|
@@ -6542,10 +6924,13 @@ function App({
|
|
|
6542
6924
|
clearTimeout(timer);
|
|
6543
6925
|
};
|
|
6544
6926
|
}, [ui.successNotice]);
|
|
6545
|
-
const reviewScrollRef =
|
|
6546
|
-
const sizeScrollRef =
|
|
6547
|
-
const commentScrollRef =
|
|
6548
|
-
const mergedScrollRef =
|
|
6927
|
+
const reviewScrollRef = useRef6(null);
|
|
6928
|
+
const sizeScrollRef = useRef6(null);
|
|
6929
|
+
const commentScrollRef = useRef6(null);
|
|
6930
|
+
const mergedScrollRef = useRef6(null);
|
|
6931
|
+
const notifyReviewChanges = useReviewNotifications(notifications2, notifier, (message) => {
|
|
6932
|
+
dispatchUi({ type: "openErrorReported", message });
|
|
6933
|
+
});
|
|
6549
6934
|
const { raw, isSnapshot, loading, load, error, stale, reload } = useLoader(options, noCache2, (data) => {
|
|
6550
6935
|
dispatchBrowse({
|
|
6551
6936
|
type: "dataLoaded",
|
|
@@ -6558,10 +6943,12 @@ function App({
|
|
|
6558
6943
|
merged: buildMergedRepoOptions(data)
|
|
6559
6944
|
}
|
|
6560
6945
|
});
|
|
6946
|
+
notifyReviewChanges(fetchParamsKey(options), data.reviewResults);
|
|
6561
6947
|
});
|
|
6948
|
+
useAutoReload(autoReload2 ? reloadIntervalMs(reloadInterval2) : null, loading, reload);
|
|
6562
6949
|
const views = useViewModel(raw, options, width, browse.scopes, browse.grouped, browse.expanded, themeState);
|
|
6563
6950
|
const showLoad = useDeferredLoading(loading, isSnapshot ? { showDelay: 0 } : void 0);
|
|
6564
|
-
const draftRef =
|
|
6951
|
+
const draftRef = useRef6("");
|
|
6565
6952
|
const commitField = () => {
|
|
6566
6953
|
const field = FIELDS[ui.selectedField];
|
|
6567
6954
|
const value2 = draftRef.current.trim();
|
|
@@ -6579,6 +6966,20 @@ function App({
|
|
|
6579
6966
|
throw error2;
|
|
6580
6967
|
}
|
|
6581
6968
|
};
|
|
6969
|
+
const commitReloadInterval = () => {
|
|
6970
|
+
const value2 = draftRef.current.trim();
|
|
6971
|
+
try {
|
|
6972
|
+
parseReloadInterval(value2);
|
|
6973
|
+
setReloadInterval(value2);
|
|
6974
|
+
dispatchUi({ type: "settingCommitted", action: saveReloadInterval(value2) ? "saved" : "notSaved" });
|
|
6975
|
+
} catch (error2) {
|
|
6976
|
+
if (error2 instanceof CliError) {
|
|
6977
|
+
dispatchUi({ type: "settingErrorReported", message: error2.message });
|
|
6978
|
+
return;
|
|
6979
|
+
}
|
|
6980
|
+
throw error2;
|
|
6981
|
+
}
|
|
6982
|
+
};
|
|
6582
6983
|
const commitThemeColor = () => {
|
|
6583
6984
|
try {
|
|
6584
6985
|
const next = withColorOverride(themeState, THEME_COLORS[ui.selectedThemeColor].key, draftRef.current.trim());
|
|
@@ -6594,10 +6995,17 @@ function App({
|
|
|
6594
6995
|
}
|
|
6595
6996
|
};
|
|
6596
6997
|
useKeyboard((key) => {
|
|
6998
|
+
if (ui.editing && key.name === "escape") {
|
|
6999
|
+
renderer2.currentFocusedRenderable?.blur();
|
|
7000
|
+
}
|
|
6597
7001
|
handleAppKey(key, {
|
|
6598
7002
|
ui,
|
|
6599
7003
|
browse,
|
|
6600
7004
|
noCache: noCache2,
|
|
7005
|
+
autoReload: autoReload2,
|
|
7006
|
+
reloadInterval: reloadInterval2,
|
|
7007
|
+
notifications: notifications2,
|
|
7008
|
+
notifyChannel: notifyChannel2,
|
|
6601
7009
|
copyLinks: copyLinks2,
|
|
6602
7010
|
themeState,
|
|
6603
7011
|
options,
|
|
@@ -6608,11 +7016,15 @@ function App({
|
|
|
6608
7016
|
setOptions,
|
|
6609
7017
|
setSaved,
|
|
6610
7018
|
setNoCache,
|
|
7019
|
+
setAutoReload,
|
|
7020
|
+
setNotifications,
|
|
7021
|
+
setNotifyChannel,
|
|
6611
7022
|
setCopyLinks,
|
|
6612
7023
|
setThemeState,
|
|
6613
7024
|
quit: onQuit,
|
|
6614
7025
|
reload,
|
|
6615
7026
|
openUrl,
|
|
7027
|
+
notify: notifier,
|
|
6616
7028
|
copyRow,
|
|
6617
7029
|
beginEdit: (value2) => {
|
|
6618
7030
|
draftRef.current = value2;
|
|
@@ -6626,7 +7038,16 @@ function App({
|
|
|
6626
7038
|
});
|
|
6627
7039
|
const capWarning = raw?.searchCapped ? "Warning, a search hit the 1000 result cap, so data may be incomplete. Narrow since or repos." : null;
|
|
6628
7040
|
return /* @__PURE__ */ jsxs13("box", { flexDirection: "column", width: "100%", height: "100%", backgroundColor: theme.bg, children: [
|
|
6629
|
-
/* @__PURE__ */ jsx15(
|
|
7041
|
+
/* @__PURE__ */ jsx15(
|
|
7042
|
+
Header,
|
|
7043
|
+
{
|
|
7044
|
+
options,
|
|
7045
|
+
raw,
|
|
7046
|
+
error,
|
|
7047
|
+
spinning: showLoad,
|
|
7048
|
+
reloadEvery: autoReload2 ? reloadInterval2 : null
|
|
7049
|
+
}
|
|
7050
|
+
),
|
|
6630
7051
|
/* @__PURE__ */ jsx15(TabBar, { tab: browse.tab }),
|
|
6631
7052
|
/* @__PURE__ */ jsx15(
|
|
6632
7053
|
MainPanel,
|
|
@@ -6669,12 +7090,17 @@ function App({
|
|
|
6669
7090
|
options,
|
|
6670
7091
|
saved: saved2,
|
|
6671
7092
|
noCache: noCache2,
|
|
7093
|
+
autoReload: autoReload2,
|
|
7094
|
+
reloadInterval: reloadInterval2,
|
|
7095
|
+
notifications: notifications2,
|
|
7096
|
+
notifyChannel: notifyChannel2,
|
|
6672
7097
|
copyLinks: copyLinks2,
|
|
6673
7098
|
themeState,
|
|
6674
7099
|
onDraft: (value2) => {
|
|
6675
7100
|
draftRef.current = value2;
|
|
6676
7101
|
},
|
|
6677
7102
|
onSubmitField: commitField,
|
|
7103
|
+
onSubmitReloadInterval: commitReloadInterval,
|
|
6678
7104
|
onSubmitThemeColor: commitThemeColor,
|
|
6679
7105
|
onToggleReviewType: (type) => {
|
|
6680
7106
|
setOptions((previous) => {
|
|
@@ -6728,6 +7154,10 @@ function bootstrap() {
|
|
|
6728
7154
|
initial: initial2,
|
|
6729
7155
|
saved: saved2,
|
|
6730
7156
|
noCache: values["no-cache"],
|
|
7157
|
+
autoReload: settings.autoReload === true,
|
|
7158
|
+
reloadInterval: settings.reloadInterval ?? DEFAULT_RELOAD_INTERVAL,
|
|
7159
|
+
notifications: settings.notifications === true,
|
|
7160
|
+
notifyChannel: settings.notifyChannel ?? "auto",
|
|
6731
7161
|
copyLinks: settings.copyLinks === true,
|
|
6732
7162
|
theme: theme3,
|
|
6733
7163
|
json: values.json
|
|
@@ -6742,7 +7172,7 @@ function bootstrap() {
|
|
|
6742
7172
|
|
|
6743
7173
|
// src/tui/main.tsx
|
|
6744
7174
|
import { jsx as jsx16 } from "@opentui/react/jsx-runtime";
|
|
6745
|
-
var { initial, saved, noCache, copyLinks, theme: theme2, json } = bootstrap();
|
|
7175
|
+
var { initial, saved, noCache, autoReload, reloadInterval, notifications, notifyChannel, copyLinks, theme: theme2, json } = bootstrap();
|
|
6746
7176
|
if (json) {
|
|
6747
7177
|
await runJsonStats(initial, noCache);
|
|
6748
7178
|
}
|
|
@@ -6761,6 +7191,10 @@ createRoot(renderer).render(
|
|
|
6761
7191
|
initial,
|
|
6762
7192
|
initialSaved: saved,
|
|
6763
7193
|
initialNoCache: noCache,
|
|
7194
|
+
initialAutoReload: autoReload,
|
|
7195
|
+
initialReloadInterval: reloadInterval,
|
|
7196
|
+
initialNotifications: notifications,
|
|
7197
|
+
initialNotifyChannel: notifyChannel,
|
|
6764
7198
|
initialCopyLinks: copyLinks,
|
|
6765
7199
|
initialTheme: theme2,
|
|
6766
7200
|
onQuit: () => {
|