github-search-cli 9.8.3 → 9.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +123 -0
- package/README.md +68 -40
- package/dist/cli.js +992 -172
- package/docs/HOW-TO.md +255 -0
- package/package.json +3 -2
- package/release-notes/9.10.0.md +137 -0
- package/release-notes/9.9.0.md +193 -0
- package/src/cli.ts +13 -11
- package/src/errors.ts +35 -11
- package/src/help.ts +21 -4
- package/src/mcp-server.ts +18 -2
- package/src/search-state.ts +8 -10
- package/src/search.ts +683 -11
- package/src/trending.ts +2 -6
- package/src/tui/mouse.ts +349 -0
- package/src/tui.ts +433 -123
- package/src/types.ts +11 -1
- package/src/update-check.ts +72 -6
- package/src/watch.ts +16 -11
package/src/tui.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
SelectRenderable,
|
|
32
32
|
ScrollBoxRenderable,
|
|
33
33
|
} from "@opentui/core";
|
|
34
|
+
import { appendFileSync } from "node:fs";
|
|
34
35
|
import type { CliRenderer } from "@opentui/core";
|
|
35
36
|
import type {
|
|
36
37
|
Repo,
|
|
@@ -47,6 +48,8 @@ import {
|
|
|
47
48
|
applyFlagFilters,
|
|
48
49
|
validateQuery,
|
|
49
50
|
suggestFor,
|
|
51
|
+
suggestRelaxations,
|
|
52
|
+
trendingQuery,
|
|
50
53
|
rankRepos,
|
|
51
54
|
createGitHubSearch,
|
|
52
55
|
SearchModule,
|
|
@@ -54,6 +57,13 @@ import {
|
|
|
54
57
|
type Logger,
|
|
55
58
|
} from "./search";
|
|
56
59
|
import { tabSince, TAB_NAMES, fmtStars } from "./trending";
|
|
60
|
+
import {
|
|
61
|
+
attachClickToSelect,
|
|
62
|
+
attachClickOutside,
|
|
63
|
+
attachClickableBox,
|
|
64
|
+
NON_INTERACTIVE_TEXT,
|
|
65
|
+
DOUBLE_CLICK_MS,
|
|
66
|
+
} from "./tui/mouse";
|
|
57
67
|
import { loadConfig, saveConfig } from "./config";
|
|
58
68
|
import { buildHelpSections, HELP_KEYS_COLUMN } from "./help";
|
|
59
69
|
import {
|
|
@@ -120,6 +130,7 @@ import {
|
|
|
120
130
|
fetchReleaseNotes,
|
|
121
131
|
readUpdateState,
|
|
122
132
|
recordPreUpdateState,
|
|
133
|
+
markPostUpgradeSeen,
|
|
123
134
|
} from "./update-check";
|
|
124
135
|
import { debugLog } from "./storage";
|
|
125
136
|
import {
|
|
@@ -152,12 +163,41 @@ const colors: Record<string, string> = {
|
|
|
152
163
|
};
|
|
153
164
|
|
|
154
165
|
// ─── Logger ───────────────────────────────────────────────────────────
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
166
|
+
/**
|
|
167
|
+
* TUI diagnostic log.
|
|
168
|
+
*
|
|
169
|
+
* The TUI owns the screen, so it cannot print debug output to stdout without
|
|
170
|
+
* corrupting the render — and the logger used to be a silent no-op, which made
|
|
171
|
+
* API failures (403/401) undiagnosable from the status-bar message alone.
|
|
172
|
+
*
|
|
173
|
+
* Set GHFIND_LOG=<path> to append timestamped lines to that file. Unset (the
|
|
174
|
+
* default) keeps logging completely off, so there is no cost in normal use.
|
|
175
|
+
* Only the shape of a response is recorded; tokens and headers are redacted.
|
|
176
|
+
*/
|
|
177
|
+
function createTuiLogger(): Logger {
|
|
178
|
+
const target = process.env.GHFIND_LOG;
|
|
179
|
+
if (!target) {
|
|
180
|
+
return { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} };
|
|
181
|
+
}
|
|
182
|
+
const write = (level: string, msg: string) => {
|
|
183
|
+
try {
|
|
184
|
+
appendFileSync(
|
|
185
|
+
target,
|
|
186
|
+
`[${new Date().toISOString()}] [${level}] ${msg}\n`,
|
|
187
|
+
);
|
|
188
|
+
} catch {
|
|
189
|
+
// A broken log path must never take down the TUI.
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
return {
|
|
193
|
+
debug: (m) => write("debug", m),
|
|
194
|
+
info: (m) => write("info", m),
|
|
195
|
+
warn: (m) => write("warn", m),
|
|
196
|
+
error: (m) => write("error", m),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const logger: Logger = createTuiLogger();
|
|
161
201
|
|
|
162
202
|
// ─── Sort cycle ───────────────────────────────────────────────────────
|
|
163
203
|
const SORT_MODES: { key: SortStrategy; label: string }[] = [
|
|
@@ -277,7 +317,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
277
317
|
|
|
278
318
|
const header = new TextRenderable(renderer, {
|
|
279
319
|
content:
|
|
280
|
-
" ghfind — GitHub repo browser [/]search [
|
|
320
|
+
" ghfind — GitHub repo browser [/]search [Esc]menu [\u2192]open [?]help [q]uit · click select · dbl-click open",
|
|
281
321
|
bg: colors.bg,
|
|
282
322
|
fg: colors.muted,
|
|
283
323
|
height: 1,
|
|
@@ -302,15 +342,26 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
302
342
|
const isActive = name === trendingTab;
|
|
303
343
|
const label = ` ${name} `;
|
|
304
344
|
const tt = new TextRenderable(renderer, {
|
|
345
|
+
...NON_INTERACTIVE_TEXT,
|
|
305
346
|
content: label,
|
|
306
347
|
fg: isActive ? colors.bg : colors.muted,
|
|
307
348
|
bg: isActive ? colors.blue : colors.bg,
|
|
308
349
|
height: 1,
|
|
309
350
|
});
|
|
351
|
+
// Click a tab to switch to it — same path as the 1-5 number keys.
|
|
352
|
+
tt.onMouseDown = (event) => {
|
|
353
|
+
event.stopPropagation();
|
|
354
|
+
if (name === trendingTab) return;
|
|
355
|
+
trendingTab = name;
|
|
356
|
+
loadTrending();
|
|
357
|
+
};
|
|
358
|
+
tt.onMouseOver = () => renderer.setMousePointer("pointer");
|
|
359
|
+
tt.onMouseOut = () => renderer.setMousePointer("default");
|
|
310
360
|
trendingTabTexts.push(tt);
|
|
311
361
|
trendingTabBox.add(tt);
|
|
312
362
|
if (i < TAB_NAMES.length - 1) {
|
|
313
363
|
const sp = new TextRenderable(renderer, {
|
|
364
|
+
...NON_INTERACTIVE_TEXT,
|
|
314
365
|
content: " ",
|
|
315
366
|
fg: colors.muted,
|
|
316
367
|
bg: colors.bg,
|
|
@@ -409,6 +460,9 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
409
460
|
content: "",
|
|
410
461
|
fg: colors.text,
|
|
411
462
|
wrapMode: "none",
|
|
463
|
+
// Non-interactive: a left-press here would otherwise be swallowed as a
|
|
464
|
+
// text-selection drag instead of reaching the click handlers.
|
|
465
|
+
selectable: false,
|
|
412
466
|
});
|
|
413
467
|
detailBox.add(detailText);
|
|
414
468
|
|
|
@@ -425,6 +479,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
425
479
|
});
|
|
426
480
|
root.add(landingBox);
|
|
427
481
|
const landingBanner = new TextRenderable(renderer, {
|
|
482
|
+
...NON_INTERACTIVE_TEXT,
|
|
428
483
|
content: [
|
|
429
484
|
" ██████╗ ██╗ ██╗███████╗██╗███╗ ██╗██████╗ ",
|
|
430
485
|
"██╔════╝ ██║ ██║██╔════╝██║████╗ ██║██╔══██╗",
|
|
@@ -507,8 +562,9 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
507
562
|
}
|
|
508
563
|
|
|
509
564
|
const landingHint = new TextRenderable(renderer, {
|
|
565
|
+
...NON_INTERACTIVE_TEXT,
|
|
510
566
|
content:
|
|
511
|
-
" \u2191\u2193 Navigate \u21E9 Select [b]ookmarks [?]help [q]uit",
|
|
567
|
+
" \u2191\u2193 Navigate \u21E9 Select [b]ookmarks [?]help [q]uit · click to pick a mode",
|
|
512
568
|
fg: colors.muted,
|
|
513
569
|
bg: colors.bg,
|
|
514
570
|
height: 1,
|
|
@@ -539,6 +595,34 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
539
595
|
}
|
|
540
596
|
}
|
|
541
597
|
|
|
598
|
+
// ── Landing mouse ───────────────────────────────────────────
|
|
599
|
+
// Click selects, double-click runs the card's action (same as Enter), hover
|
|
600
|
+
// shows a pointer cursor. `updateLandingCards` is the single source of truth
|
|
601
|
+
// for the card colours, so hover and selection cannot drift.
|
|
602
|
+
landingCards.forEach((card, i) => {
|
|
603
|
+
let lastClickAt = 0;
|
|
604
|
+
card.onMouseDown = (event) => {
|
|
605
|
+
event.stopPropagation();
|
|
606
|
+
const now = Date.now();
|
|
607
|
+
const isDouble =
|
|
608
|
+
landingSelected === i && now - lastClickAt < DOUBLE_CLICK_MS;
|
|
609
|
+
lastClickAt = now;
|
|
610
|
+
if (isDouble) {
|
|
611
|
+
landingOptions[i].action();
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
landingSelected = i;
|
|
615
|
+
updateLandingCards();
|
|
616
|
+
renderer.requestRender();
|
|
617
|
+
};
|
|
618
|
+
card.onMouseOver = () => {
|
|
619
|
+
renderer.setMousePointer("pointer");
|
|
620
|
+
};
|
|
621
|
+
card.onMouseOut = () => {
|
|
622
|
+
renderer.setMousePointer("default");
|
|
623
|
+
};
|
|
624
|
+
});
|
|
625
|
+
|
|
542
626
|
// Landing keyboard handler. Registered before the global handler below,
|
|
543
627
|
// and both listeners receive the same key event — so keys the landing
|
|
544
628
|
// screen consumes must be claimed with stopPropagation(), or the global
|
|
@@ -612,6 +696,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
612
696
|
updateDim.visible = false;
|
|
613
697
|
if (type === "none") {
|
|
614
698
|
currentOverlay = "none";
|
|
699
|
+
menuButton.visible = true;
|
|
615
700
|
if (currentMode === "landing") {
|
|
616
701
|
landingBox.visible = true;
|
|
617
702
|
body.visible = false;
|
|
@@ -625,6 +710,9 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
625
710
|
return;
|
|
626
711
|
}
|
|
627
712
|
currentOverlay = type;
|
|
713
|
+
// The button floats above the layout, so it would sit on top of every
|
|
714
|
+
// full-screen overlay (and stay clickable through the dim layer).
|
|
715
|
+
menuButton.visible = false;
|
|
628
716
|
// An overlay opened from the landing screen must hide the menu too
|
|
629
717
|
// (hideMainContent only covers the search/trending/toolbar boxes).
|
|
630
718
|
landingBox.visible = false;
|
|
@@ -912,6 +1000,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
912
1000
|
content: "",
|
|
913
1001
|
fg: colors.text,
|
|
914
1002
|
bg: colors.bg,
|
|
1003
|
+
selectable: false,
|
|
915
1004
|
});
|
|
916
1005
|
compareBox.add(compareText);
|
|
917
1006
|
root.add(compareBox);
|
|
@@ -1007,7 +1096,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
1007
1096
|
savedSelect.setSelectedIndex(0);
|
|
1008
1097
|
}
|
|
1009
1098
|
|
|
1010
|
-
// ── Leader menu overlay (
|
|
1099
|
+
// ── Leader menu overlay (Esc key) ─────────────────────────────
|
|
1011
1100
|
// Dim overlay — sits behind the menu, above main content
|
|
1012
1101
|
const leaderDim = new BoxRenderable(renderer, {
|
|
1013
1102
|
visible: false,
|
|
@@ -1053,7 +1142,9 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
1053
1142
|
itemSpacing: 0,
|
|
1054
1143
|
});
|
|
1055
1144
|
const leaderFooter = new TextRenderable(renderer, {
|
|
1056
|
-
|
|
1145
|
+
...NON_INTERACTIVE_TEXT,
|
|
1146
|
+
content:
|
|
1147
|
+
" ↑↓ select Enter run Esc/q close · click to run click outside to close ",
|
|
1057
1148
|
fg: colors.muted,
|
|
1058
1149
|
bg: colors.surfaceDim,
|
|
1059
1150
|
height: 1,
|
|
@@ -1203,11 +1294,77 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
1203
1294
|
renderer.requestRender();
|
|
1204
1295
|
}
|
|
1205
1296
|
|
|
1297
|
+
// ── Update panel mouse ────────────────────────────────────────────
|
|
1298
|
+
// Click a card to select *and* run it in one gesture: these three options are
|
|
1299
|
+
// terminal actions (install now / snooze / suppress), and a modal that needs
|
|
1300
|
+
// select-then-Enter would be two clicks for the common case. `renderUpdateOptions`
|
|
1301
|
+
// stays the single source of truth for the colours.
|
|
1302
|
+
function runUpdateOption(index: number) {
|
|
1303
|
+
updateSelectedOption = index;
|
|
1304
|
+
renderUpdateOptions();
|
|
1305
|
+
const action = ["now", "later", "never"][index] as
|
|
1306
|
+
"now" | "later" | "never" | undefined;
|
|
1307
|
+
if (action === "now") {
|
|
1308
|
+
showOverlay("none");
|
|
1309
|
+
// ponytail: record the running version so the next launch detects the upgrade
|
|
1310
|
+
recordPreUpdateState(cachedVersion ?? "");
|
|
1311
|
+
performUpdate()
|
|
1312
|
+
.then((ok) => {
|
|
1313
|
+
if (ok) {
|
|
1314
|
+
setStatus("✓ Update complete — restart ghfind");
|
|
1315
|
+
} else {
|
|
1316
|
+
setStatus("✗ Update failed — check terminal output");
|
|
1317
|
+
}
|
|
1318
|
+
})
|
|
1319
|
+
.catch(() => {
|
|
1320
|
+
setStatus("✗ Update failed — check terminal output");
|
|
1321
|
+
});
|
|
1322
|
+
} else if (action === "never") {
|
|
1323
|
+
suppressUpdateNotices();
|
|
1324
|
+
showOverlay("none");
|
|
1325
|
+
setStatus("Update notices suppressed");
|
|
1326
|
+
} else {
|
|
1327
|
+
snoozeUpdateNotices(3);
|
|
1328
|
+
showOverlay("none");
|
|
1329
|
+
setStatus("Update reminder snoozed for 3 days");
|
|
1330
|
+
}
|
|
1331
|
+
renderer.requestRender();
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
const updateCards: [BoxRenderable, number][] = [
|
|
1335
|
+
[updateNowBox, 0],
|
|
1336
|
+
[laterBox, 1],
|
|
1337
|
+
[neverBox, 2],
|
|
1338
|
+
];
|
|
1339
|
+
for (const [box, index] of updateCards) {
|
|
1340
|
+
attachClickableBox(renderer, box, {
|
|
1341
|
+
onActivate: () => runUpdateOption(index),
|
|
1342
|
+
idleBorder:
|
|
1343
|
+
updateSelectedOption === index ? colors.yellow : colors.border,
|
|
1344
|
+
activeBorder: colors.yellow,
|
|
1345
|
+
// Hovering must not fight the selection highlight, so it only moves the
|
|
1346
|
+
// pointer cursor; the border stays owned by renderUpdateOptions().
|
|
1347
|
+
onStateChange: () => {
|
|
1348
|
+
box.borderColor =
|
|
1349
|
+
updateSelectedOption === index ? colors.yellow : colors.border;
|
|
1350
|
+
},
|
|
1351
|
+
});
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// Clicking the dim layer behind the panel dismisses it without applying
|
|
1355
|
+
// anything (equivalent to Esc).
|
|
1356
|
+
attachClickOutside(updateDim, () => {
|
|
1357
|
+
showOverlay("none");
|
|
1358
|
+
renderer.requestRender();
|
|
1359
|
+
});
|
|
1360
|
+
|
|
1206
1361
|
updateOptionsRow.add(updateNowBox);
|
|
1207
1362
|
updateOptionsRow.add(laterBox);
|
|
1208
1363
|
|
|
1209
1364
|
const updateFooter = new TextRenderable(renderer, {
|
|
1210
|
-
|
|
1365
|
+
...NON_INTERACTIVE_TEXT,
|
|
1366
|
+
content:
|
|
1367
|
+
" ↑↓/jk/PgUp/PgDn scroll ←→ select Enter Esc/q close · click a button click outside to close",
|
|
1211
1368
|
fg: colors.muted,
|
|
1212
1369
|
bg: colors.surfaceDim,
|
|
1213
1370
|
height: 1,
|
|
@@ -1625,6 +1782,9 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
1625
1782
|
if (state.lastInstalledVersion === currentVersion) return;
|
|
1626
1783
|
// We just upgraded — show notes for the new version
|
|
1627
1784
|
const notes = fetchReleaseNotes(currentVersion);
|
|
1785
|
+
// Acknowledge immediately: this panel's trigger is a version inequality,
|
|
1786
|
+
// so leaving it unacknowledged makes it reappear on every launch.
|
|
1787
|
+
markPostUpgradeSeen(currentVersion);
|
|
1628
1788
|
updateSelectedOption = 0;
|
|
1629
1789
|
updateHeader.content = ` Updated ghfind ${state.lastInstalledVersion} → ${currentVersion}\n`;
|
|
1630
1790
|
updateNotes.setContent(
|
|
@@ -1928,6 +2088,48 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
1928
2088
|
});
|
|
1929
2089
|
root.add(statusBar);
|
|
1930
2090
|
|
|
2091
|
+
// ── Menu button (bottom-right corner) ─────────────────────────────
|
|
2092
|
+
// Mouse-only affordance: Space types a space in the query, so a mouse user
|
|
2093
|
+
// needs a target to reach the command menu. Absolutely positioned so it
|
|
2094
|
+
// floats over the results pane instead of stealing a flex row, and added
|
|
2095
|
+
// after every other root child so it wins the hit test on overlap.
|
|
2096
|
+
const menuButton = new BoxRenderable(renderer, {
|
|
2097
|
+
position: "absolute",
|
|
2098
|
+
bottom: 1,
|
|
2099
|
+
right: 1,
|
|
2100
|
+
height: 1,
|
|
2101
|
+
paddingX: 1,
|
|
2102
|
+
backgroundColor: colors.surface,
|
|
2103
|
+
focusable: false,
|
|
2104
|
+
});
|
|
2105
|
+
const menuButtonLabel = new TextRenderable(renderer, {
|
|
2106
|
+
...NON_INTERACTIVE_TEXT,
|
|
2107
|
+
content: "☰ menu",
|
|
2108
|
+
fg: colors.muted,
|
|
2109
|
+
bg: colors.surface,
|
|
2110
|
+
height: 1,
|
|
2111
|
+
});
|
|
2112
|
+
menuButton.add(menuButtonLabel);
|
|
2113
|
+
root.add(menuButton);
|
|
2114
|
+
|
|
2115
|
+
menuButton.onMouseDown = (event) => {
|
|
2116
|
+
event.stopPropagation();
|
|
2117
|
+
// Toggle, so clicking the button twice returns to where you were.
|
|
2118
|
+
if (currentOverlay === "leader") showOverlay("none");
|
|
2119
|
+
else showLeaderMenu();
|
|
2120
|
+
renderer.requestRender();
|
|
2121
|
+
};
|
|
2122
|
+
menuButton.onMouseOver = () => {
|
|
2123
|
+
renderer.setMousePointer("pointer");
|
|
2124
|
+
menuButtonLabel.fg = colors.accent;
|
|
2125
|
+
renderer.requestRender();
|
|
2126
|
+
};
|
|
2127
|
+
menuButton.onMouseOut = () => {
|
|
2128
|
+
renderer.setMousePointer("default");
|
|
2129
|
+
menuButtonLabel.fg = colors.muted;
|
|
2130
|
+
renderer.requestRender();
|
|
2131
|
+
};
|
|
2132
|
+
|
|
1931
2133
|
const statusMgr = new StatusManager((text) => {
|
|
1932
2134
|
statusBar.content = ` ${text}`;
|
|
1933
2135
|
renderer.requestRender();
|
|
@@ -2365,16 +2567,13 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
2365
2567
|
|
|
2366
2568
|
try {
|
|
2367
2569
|
const searchModule = new SearchModule(new TrendingAdapter());
|
|
2368
|
-
const response = await searchModule.search(
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
trendingSince: tabSince(trendingTab),
|
|
2376
|
-
},
|
|
2377
|
-
);
|
|
2570
|
+
const response = await searchModule.search(trendingQuery(), {
|
|
2571
|
+
limit: 25,
|
|
2572
|
+
sort: "stars",
|
|
2573
|
+
json: false,
|
|
2574
|
+
verbose: false,
|
|
2575
|
+
trendingSince: tabSince(trendingTab),
|
|
2576
|
+
});
|
|
2378
2577
|
const fetched = response.repos;
|
|
2379
2578
|
const since = tabSince(trendingTab);
|
|
2380
2579
|
trendingPeriod =
|
|
@@ -2528,7 +2727,12 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
2528
2727
|
}
|
|
2529
2728
|
|
|
2530
2729
|
if (currentRepos.length === 0) {
|
|
2531
|
-
|
|
2730
|
+
// A zero-result search usually means one qualifier over-constrained
|
|
2731
|
+
// it. Offer the ranked loosenings instead of generic advice.
|
|
2732
|
+
throw new NoResultsError(
|
|
2733
|
+
q,
|
|
2734
|
+
suggestRelaxations(parsed).map((r) => r.suggestion),
|
|
2735
|
+
);
|
|
2532
2736
|
} else {
|
|
2533
2737
|
resultsSelect.options = currentRepos.map((r) => ({
|
|
2534
2738
|
name: formatResultLine(r),
|
|
@@ -2675,6 +2879,192 @@ ${pack.description ?? ""}`;
|
|
|
2675
2879
|
}
|
|
2676
2880
|
});
|
|
2677
2881
|
|
|
2882
|
+
// ── Mouse on the results list ───────────────────────────────────────
|
|
2883
|
+
// Click selects (the `selectionChanged` handler above refreshes the detail
|
|
2884
|
+
// pane), double-click opens via the same path as Enter, and the wheel steps
|
|
2885
|
+
// the selection. `keepFocus` stops OpenTUI from auto-focusing the list away
|
|
2886
|
+
// from the query input, so typing after a click still edits the query.
|
|
2887
|
+
attachClickToSelect(renderer, resultsSelect, {
|
|
2888
|
+
keepFocus: true,
|
|
2889
|
+
onOpen: () => resultsSelect.selectCurrent(),
|
|
2890
|
+
});
|
|
2891
|
+
|
|
2892
|
+
/**
|
|
2893
|
+
* Enter-equivalent for every list overlay. Single source of truth for both
|
|
2894
|
+
* the keyboard handler below and the overlay mouse wiring, so a click can
|
|
2895
|
+
* never drift from pressing Enter.
|
|
2896
|
+
*/
|
|
2897
|
+
function activateOverlaySelection() {
|
|
2898
|
+
switch (currentOverlay) {
|
|
2899
|
+
case "history": {
|
|
2900
|
+
const sel = historySelect.getSelectedOption();
|
|
2901
|
+
if (sel?.value) {
|
|
2902
|
+
const entry = (sel.value as { entry?: HistoryEntry }).entry;
|
|
2903
|
+
showOverlay("none");
|
|
2904
|
+
if (!entry) return;
|
|
2905
|
+
if (entry.mode === "trending") {
|
|
2906
|
+
if (entry.tab)
|
|
2907
|
+
trendingTab = entry.tab as (typeof TAB_NAMES)[number];
|
|
2908
|
+
loadTrending();
|
|
2909
|
+
} else {
|
|
2910
|
+
searchInput.value = entry.query;
|
|
2911
|
+
showSearchMode();
|
|
2912
|
+
doSearch(entry.query);
|
|
2913
|
+
}
|
|
2914
|
+
}
|
|
2915
|
+
return;
|
|
2916
|
+
}
|
|
2917
|
+
case "bookmarks": {
|
|
2918
|
+
const sel = bookmarksSelect.getSelectedOption();
|
|
2919
|
+
if (sel?.value)
|
|
2920
|
+
openUrl((sel.value as { repo?: { url?: string } }).repo?.url ?? "");
|
|
2921
|
+
return;
|
|
2922
|
+
}
|
|
2923
|
+
case "saved": {
|
|
2924
|
+
const sel = savedSelect.getSelectedOption();
|
|
2925
|
+
if (sel?.value) {
|
|
2926
|
+
const s = sel.value as SavedSearch;
|
|
2927
|
+
showOverlay("none");
|
|
2928
|
+
touchSavedSearch(s.name);
|
|
2929
|
+
if (s.mode === "trending") {
|
|
2930
|
+
if (s.tab) trendingTab = s.tab as (typeof TAB_NAMES)[number];
|
|
2931
|
+
loadTrending();
|
|
2932
|
+
} else {
|
|
2933
|
+
searchInput.value = s.query;
|
|
2934
|
+
showSearchMode();
|
|
2935
|
+
currentSort = s.sort;
|
|
2936
|
+
currentLimit = s.limit;
|
|
2937
|
+
setToolbar();
|
|
2938
|
+
doSearch(s.query);
|
|
2939
|
+
}
|
|
2940
|
+
}
|
|
2941
|
+
return;
|
|
2942
|
+
}
|
|
2943
|
+
case "topics": {
|
|
2944
|
+
const sel = topicsSelect.getSelectedOption();
|
|
2945
|
+
if (sel?.value) {
|
|
2946
|
+
const topic = sel.value as { name?: string };
|
|
2947
|
+
showOverlay("none");
|
|
2948
|
+
searchInput.value = `topic:${topic.name ?? ""}`;
|
|
2949
|
+
showSearchMode();
|
|
2950
|
+
doSearch(`topic:${topic.name}`);
|
|
2951
|
+
}
|
|
2952
|
+
return;
|
|
2953
|
+
}
|
|
2954
|
+
case "export": {
|
|
2955
|
+
const sel = exportSelect.getSelectedOption();
|
|
2956
|
+
if (sel?.value && currentRepos.length > 0) {
|
|
2957
|
+
const format = sel.value as ExportFormat;
|
|
2958
|
+
const path = exportToFile(currentRepos, format);
|
|
2959
|
+
showOverlay("none");
|
|
2960
|
+
setStatus(`✓ Exported to ${path}`);
|
|
2961
|
+
} else {
|
|
2962
|
+
setStatus("No results to export");
|
|
2963
|
+
showOverlay("none");
|
|
2964
|
+
}
|
|
2965
|
+
renderer.requestRender();
|
|
2966
|
+
return;
|
|
2967
|
+
}
|
|
2968
|
+
case "share": {
|
|
2969
|
+
const sel = shareSelect.getSelectedOption();
|
|
2970
|
+
if (sel?.value) {
|
|
2971
|
+
const opt = resultsSelect.getSelectedOption();
|
|
2972
|
+
const repo = opt?.value as Repo | undefined;
|
|
2973
|
+
if (repo) {
|
|
2974
|
+
const text = formatShare(repo, sel.value as ShareFormat);
|
|
2975
|
+
copyToClipboard(text).then((ok) => {
|
|
2976
|
+
showOverlay("none");
|
|
2977
|
+
setStatus(ok ? "✓ Copied!" : "Clipboard not available");
|
|
2978
|
+
});
|
|
2979
|
+
}
|
|
2980
|
+
}
|
|
2981
|
+
renderer.requestRender();
|
|
2982
|
+
return;
|
|
2983
|
+
}
|
|
2984
|
+
case "help": {
|
|
2985
|
+
showOverlay("none");
|
|
2986
|
+
renderer.requestRender();
|
|
2987
|
+
return;
|
|
2988
|
+
}
|
|
2989
|
+
default:
|
|
2990
|
+
// Leader / update / readme / org / compare / notifications have their
|
|
2991
|
+
// own activation paths (see the key handler) or are not list-driven.
|
|
2992
|
+
return;
|
|
2993
|
+
}
|
|
2994
|
+
}
|
|
2995
|
+
|
|
2996
|
+
/**
|
|
2997
|
+
* Wire the overlay lists. A click selects, a double-click activates (the
|
|
2998
|
+
* overlay's Enter-equivalent), the wheel scrolls, and a click on the panel
|
|
2999
|
+
* outside the rows dismisses the overlay like Esc.
|
|
3000
|
+
*/
|
|
3001
|
+
function attachOverlayMouse(
|
|
3002
|
+
box: Parameters<typeof attachClickOutside>[0],
|
|
3003
|
+
select: Parameters<typeof attachClickToSelect>[1],
|
|
3004
|
+
) {
|
|
3005
|
+
attachClickToSelect(renderer, select, {
|
|
3006
|
+
onOpen: () => activateOverlaySelection(),
|
|
3007
|
+
onClickMiss: () => {
|
|
3008
|
+
showOverlay("none");
|
|
3009
|
+
renderer.requestRender();
|
|
3010
|
+
},
|
|
3011
|
+
});
|
|
3012
|
+
attachClickOutside(box, () => {
|
|
3013
|
+
showOverlay("none");
|
|
3014
|
+
renderer.requestRender();
|
|
3015
|
+
});
|
|
3016
|
+
}
|
|
3017
|
+
|
|
3018
|
+
attachOverlayMouse(historyBox, historySelect);
|
|
3019
|
+
attachOverlayMouse(bookmarksBox, bookmarksSelect);
|
|
3020
|
+
attachOverlayMouse(savedBox, savedSelect);
|
|
3021
|
+
attachOverlayMouse(topicsBox, topicsSelect);
|
|
3022
|
+
attachOverlayMouse(exportBox, exportSelect);
|
|
3023
|
+
attachOverlayMouse(shareBox, shareSelect);
|
|
3024
|
+
|
|
3025
|
+
// Read-only overlays have no rows to click, so the panel itself is the
|
|
3026
|
+
// dismiss target (same as Esc). Their ScrollBox children handle the wheel
|
|
3027
|
+
// natively; claiming it here stops the renderer from also feeding the
|
|
3028
|
+
// focused-renderable fallback.
|
|
3029
|
+
for (const panel of [helpBox, orgBox, readmeBox, compareBox, notifsBox]) {
|
|
3030
|
+
attachClickOutside(panel, () => {
|
|
3031
|
+
showOverlay("none");
|
|
3032
|
+
renderer.requestRender();
|
|
3033
|
+
});
|
|
3034
|
+
}
|
|
3035
|
+
for (const scroller of [helpScroll, orgScroll, readmeScroll, updateScroll]) {
|
|
3036
|
+
scroller.onMouseScroll = (event) => {
|
|
3037
|
+
event.stopPropagation();
|
|
3038
|
+
};
|
|
3039
|
+
}
|
|
3040
|
+
|
|
3041
|
+
// Leader menu: the list activates like Enter, but the dim layer behind the
|
|
3042
|
+
// floating menu is the click-outside target (the menu box itself is nested
|
|
3043
|
+
// inside it and stops propagation).
|
|
3044
|
+
attachClickToSelect(renderer, leaderSelect, {
|
|
3045
|
+
onOpen: () => {
|
|
3046
|
+
const entry = leaderSelect.getSelectedOption()?.value as
|
|
3047
|
+
MenuEntry | undefined;
|
|
3048
|
+
if (!entry) return;
|
|
3049
|
+
if (entry.type === "category") {
|
|
3050
|
+
pushMenuLevel(` Menu > ${entry.name}`, entry.name, entry.children, 0);
|
|
3051
|
+
} else {
|
|
3052
|
+
resetMenu();
|
|
3053
|
+
showOverlay("none");
|
|
3054
|
+
entry.action();
|
|
3055
|
+
}
|
|
3056
|
+
renderer.requestRender();
|
|
3057
|
+
},
|
|
3058
|
+
onClickMiss: () => {
|
|
3059
|
+
showOverlay("none");
|
|
3060
|
+
renderer.requestRender();
|
|
3061
|
+
},
|
|
3062
|
+
});
|
|
3063
|
+
attachClickOutside(leaderDim, () => {
|
|
3064
|
+
showOverlay("none");
|
|
3065
|
+
renderer.requestRender();
|
|
3066
|
+
});
|
|
3067
|
+
|
|
2678
3068
|
// ── Global keyboard shortcuts ──────────────────────────────────────
|
|
2679
3069
|
renderer.keyInput.on("keypress", (key) => {
|
|
2680
3070
|
// ── Token prompt (own keyboard scope) ──────────────────────────
|
|
@@ -2787,21 +3177,7 @@ ${pack.description ?? ""}`;
|
|
|
2787
3177
|
return;
|
|
2788
3178
|
}
|
|
2789
3179
|
if (key.name === "enter" || key.name === "return") {
|
|
2790
|
-
|
|
2791
|
-
if (sel?.value) {
|
|
2792
|
-
const entry = (sel.value as { entry?: HistoryEntry }).entry;
|
|
2793
|
-
showOverlay("none");
|
|
2794
|
-
if (!entry) return;
|
|
2795
|
-
if (entry.mode === "trending") {
|
|
2796
|
-
if (entry.tab)
|
|
2797
|
-
trendingTab = entry.tab as (typeof TAB_NAMES)[number];
|
|
2798
|
-
loadTrending();
|
|
2799
|
-
} else {
|
|
2800
|
-
searchInput.value = entry.query;
|
|
2801
|
-
showSearchMode();
|
|
2802
|
-
doSearch(entry.query);
|
|
2803
|
-
}
|
|
2804
|
-
}
|
|
3180
|
+
activateOverlaySelection();
|
|
2805
3181
|
return;
|
|
2806
3182
|
}
|
|
2807
3183
|
return;
|
|
@@ -2823,9 +3199,7 @@ ${pack.description ?? ""}`;
|
|
|
2823
3199
|
return;
|
|
2824
3200
|
}
|
|
2825
3201
|
if (key.name === "enter" || key.name === "return") {
|
|
2826
|
-
|
|
2827
|
-
if (sel?.value)
|
|
2828
|
-
openUrl((sel.value as { repo?: { url?: string } }).repo?.url ?? "");
|
|
3202
|
+
activateOverlaySelection();
|
|
2829
3203
|
return;
|
|
2830
3204
|
}
|
|
2831
3205
|
return;
|
|
@@ -2844,23 +3218,7 @@ ${pack.description ?? ""}`;
|
|
|
2844
3218
|
return;
|
|
2845
3219
|
}
|
|
2846
3220
|
if (key.name === "enter" || key.name === "return") {
|
|
2847
|
-
|
|
2848
|
-
if (sel?.value) {
|
|
2849
|
-
const s = sel.value as SavedSearch;
|
|
2850
|
-
showOverlay("none");
|
|
2851
|
-
touchSavedSearch(s.name);
|
|
2852
|
-
if (s.mode === "trending") {
|
|
2853
|
-
if (s.tab) trendingTab = s.tab as (typeof TAB_NAMES)[number];
|
|
2854
|
-
loadTrending();
|
|
2855
|
-
} else {
|
|
2856
|
-
searchInput.value = s.query;
|
|
2857
|
-
showSearchMode();
|
|
2858
|
-
currentSort = s.sort;
|
|
2859
|
-
currentLimit = s.limit;
|
|
2860
|
-
setToolbar();
|
|
2861
|
-
doSearch(s.query);
|
|
2862
|
-
}
|
|
2863
|
-
}
|
|
3221
|
+
activateOverlaySelection();
|
|
2864
3222
|
return;
|
|
2865
3223
|
}
|
|
2866
3224
|
return;
|
|
@@ -2869,14 +3227,7 @@ ${pack.description ?? ""}`;
|
|
|
2869
3227
|
// Topics explorer overlay
|
|
2870
3228
|
if (currentOverlay === "topics") {
|
|
2871
3229
|
if (key.name === "enter" || key.name === "return") {
|
|
2872
|
-
|
|
2873
|
-
if (sel?.value) {
|
|
2874
|
-
const topic = sel.value as { name?: string };
|
|
2875
|
-
showOverlay("none");
|
|
2876
|
-
searchInput.value = `topic:${topic.name ?? ""}`;
|
|
2877
|
-
showSearchMode();
|
|
2878
|
-
doSearch(`topic:${topic.name}`);
|
|
2879
|
-
}
|
|
3230
|
+
activateOverlaySelection();
|
|
2880
3231
|
return;
|
|
2881
3232
|
}
|
|
2882
3233
|
return;
|
|
@@ -2885,17 +3236,7 @@ ${pack.description ?? ""}`;
|
|
|
2885
3236
|
// Export overlay
|
|
2886
3237
|
if (currentOverlay === "export") {
|
|
2887
3238
|
if (key.name === "enter" || key.name === "return") {
|
|
2888
|
-
|
|
2889
|
-
if (sel?.value && currentRepos.length > 0) {
|
|
2890
|
-
const format = sel.value as ExportFormat;
|
|
2891
|
-
const path = exportToFile(currentRepos, format);
|
|
2892
|
-
showOverlay("none");
|
|
2893
|
-
setStatus(`✓ Exported to ${path}`);
|
|
2894
|
-
} else {
|
|
2895
|
-
setStatus("No results to export");
|
|
2896
|
-
showOverlay("none");
|
|
2897
|
-
}
|
|
2898
|
-
renderer.requestRender();
|
|
3239
|
+
activateOverlaySelection();
|
|
2899
3240
|
return;
|
|
2900
3241
|
}
|
|
2901
3242
|
return;
|
|
@@ -2930,19 +3271,7 @@ ${pack.description ?? ""}`;
|
|
|
2930
3271
|
// Share overlay
|
|
2931
3272
|
if (currentOverlay === "share") {
|
|
2932
3273
|
if (key.name === "enter" || key.name === "return") {
|
|
2933
|
-
|
|
2934
|
-
if (sel?.value) {
|
|
2935
|
-
const opt = resultsSelect.getSelectedOption();
|
|
2936
|
-
const repo = opt?.value as Repo | undefined;
|
|
2937
|
-
if (repo) {
|
|
2938
|
-
const text = formatShare(repo, sel.value as ShareFormat);
|
|
2939
|
-
copyToClipboard(text).then((ok) => {
|
|
2940
|
-
showOverlay("none");
|
|
2941
|
-
setStatus(ok ? "✓ Copied!" : "Clipboard not available");
|
|
2942
|
-
});
|
|
2943
|
-
}
|
|
2944
|
-
}
|
|
2945
|
-
renderer.requestRender();
|
|
3274
|
+
activateOverlaySelection();
|
|
2946
3275
|
return;
|
|
2947
3276
|
}
|
|
2948
3277
|
return;
|
|
@@ -2950,8 +3279,7 @@ ${pack.description ?? ""}`;
|
|
|
2950
3279
|
|
|
2951
3280
|
// Help overlay: any key closes
|
|
2952
3281
|
if (currentOverlay === "help") {
|
|
2953
|
-
|
|
2954
|
-
renderer.requestRender();
|
|
3282
|
+
activateOverlaySelection();
|
|
2955
3283
|
return;
|
|
2956
3284
|
}
|
|
2957
3285
|
|
|
@@ -2991,33 +3319,7 @@ ${pack.description ?? ""}`;
|
|
|
2991
3319
|
return;
|
|
2992
3320
|
}
|
|
2993
3321
|
if (key.name === "enter" || key.name === "return") {
|
|
2994
|
-
|
|
2995
|
-
"now" | "later" | "never" | undefined;
|
|
2996
|
-
if (action === "now") {
|
|
2997
|
-
showOverlay("none");
|
|
2998
|
-
// ponytail: record the running version so the next launch detects the upgrade
|
|
2999
|
-
recordPreUpdateState(cachedVersion ?? "");
|
|
3000
|
-
performUpdate()
|
|
3001
|
-
.then((ok) => {
|
|
3002
|
-
if (ok) {
|
|
3003
|
-
setStatus("✓ Update complete — restart ghfind");
|
|
3004
|
-
} else {
|
|
3005
|
-
setStatus("✗ Update failed — check terminal output");
|
|
3006
|
-
}
|
|
3007
|
-
})
|
|
3008
|
-
.catch(() => {
|
|
3009
|
-
setStatus("✗ Update failed — check terminal output");
|
|
3010
|
-
});
|
|
3011
|
-
} else if (action === "never") {
|
|
3012
|
-
suppressUpdateNotices();
|
|
3013
|
-
showOverlay("none");
|
|
3014
|
-
setStatus("Update notices suppressed");
|
|
3015
|
-
} else {
|
|
3016
|
-
snoozeUpdateNotices(3);
|
|
3017
|
-
showOverlay("none");
|
|
3018
|
-
setStatus("Update reminder snoozed for 3 days");
|
|
3019
|
-
}
|
|
3020
|
-
renderer.requestRender();
|
|
3322
|
+
runUpdateOption(updateSelectedOption);
|
|
3021
3323
|
return;
|
|
3022
3324
|
}
|
|
3023
3325
|
return;
|
|
@@ -3053,11 +3355,19 @@ ${pack.description ?? ""}`;
|
|
|
3053
3355
|
return;
|
|
3054
3356
|
}
|
|
3055
3357
|
|
|
3056
|
-
//
|
|
3057
|
-
//
|
|
3058
|
-
//
|
|
3059
|
-
if (key.name === "
|
|
3060
|
-
|
|
3358
|
+
// Esc toggles the leader menu — the only binding for it. Space used to open it
|
|
3359
|
+
// when the query input was not focused, which made the same key mean two
|
|
3360
|
+
// things depending on invisible state; it is now reserved for typing.
|
|
3361
|
+
if (key.name === "escape") {
|
|
3362
|
+
if (currentOverlay !== "none") {
|
|
3363
|
+
// Only reachable for the leader menu — every other overlay returned
|
|
3364
|
+
// above, and its own branch already closed it.
|
|
3365
|
+
showOverlay("none");
|
|
3366
|
+
setToolbar();
|
|
3367
|
+
} else {
|
|
3368
|
+
showLeaderMenu();
|
|
3369
|
+
}
|
|
3370
|
+
renderer.requestRender();
|
|
3061
3371
|
return;
|
|
3062
3372
|
}
|
|
3063
3373
|
|