github-search-cli 0.6.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/LICENSE +21 -0
- package/README.md +188 -0
- package/bin/search-cli.js +17 -0
- package/completions/search-cli.bash +43 -0
- package/completions/search-cli.fish +19 -0
- package/completions/search-cli.zsh +27 -0
- package/package.json +55 -0
- package/src/bookmarks.ts +69 -0
- package/src/cache.ts +64 -0
- package/src/cli.ts +242 -0
- package/src/compare.ts +46 -0
- package/src/config.ts +66 -0
- package/src/deepdive.ts +124 -0
- package/src/errors.ts +62 -0
- package/src/explore.ts +45 -0
- package/src/export.ts +56 -0
- package/src/help.ts +46 -0
- package/src/history.ts +91 -0
- package/src/init.ts +47 -0
- package/src/normalizer.ts +66 -0
- package/src/notifications.ts +69 -0
- package/src/output.ts +74 -0
- package/src/pipe.ts +65 -0
- package/src/provider.ts +142 -0
- package/src/query.ts +251 -0
- package/src/ranking.ts +55 -0
- package/src/saved-searches.ts +58 -0
- package/src/session.ts +35 -0
- package/src/share.ts +49 -0
- package/src/status.ts +61 -0
- package/src/themes.ts +52 -0
- package/src/tips.ts +27 -0
- package/src/trending.ts +588 -0
- package/src/tui.ts +1463 -0
- package/src/types.ts +148 -0
- package/src/watch.ts +53 -0
package/src/tui.ts
ADDED
|
@@ -0,0 +1,1463 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* search-cli — interactive GitHub repository browser (OpenTUI)
|
|
4
|
+
*
|
|
5
|
+
* Full-screen, keyboard-driven browser. This is the only entry point.
|
|
6
|
+
* Pipeline: user query → provider → normalizer → ranking → rendered in TUI
|
|
7
|
+
*
|
|
8
|
+
* Layout:
|
|
9
|
+
* ┌─ search-cli [s]sort [l]limit [r]refresh [q]quit ─────┐
|
|
10
|
+
* │ > [query input ........................................] │
|
|
11
|
+
* │ sort: best-match limit: 50 │
|
|
12
|
+
* ├─── Results ────────────────┬─── Details ──────────────────┤
|
|
13
|
+
* │ owner/repo ★69k │ Name owner/repo │
|
|
14
|
+
* │ owner/repo ★42k │ Stars 69,420 │
|
|
15
|
+
* │ owner/repo ★12k │ Forks 1,337 │
|
|
16
|
+
* │ owner/repo ★5k │ Language TypeScript │
|
|
17
|
+
* │ owner/repo ★2k │ Updated 2026-07-09 │
|
|
18
|
+
* │ owner/repo ★1k │ Topics ai, agent, tui │
|
|
19
|
+
* │ │ │
|
|
20
|
+
* │ │ A description... │
|
|
21
|
+
* │ │ https://github.com/... │
|
|
22
|
+
* ├──────────────────────────┴────────────────────────────────┤
|
|
23
|
+
* │ 42 results of 15000 (↑↓ nav Enter open o browser) │
|
|
24
|
+
* └───────────────────────────────────────────────────────────┘
|
|
25
|
+
*/
|
|
26
|
+
import {
|
|
27
|
+
createCliRenderer,
|
|
28
|
+
BoxRenderable,
|
|
29
|
+
TextRenderable,
|
|
30
|
+
InputRenderable,
|
|
31
|
+
SelectRenderable,
|
|
32
|
+
} from "@opentui/core";
|
|
33
|
+
import type { Repo, SearchOptions, SortStrategy } from "./types.ts";
|
|
34
|
+
import { parseQuery, applyFlagFilters, validateQuery, suggestFor } from "./query.ts";
|
|
35
|
+
import { GitHubSearchProvider, type Logger } from "./provider.ts";
|
|
36
|
+
import { rankRepos } from "./ranking.ts";
|
|
37
|
+
import { fetchTrendingRepos, tabSince, TAB_NAMES, fmtStars, type TrendingRepo } from "./trending.ts";
|
|
38
|
+
import { loadConfig } from "./config.ts";
|
|
39
|
+
import { buildHelpText } from "./help.ts";
|
|
40
|
+
import { SearchCliError, NetworkError, RateLimitError, BadQueryError, NoResultsError } from "./errors.ts";
|
|
41
|
+
import { appendHistory, readHistory, deleteHistoryEntry, clearHistory } from "./history.ts";
|
|
42
|
+
import { getBookmarks, isBookmarked, toggleBookmark, removeBookmark } from "./bookmarks.ts";
|
|
43
|
+
import { getSavedSearches, saveSearch, deleteSavedSearch, touchSavedSearch } from "./saved-searches.ts";
|
|
44
|
+
import { saveSession, restoreSession } from "./session.ts";
|
|
45
|
+
import { fetchDeepDive, buildDeepDiveText } from "./deepdive.ts";
|
|
46
|
+
import { buildComparisonTable } from "./compare.ts";
|
|
47
|
+
import { fetchTopics, type TopicItem } from "./explore.ts";
|
|
48
|
+
import { exportToFile, type ExportFormat } from "./export.ts";
|
|
49
|
+
import { loadTheme } from "./themes.ts";
|
|
50
|
+
import { StatusManager } from "./status.ts";
|
|
51
|
+
import { addNotification, getNotifications, dismissNotification, dismissAll } from "./notifications.ts";
|
|
52
|
+
import { formatShare, copyToClipboard, type ShareFormat } from "./share.ts";
|
|
53
|
+
import { nextTip } from "./tips.ts";
|
|
54
|
+
|
|
55
|
+
// ─── Theme ────────────────────────────────────────────────────────────
|
|
56
|
+
const colors = {
|
|
57
|
+
bg: "#1e1e2e",
|
|
58
|
+
surface: "#181825",
|
|
59
|
+
text: "#cdd6f4",
|
|
60
|
+
muted: "#6c7086",
|
|
61
|
+
blue: "#89b4fa",
|
|
62
|
+
green: "#a6e3a1",
|
|
63
|
+
yellow: "#f9e2af",
|
|
64
|
+
red: "#f38ba8",
|
|
65
|
+
teal: "#94e2d5",
|
|
66
|
+
border: "#45475a",
|
|
67
|
+
selectionBg: "#2d5bcf",
|
|
68
|
+
selectionText: "#ffffff",
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// ─── Logger ───────────────────────────────────────────────────────────
|
|
72
|
+
const logger: Logger = {
|
|
73
|
+
debug: () => {},
|
|
74
|
+
info: () => {},
|
|
75
|
+
warn: () => {},
|
|
76
|
+
error: () => {},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// ─── Sort cycle ───────────────────────────────────────────────────────
|
|
80
|
+
const SORT_MODES: { key: SortStrategy; label: string }[] = [
|
|
81
|
+
{ key: "best-match", label: "best-match" },
|
|
82
|
+
{ key: "stars", label: "stars" },
|
|
83
|
+
{ key: "updated", label: "updated" },
|
|
84
|
+
{ key: "forks", label: "forks" },
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
// ─── Main ─────────────────────────────────────────────────────────────
|
|
88
|
+
export async function launchBrowser(): Promise<void> {
|
|
89
|
+
let renderer;
|
|
90
|
+
try {
|
|
91
|
+
renderer = await createCliRenderer();
|
|
92
|
+
} catch (err) {
|
|
93
|
+
console.error(
|
|
94
|
+
"Failed to start the interactive browser. OpenTUI native backend unavailable.\n",
|
|
95
|
+
`Reason: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
96
|
+
"Install Zig or use a platform with native OpenTUI support.",
|
|
97
|
+
);
|
|
98
|
+
process.exit(1);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const root = renderer.root;
|
|
103
|
+
root.flexDirection = "column";
|
|
104
|
+
root.backgroundColor = colors.bg;
|
|
105
|
+
|
|
106
|
+
// ── Config ──
|
|
107
|
+
const config = loadConfig();
|
|
108
|
+
const githubToken = config.githubToken || process.env.GITHUB_TOKEN;
|
|
109
|
+
|
|
110
|
+
// Apply theme
|
|
111
|
+
const theme = loadTheme(config.theme);
|
|
112
|
+
Object.assign(colors, theme);
|
|
113
|
+
|
|
114
|
+
// ── Session restore ──
|
|
115
|
+
const session = restoreSession();
|
|
116
|
+
|
|
117
|
+
// ── State ───────────────────────────────────────────────────────────
|
|
118
|
+
let currentRepos: Repo[] = [];
|
|
119
|
+
let currentSort: SortStrategy = session?.sort ?? config.defaultSort;
|
|
120
|
+
let currentLimit = session?.limit ?? config.defaultLimit;
|
|
121
|
+
let currentQueryInput = session?.query ?? "";
|
|
122
|
+
let isLoading = false;
|
|
123
|
+
let currentMode: "search" | "trending" = session?.mode ?? "search";
|
|
124
|
+
let trendingTab: typeof TAB_NAMES[number] = (session?.trendingTab as typeof TAB_NAMES[number]) ?? "This Week";
|
|
125
|
+
let trendingPeriod = "this week";
|
|
126
|
+
let graphFullscreen = false;
|
|
127
|
+
let chartCommitData: number[] = [];
|
|
128
|
+
let currentOverlay: "none" | "history" | "bookmarks" | "saved" | "help" | "topics" | "export" | "compare" | "notifications" | "share" = "none";
|
|
129
|
+
let currentPage = 1;
|
|
130
|
+
let totalCount = 0;
|
|
131
|
+
let deepDiveActive = false;
|
|
132
|
+
let compareList: Repo[] = [];
|
|
133
|
+
|
|
134
|
+
// ── Header bar ──────────────────────────────────────────────────────
|
|
135
|
+
const header = new TextRenderable(renderer, {
|
|
136
|
+
content:
|
|
137
|
+
" search-cli — GitHub repo browser [t]rending [/]search [g]raph [r]efresh [q]uit",
|
|
138
|
+
backgroundColor: colors.surface,
|
|
139
|
+
color: colors.muted,
|
|
140
|
+
height: 1,
|
|
141
|
+
});
|
|
142
|
+
root.add(header);
|
|
143
|
+
|
|
144
|
+
// ── Trending tab bar (hidden in search mode) ────────────────────────
|
|
145
|
+
const trendingTabBox = new BoxRenderable(renderer, {
|
|
146
|
+
flexDirection: "row",
|
|
147
|
+
height: 1,
|
|
148
|
+
backgroundColor: colors.bg,
|
|
149
|
+
paddingX: 1,
|
|
150
|
+
visible: false,
|
|
151
|
+
});
|
|
152
|
+
// Hidden by default; shown when currentMode === "trending"
|
|
153
|
+
const trendingTabTexts: TextRenderable[] = [];
|
|
154
|
+
|
|
155
|
+
function renderTrendingTabs() {
|
|
156
|
+
for (const t of trendingTabTexts) trendingTabBox.remove(t);
|
|
157
|
+
trendingTabTexts.length = 0;
|
|
158
|
+
TAB_NAMES.forEach((name, i) => {
|
|
159
|
+
const isActive = name === trendingTab;
|
|
160
|
+
const label = ` ${name} `;
|
|
161
|
+
const tt = new TextRenderable(renderer, {
|
|
162
|
+
content: label,
|
|
163
|
+
color: isActive ? colors.bg : colors.muted,
|
|
164
|
+
backgroundColor: isActive ? colors.blue : colors.bg,
|
|
165
|
+
height: 1,
|
|
166
|
+
});
|
|
167
|
+
trendingTabTexts.push(tt);
|
|
168
|
+
trendingTabBox.add(tt);
|
|
169
|
+
if (i < TAB_NAMES.length - 1) {
|
|
170
|
+
const sp = new TextRenderable(renderer, {
|
|
171
|
+
content: " ",
|
|
172
|
+
color: colors.muted,
|
|
173
|
+
backgroundColor: colors.bg,
|
|
174
|
+
height: 1,
|
|
175
|
+
});
|
|
176
|
+
trendingTabTexts.push(sp);
|
|
177
|
+
trendingTabBox.add(sp);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
root.add(trendingTabBox);
|
|
182
|
+
|
|
183
|
+
// ── Search input row ────────────────────────────────────────────────
|
|
184
|
+
const searchBox = new BoxRenderable(renderer, {
|
|
185
|
+
visible: true, // hidden in trending mode
|
|
186
|
+
border: true,
|
|
187
|
+
borderColor: colors.blue,
|
|
188
|
+
paddingX: 1,
|
|
189
|
+
height: 3,
|
|
190
|
+
});
|
|
191
|
+
const searchInput = new InputRenderable(renderer, {
|
|
192
|
+
placeholder: "Search GitHub repos (e.g. rust cli, or language:Rust stars:>100)",
|
|
193
|
+
value: "",
|
|
194
|
+
});
|
|
195
|
+
searchBox.add(searchInput);
|
|
196
|
+
root.add(searchBox);
|
|
197
|
+
|
|
198
|
+
// ── Toolbar (sort + limit indicator) ────────────────────────────────
|
|
199
|
+
const toolbarText = new TextRenderable(renderer, {
|
|
200
|
+
content: formatToolbar(currentSort, currentLimit),
|
|
201
|
+
visible: true, // hidden in trending mode
|
|
202
|
+
backgroundColor: colors.bg,
|
|
203
|
+
color: colors.muted,
|
|
204
|
+
height: 1,
|
|
205
|
+
paddingX: 1,
|
|
206
|
+
});
|
|
207
|
+
root.add(toolbarText);
|
|
208
|
+
|
|
209
|
+
// ── Body: results + detail ──────────────────────────────────────────
|
|
210
|
+
const body = new BoxRenderable(renderer, {
|
|
211
|
+
flexDirection: "row",
|
|
212
|
+
flexGrow: 1,
|
|
213
|
+
gap: 1,
|
|
214
|
+
paddingY: 1,
|
|
215
|
+
paddingX: 1,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// Results pane
|
|
219
|
+
const resultsBox = new BoxRenderable(renderer, {
|
|
220
|
+
border: true,
|
|
221
|
+
borderColor: colors.border,
|
|
222
|
+
title: " Results ",
|
|
223
|
+
titleColor: colors.blue,
|
|
224
|
+
width: "50%",
|
|
225
|
+
flexDirection: "column",
|
|
226
|
+
paddingLeft: 0,
|
|
227
|
+
focusable: false,
|
|
228
|
+
});
|
|
229
|
+
const resultsSelect = new SelectRenderable(renderer, {
|
|
230
|
+
options: [{ name: "", description: "Type a query and press Enter to search", value: null }],
|
|
231
|
+
showDescription: false,
|
|
232
|
+
showSelectionIndicator: false,
|
|
233
|
+
flexGrow: 1,
|
|
234
|
+
backgroundColor: colors.bg,
|
|
235
|
+
textColor: colors.text,
|
|
236
|
+
selectedBackgroundColor: colors.selectionBg,
|
|
237
|
+
selectedTextColor: colors.selectionText,
|
|
238
|
+
focusedBackgroundColor: colors.bg,
|
|
239
|
+
focusedTextColor: colors.text,
|
|
240
|
+
itemSpacing: 0,
|
|
241
|
+
});
|
|
242
|
+
resultsBox.add(resultsSelect);
|
|
243
|
+
|
|
244
|
+
// Detail / Graph pane
|
|
245
|
+
const detailBox = new BoxRenderable(renderer, {
|
|
246
|
+
border: true,
|
|
247
|
+
borderColor: colors.border,
|
|
248
|
+
title: " Details ",
|
|
249
|
+
titleColor: colors.green,
|
|
250
|
+
width: "50%",
|
|
251
|
+
flexDirection: "column",
|
|
252
|
+
paddingX: 1,
|
|
253
|
+
paddingY: 1,
|
|
254
|
+
focusable: false,
|
|
255
|
+
});
|
|
256
|
+
const detailText = new TextRenderable(renderer, {
|
|
257
|
+
content: "",
|
|
258
|
+
color: colors.text,
|
|
259
|
+
});
|
|
260
|
+
detailBox.add(detailText);
|
|
261
|
+
|
|
262
|
+
body.add(resultsBox);
|
|
263
|
+
body.add(detailBox);
|
|
264
|
+
root.add(body);
|
|
265
|
+
|
|
266
|
+
// ── Overlay system ──────────────────────────────────────────────────
|
|
267
|
+
function hideAllOverlays() {
|
|
268
|
+
helpBox.visible = false;
|
|
269
|
+
historyBox.visible = false;
|
|
270
|
+
bookmarksBox.visible = false;
|
|
271
|
+
savedBox.visible = false;
|
|
272
|
+
topicsBox.visible = false;
|
|
273
|
+
exportBox.visible = false;
|
|
274
|
+
compareBox.visible = false;
|
|
275
|
+
notifsBox.visible = false;
|
|
276
|
+
shareBox.visible = false;
|
|
277
|
+
currentOverlay = "none";
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function showOverlay(type: typeof currentOverlay) {
|
|
281
|
+
hideAllOverlays();
|
|
282
|
+
if (type === "none") return;
|
|
283
|
+
currentOverlay = type;
|
|
284
|
+
if (type === "help") { helpBox.visible = true; }
|
|
285
|
+
else if (type === "history") { historyBox.visible = true; }
|
|
286
|
+
else if (type === "bookmarks") { bookmarksBox.visible = true; }
|
|
287
|
+
else if (type === "saved") { savedBox.visible = true; }
|
|
288
|
+
else if (type === "topics") { topicsBox.visible = true; }
|
|
289
|
+
else if (type === "export") { exportBox.visible = true; }
|
|
290
|
+
else if (type === "compare") { compareBox.visible = true; }
|
|
291
|
+
else if (type === "notifications") { notifsBox.visible = true; }
|
|
292
|
+
else if (type === "share") { shareBox.visible = true; }
|
|
293
|
+
renderer.requestRender();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ── Help overlay ────────────────────────────────────────────
|
|
297
|
+
const helpBox = new BoxRenderable(renderer, {
|
|
298
|
+
visible: false,
|
|
299
|
+
flexGrow: 1,
|
|
300
|
+
backgroundColor: colors.bg,
|
|
301
|
+
border: true,
|
|
302
|
+
borderColor: colors.border,
|
|
303
|
+
});
|
|
304
|
+
const helpTextEl = new TextRenderable(renderer, {
|
|
305
|
+
content: buildHelpText(),
|
|
306
|
+
color: colors.text,
|
|
307
|
+
backgroundColor: colors.bg,
|
|
308
|
+
});
|
|
309
|
+
helpBox.add(helpTextEl);
|
|
310
|
+
root.add(helpBox);
|
|
311
|
+
|
|
312
|
+
// ── History overlay ─────────────────────────────────────────
|
|
313
|
+
const historyBox = new BoxRenderable(renderer, {
|
|
314
|
+
visible: false,
|
|
315
|
+
flexGrow: 1,
|
|
316
|
+
backgroundColor: colors.bg,
|
|
317
|
+
border: true,
|
|
318
|
+
borderColor: colors.border,
|
|
319
|
+
title: " Search History ",
|
|
320
|
+
titleColor: colors.blue,
|
|
321
|
+
});
|
|
322
|
+
const historySelect = new SelectRenderable(renderer, {
|
|
323
|
+
options: [{ name: "(no history)", description: "", value: null }],
|
|
324
|
+
showDescription: false,
|
|
325
|
+
showSelectionIndicator: true,
|
|
326
|
+
flexGrow: 1,
|
|
327
|
+
backgroundColor: colors.bg,
|
|
328
|
+
textColor: colors.text,
|
|
329
|
+
selectedBackgroundColor: colors.selectionBg,
|
|
330
|
+
selectedTextColor: colors.selectionText,
|
|
331
|
+
itemSpacing: 0,
|
|
332
|
+
});
|
|
333
|
+
historyBox.add(historySelect);
|
|
334
|
+
root.add(historyBox);
|
|
335
|
+
|
|
336
|
+
function refreshHistory() {
|
|
337
|
+
const entries = readHistory();
|
|
338
|
+
if (entries.length === 0) {
|
|
339
|
+
historySelect.options = [{ name: "(no history)", description: "", value: null }];
|
|
340
|
+
} else {
|
|
341
|
+
historySelect.options = entries.map((e, i) => ({
|
|
342
|
+
name: `${e.query} — ${e.mode}${e.tab ? ` (${e.tab})` : ""}`,
|
|
343
|
+
description: `${e.resultCount} results`,
|
|
344
|
+
value: { index: i, entry: e },
|
|
345
|
+
}));
|
|
346
|
+
}
|
|
347
|
+
historySelect.setSelectedIndex(0);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ── Bookmarks overlay ───────────────────────────────────────
|
|
351
|
+
const bookmarksBox = new BoxRenderable(renderer, {
|
|
352
|
+
visible: false,
|
|
353
|
+
flexGrow: 1,
|
|
354
|
+
backgroundColor: colors.bg,
|
|
355
|
+
border: true,
|
|
356
|
+
borderColor: colors.border,
|
|
357
|
+
title: " Bookmarks ",
|
|
358
|
+
titleColor: colors.green,
|
|
359
|
+
});
|
|
360
|
+
const bookmarksSelect = new SelectRenderable(renderer, {
|
|
361
|
+
options: [{ name: "(no bookmarks)", description: "", value: null }],
|
|
362
|
+
showDescription: false,
|
|
363
|
+
showSelectionIndicator: true,
|
|
364
|
+
flexGrow: 1,
|
|
365
|
+
backgroundColor: colors.bg,
|
|
366
|
+
textColor: colors.text,
|
|
367
|
+
selectedBackgroundColor: colors.selectionBg,
|
|
368
|
+
selectedTextColor: colors.selectionText,
|
|
369
|
+
itemSpacing: 0,
|
|
370
|
+
});
|
|
371
|
+
bookmarksBox.add(bookmarksSelect);
|
|
372
|
+
root.add(bookmarksBox);
|
|
373
|
+
|
|
374
|
+
function refreshBookmarks() {
|
|
375
|
+
const bookmarks = getBookmarks();
|
|
376
|
+
if (bookmarks.length === 0) {
|
|
377
|
+
bookmarksSelect.options = [{ name: "(no bookmarks)", description: "", value: null }];
|
|
378
|
+
} else {
|
|
379
|
+
bookmarksSelect.options = bookmarks.map((b) => ({
|
|
380
|
+
name: `${b.repo.fullName} ★ ${b.repo.stars.toLocaleString()}`,
|
|
381
|
+
description: b.repo.description ?? "",
|
|
382
|
+
value: b,
|
|
383
|
+
}));
|
|
384
|
+
}
|
|
385
|
+
bookmarksSelect.setSelectedIndex(0);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ── Saved searches overlay ─────────────────────────────────
|
|
389
|
+
const savedBox = new BoxRenderable(renderer, {
|
|
390
|
+
visible: false,
|
|
391
|
+
flexGrow: 1,
|
|
392
|
+
backgroundColor: colors.bg,
|
|
393
|
+
border: true,
|
|
394
|
+
borderColor: colors.border,
|
|
395
|
+
title: " Saved Searches ",
|
|
396
|
+
titleColor: colors.yellow,
|
|
397
|
+
});
|
|
398
|
+
const savedSelect = new SelectRenderable(renderer, {
|
|
399
|
+
options: [{ name: "(no saved searches)", description: "", value: null }],
|
|
400
|
+
showDescription: false,
|
|
401
|
+
showSelectionIndicator: true,
|
|
402
|
+
flexGrow: 1,
|
|
403
|
+
backgroundColor: colors.bg,
|
|
404
|
+
textColor: colors.text,
|
|
405
|
+
selectedBackgroundColor: colors.selectionBg,
|
|
406
|
+
selectedTextColor: colors.selectionText,
|
|
407
|
+
itemSpacing: 0,
|
|
408
|
+
});
|
|
409
|
+
savedBox.add(savedSelect);
|
|
410
|
+
root.add(savedBox);
|
|
411
|
+
|
|
412
|
+
// ── Topic explorer overlay ───────────────────────────────────
|
|
413
|
+
const topicsBox = new BoxRenderable(renderer, {
|
|
414
|
+
visible: false,
|
|
415
|
+
flexGrow: 1,
|
|
416
|
+
backgroundColor: colors.bg,
|
|
417
|
+
border: true,
|
|
418
|
+
borderColor: colors.border,
|
|
419
|
+
title: " Topics ",
|
|
420
|
+
titleColor: colors.purple ?? colors.blue,
|
|
421
|
+
});
|
|
422
|
+
const topicsSelect = new SelectRenderable(renderer, {
|
|
423
|
+
options: [{ name: "(loading topics...)", description: "", value: null }],
|
|
424
|
+
showDescription: true,
|
|
425
|
+
showSelectionIndicator: true,
|
|
426
|
+
flexGrow: 1,
|
|
427
|
+
backgroundColor: colors.bg,
|
|
428
|
+
textColor: colors.text,
|
|
429
|
+
selectedBackgroundColor: colors.selectionBg,
|
|
430
|
+
selectedTextColor: colors.selectionText,
|
|
431
|
+
itemSpacing: 0,
|
|
432
|
+
});
|
|
433
|
+
topicsBox.add(topicsSelect);
|
|
434
|
+
root.add(topicsBox);
|
|
435
|
+
|
|
436
|
+
async function refreshTopics() {
|
|
437
|
+
try {
|
|
438
|
+
const topics = await fetchTopics();
|
|
439
|
+
topicsSelect.options = topics.map((t) => ({
|
|
440
|
+
name: `${t.name} (${t.repoCount.toLocaleString()} repos)`,
|
|
441
|
+
description: t.description.slice(0, 80),
|
|
442
|
+
value: t,
|
|
443
|
+
}));
|
|
444
|
+
} catch {
|
|
445
|
+
topicsSelect.options = [{ name: "(failed to load topics)", description: "", value: null }];
|
|
446
|
+
}
|
|
447
|
+
topicsSelect.setSelectedIndex(0);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ── Export overlay ───────────────────────────────────────────
|
|
451
|
+
const exportBox = new BoxRenderable(renderer, {
|
|
452
|
+
visible: false,
|
|
453
|
+
flexGrow: 1,
|
|
454
|
+
backgroundColor: colors.bg,
|
|
455
|
+
border: true,
|
|
456
|
+
borderColor: colors.border,
|
|
457
|
+
title: " Export ",
|
|
458
|
+
titleColor: colors.green,
|
|
459
|
+
});
|
|
460
|
+
const exportSelect = new SelectRenderable(renderer, {
|
|
461
|
+
options: [
|
|
462
|
+
{ name: " JSON", description: "Full repo data as JSON array", value: "json" },
|
|
463
|
+
{ name: " CSV", description: "Rank, name, stars, forks, language, URL", value: "csv" },
|
|
464
|
+
{ name: " Markdown", description: "Pretty table with links (paste into README)", value: "markdown" },
|
|
465
|
+
{ name: " Plain text", description: "One repo per line", value: "text" },
|
|
466
|
+
],
|
|
467
|
+
showDescription: true,
|
|
468
|
+
showSelectionIndicator: true,
|
|
469
|
+
flexGrow: 1,
|
|
470
|
+
backgroundColor: colors.bg,
|
|
471
|
+
textColor: colors.text,
|
|
472
|
+
selectedBackgroundColor: colors.selectionBg,
|
|
473
|
+
selectedTextColor: colors.selectionText,
|
|
474
|
+
itemSpacing: 0,
|
|
475
|
+
});
|
|
476
|
+
exportBox.add(exportSelect);
|
|
477
|
+
root.add(exportBox);
|
|
478
|
+
|
|
479
|
+
// ── Compare overlay ──────────────────────────────────────────
|
|
480
|
+
const compareBox = new BoxRenderable(renderer, {
|
|
481
|
+
visible: false,
|
|
482
|
+
flexGrow: 1,
|
|
483
|
+
backgroundColor: colors.bg,
|
|
484
|
+
border: true,
|
|
485
|
+
borderColor: colors.border,
|
|
486
|
+
title: " Comparison ",
|
|
487
|
+
titleColor: colors.yellow,
|
|
488
|
+
});
|
|
489
|
+
const compareText = new TextRenderable(renderer, {
|
|
490
|
+
content: "",
|
|
491
|
+
color: colors.text,
|
|
492
|
+
backgroundColor: colors.bg,
|
|
493
|
+
});
|
|
494
|
+
compareBox.add(compareText);
|
|
495
|
+
root.add(compareBox);
|
|
496
|
+
|
|
497
|
+
// ── Notifications overlay ────────────────────────────────────
|
|
498
|
+
const notifsBox = new BoxRenderable(renderer, {
|
|
499
|
+
visible: false,
|
|
500
|
+
flexGrow: 1,
|
|
501
|
+
backgroundColor: colors.bg,
|
|
502
|
+
border: true,
|
|
503
|
+
borderColor: colors.border,
|
|
504
|
+
title: " Notifications ",
|
|
505
|
+
titleColor: colors.yellow,
|
|
506
|
+
});
|
|
507
|
+
const notifsText = new TextRenderable(renderer, {
|
|
508
|
+
content: "(no notifications)",
|
|
509
|
+
color: colors.text,
|
|
510
|
+
backgroundColor: colors.bg,
|
|
511
|
+
});
|
|
512
|
+
notifsBox.add(notifsText);
|
|
513
|
+
root.add(notifsBox);
|
|
514
|
+
|
|
515
|
+
function refreshNotifications() {
|
|
516
|
+
const notifs = getNotifications();
|
|
517
|
+
if (notifs.length === 0) {
|
|
518
|
+
notifsText.content = " (no notifications)";
|
|
519
|
+
} else {
|
|
520
|
+
notifsText.content = notifs
|
|
521
|
+
.map((n) => ` ${n.icon} ${n.message}`)
|
|
522
|
+
.join("\n");
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// ── Share overlay ────────────────────────────────────────────
|
|
527
|
+
const shareBox = new BoxRenderable(renderer, {
|
|
528
|
+
visible: false,
|
|
529
|
+
flexGrow: 1,
|
|
530
|
+
backgroundColor: colors.bg,
|
|
531
|
+
border: true,
|
|
532
|
+
borderColor: colors.border,
|
|
533
|
+
title: " Share Repo ",
|
|
534
|
+
titleColor: colors.blue,
|
|
535
|
+
});
|
|
536
|
+
const shareSelect = new SelectRenderable(renderer, {
|
|
537
|
+
options: [
|
|
538
|
+
{ name: " Markdown", description: "[owner/repo](url) — description", value: "markdown" },
|
|
539
|
+
{ name: " Plain", description: "owner/repo — description — url", value: "plain" },
|
|
540
|
+
{ name: " GitHub CLI", description: "gh repo view owner/repo", value: "gh-cli" },
|
|
541
|
+
{ name: " Short", description: "owner/repo", value: "short" },
|
|
542
|
+
],
|
|
543
|
+
showDescription: true,
|
|
544
|
+
showSelectionIndicator: true,
|
|
545
|
+
flexGrow: 1,
|
|
546
|
+
backgroundColor: colors.bg,
|
|
547
|
+
textColor: colors.text,
|
|
548
|
+
selectedBackgroundColor: colors.selectionBg,
|
|
549
|
+
selectedTextColor: colors.selectionText,
|
|
550
|
+
itemSpacing: 0,
|
|
551
|
+
});
|
|
552
|
+
shareBox.add(shareSelect);
|
|
553
|
+
root.add(shareBox);
|
|
554
|
+
|
|
555
|
+
function refreshCompare() {
|
|
556
|
+
if (compareList.length < 2) {
|
|
557
|
+
compareText.content = "Select at least 2 repos to compare (press c to add).";
|
|
558
|
+
} else {
|
|
559
|
+
compareText.content = buildComparisonTable(compareList);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function refreshSavedSearches() {
|
|
564
|
+
const saved = getSavedSearches();
|
|
565
|
+
if (saved.length === 0) {
|
|
566
|
+
savedSelect.options = [{ name: "(no saved searches)", description: "", value: null }];
|
|
567
|
+
} else {
|
|
568
|
+
savedSelect.options = saved.map((s) => ({
|
|
569
|
+
name: `${s.name}`,
|
|
570
|
+
description: `${s.query} (${s.mode})`,
|
|
571
|
+
value: s,
|
|
572
|
+
}));
|
|
573
|
+
}
|
|
574
|
+
savedSelect.setSelectedIndex(0);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// ── Status bar ──────────────────────────────────────────────────────
|
|
578
|
+
const statusBar = new TextRenderable(renderer, {
|
|
579
|
+
content: " Ready. Press Enter to search.",
|
|
580
|
+
backgroundColor: colors.surface,
|
|
581
|
+
color: colors.muted,
|
|
582
|
+
height: 1,
|
|
583
|
+
paddingX: 1,
|
|
584
|
+
});
|
|
585
|
+
root.add(statusBar);
|
|
586
|
+
|
|
587
|
+
const statusMgr = new StatusManager((text) => {
|
|
588
|
+
statusBar.content = ` ${text}`;
|
|
589
|
+
renderer.requestRender();
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
// ── Startup tip ──
|
|
593
|
+
if (config.theme !== undefined || true) {
|
|
594
|
+
// Show a tip after 2s idle
|
|
595
|
+
setTimeout(() => {
|
|
596
|
+
if (currentOverlay === "none") {
|
|
597
|
+
setStatus(nextTip());
|
|
598
|
+
}
|
|
599
|
+
}, 2000);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// ── Helper functions ────────────────────────────────────────────────
|
|
603
|
+
|
|
604
|
+
function setStatus(msg: string) {
|
|
605
|
+
statusMgr.set("idle", msg);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function setToolbar() {
|
|
609
|
+
toolbarText.content = formatToolbar(currentSort, currentLimit);
|
|
610
|
+
renderer.requestRender();
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function updateDetail(repo: Repo | null) {
|
|
614
|
+
if (!repo) {
|
|
615
|
+
detailText.content = "";
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
const topics = repo.topics.length ? repo.topics.slice(0, 8).join(", ") : "—";
|
|
619
|
+
const desc = repo.description ?? "(no description)";
|
|
620
|
+
const lines = [
|
|
621
|
+
`Name ${repo.fullName}`,
|
|
622
|
+
`Stars ${repo.stars.toLocaleString()}`,
|
|
623
|
+
`Forks ${repo.forks.toLocaleString()}`,
|
|
624
|
+
`Lang ${repo.language ?? "—"}`,
|
|
625
|
+
`Updated ${repo.updatedAt.slice(0, 10)}`,
|
|
626
|
+
repo.archived ? "Status archived" : "",
|
|
627
|
+
repo.isFork ? "Status fork" : "",
|
|
628
|
+
`Topics ${topics}`,
|
|
629
|
+
"",
|
|
630
|
+
desc,
|
|
631
|
+
"",
|
|
632
|
+
repo.url,
|
|
633
|
+
].filter((l) => l !== "");
|
|
634
|
+
detailText.content = lines.join("\n");
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// ── Graph / Chart ──────────────────────────────────────────────────
|
|
638
|
+
|
|
639
|
+
/** Unicode blocks for 8 vertical levels within one character cell. */
|
|
640
|
+
const BLOCKS = [" ", "\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"];
|
|
641
|
+
|
|
642
|
+
function buildChartString(values: number[], width: number, height: number): string[] {
|
|
643
|
+
if (values.length === 0) return ["(no data)"];
|
|
644
|
+
// Sample to fit width
|
|
645
|
+
const sampled: number[] = [];
|
|
646
|
+
for (let i = 0; i < width; i++) {
|
|
647
|
+
sampled.push(values[Math.floor((i / width) * values.length)]);
|
|
648
|
+
}
|
|
649
|
+
const max = Math.max(...sampled, 1);
|
|
650
|
+
const norm = sampled.map(v => Math.round((v / max) * (8 * height - 1)));
|
|
651
|
+
|
|
652
|
+
// Build char grid from bottom
|
|
653
|
+
const grid: string[][] = Array.from({ length: height }, () => Array(width).fill(" "));
|
|
654
|
+
for (let c = 0; c < width; c++) {
|
|
655
|
+
const p = norm[c];
|
|
656
|
+
const full = Math.floor(p / 8);
|
|
657
|
+
const part = p % 8;
|
|
658
|
+
for (let r = 0; r < full && r < height; r++) grid[height - 1 - r][c] = "\u2588";
|
|
659
|
+
if (full < height && part > 0) grid[height - 1 - full][c] = BLOCKS[part];
|
|
660
|
+
}
|
|
661
|
+
// Fill vertical gaps between adjacent columns
|
|
662
|
+
for (let c = 0; c < width - 1; c++) {
|
|
663
|
+
const r1 = Math.floor(norm[c] / 8);
|
|
664
|
+
const r2 = Math.floor(norm[c + 1] / 8);
|
|
665
|
+
const lo = Math.max(0, Math.min(r1, r2));
|
|
666
|
+
const hi = Math.min(height - 1, Math.max(r1, r2));
|
|
667
|
+
for (let r = lo; r <= hi; r++) {
|
|
668
|
+
if (grid[height - 1 - r][c] === " ") grid[height - 1 - r][c] = "\u2588";
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Build rows with Y-axis labels
|
|
673
|
+
const lines: string[] = [];
|
|
674
|
+
const labelRows = [0, Math.floor(height / 4), Math.floor(height / 2), Math.floor(3 * height / 4), height - 1];
|
|
675
|
+
const labelVals = [max, Math.round(max * 0.75), Math.round(max / 2), Math.round(max / 4), 0];
|
|
676
|
+
for (let r = 0; r < height; r++) {
|
|
677
|
+
const idx = labelRows.indexOf(r);
|
|
678
|
+
let label = " ";
|
|
679
|
+
if (idx >= 0) label = String(labelVals[idx]).padStart(4) + " ";
|
|
680
|
+
lines.push(`${label}\u2524${grid[r].join("")}`);
|
|
681
|
+
}
|
|
682
|
+
// X-axis
|
|
683
|
+
lines.push(` \u2514${String.fromCharCode(0x2500).repeat(width)}`);
|
|
684
|
+
return lines;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
async function loadChart(repo: Repo) {
|
|
688
|
+
detailText.content = " Loading chart...";
|
|
689
|
+
renderer.requestRender();
|
|
690
|
+
try {
|
|
691
|
+
const [chartRes, repoRes] = await Promise.all([
|
|
692
|
+
fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}/stats/participation`,
|
|
693
|
+
{ headers: { "User-Agent": "search-cli/1.0" } }),
|
|
694
|
+
fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}`,
|
|
695
|
+
{ headers: { "User-Agent": "search-cli/1.0" } }).then(r => r.ok ? r.json() : null),
|
|
696
|
+
]);
|
|
697
|
+
|
|
698
|
+
let chartSection = "";
|
|
699
|
+
if (chartRes.ok) {
|
|
700
|
+
const data = await chartRes.json();
|
|
701
|
+
if (data?.all && data.all.length >= 2) {
|
|
702
|
+
chartCommitData = data.all;
|
|
703
|
+
const chartW = Math.min(55, chartCommitData.length);
|
|
704
|
+
const chartH = 10;
|
|
705
|
+
const chartLines = buildChartString(chartCommitData, chartW, chartH);
|
|
706
|
+
chartSection = ["", "Weekly commits (52 weeks)", ...chartLines].join("\n");
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const desc = repo.description ?? "";
|
|
711
|
+
const lang = repo.language ?? "\u2014";
|
|
712
|
+
const updated = repo.updatedAt ? repo.updatedAt.slice(0, 10) : "";
|
|
713
|
+
const topics = repoRes?.topics?.length ? repoRes.topics.slice(0, 8).join(", ") : "";
|
|
714
|
+
|
|
715
|
+
const growthLine = currentMode === "trending" && repo.score > 0
|
|
716
|
+
? ` Growth \u25b2 ${fmtStars(repo.score)} ${trendingPeriod}`
|
|
717
|
+
: "";
|
|
718
|
+
|
|
719
|
+
const detailLines = [
|
|
720
|
+
` ${repo.fullName}`,
|
|
721
|
+
` \u2606 ${repo.stars.toLocaleString()} \u2666 ${repo.forks.toLocaleString()} ${lang}`,
|
|
722
|
+
growthLine,
|
|
723
|
+
updated ? ` Updated ${updated}` : "",
|
|
724
|
+
topics ? ` Topics ${topics}` : "",
|
|
725
|
+
"",
|
|
726
|
+
desc,
|
|
727
|
+
"",
|
|
728
|
+
` ${repo.url}`,
|
|
729
|
+
].filter(l => l !== "");
|
|
730
|
+
|
|
731
|
+
detailText.content = [
|
|
732
|
+
...detailLines,
|
|
733
|
+
chartSection,
|
|
734
|
+
].join("\n");
|
|
735
|
+
} catch {
|
|
736
|
+
detailText.content = " Failed to load chart";
|
|
737
|
+
}
|
|
738
|
+
renderer.requestRender();
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function toggleGraph() {
|
|
742
|
+
graphFullscreen = !graphFullscreen;
|
|
743
|
+
if (graphFullscreen) {
|
|
744
|
+
resultsBox.visible = false;
|
|
745
|
+
resultsBox.width = "0%";
|
|
746
|
+
detailBox.width = "100%";
|
|
747
|
+
const opt = resultsSelect.getSelectedOption();
|
|
748
|
+
const repo = opt?.value as Repo | undefined;
|
|
749
|
+
if (repo) loadChart(repo);
|
|
750
|
+
else detailText.content = " Select a repo to view activity";
|
|
751
|
+
} else {
|
|
752
|
+
resultsBox.visible = true;
|
|
753
|
+
resultsBox.width = "50%";
|
|
754
|
+
detailBox.width = "50%";
|
|
755
|
+
const opt = resultsSelect.getSelectedOption();
|
|
756
|
+
if (opt?.value) updateDetail(opt.value as Repo);
|
|
757
|
+
else detailText.content = "";
|
|
758
|
+
}
|
|
759
|
+
renderer.requestRender();
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// ── Trending helpers ────────────────────────────────────────────────
|
|
763
|
+
|
|
764
|
+
function formatTrendingLine(r: { rank: number; owner: string; name: string; stars: number; starsToday: number; language: string }): string {
|
|
765
|
+
const rank = String(r.rank).padStart(2, "0");
|
|
766
|
+
const name = `${r.owner}/${r.name}`.padEnd(30).slice(0, 30);
|
|
767
|
+
const lang = (r.language || "?").padEnd(12).slice(0, 12);
|
|
768
|
+
const stars = `★ ${fmtStars(r.stars)}`.padEnd(9).slice(0, 9);
|
|
769
|
+
const arrow = r.starsToday > 0 ? "▲" : r.starsToday < 0 ? "▼" : "—";
|
|
770
|
+
const growth = `${arrow} ${fmtStars(r.starsToday)} ${trendingPeriod}`;
|
|
771
|
+
return `[${rank}] ${name} ${lang} ${stars} ${growth}`;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function trendingRepoToSelectValue(r: TrendingRepo): Repo {
|
|
775
|
+
return {
|
|
776
|
+
id: 0,
|
|
777
|
+
fullName: `${r.owner}/${r.name}`,
|
|
778
|
+
name: r.name,
|
|
779
|
+
owner: r.owner,
|
|
780
|
+
description: r.description,
|
|
781
|
+
url: `https://github.com/${r.owner}/${r.name}`,
|
|
782
|
+
stars: r.stars,
|
|
783
|
+
forks: 0,
|
|
784
|
+
watchers: 0,
|
|
785
|
+
language: r.language,
|
|
786
|
+
topics: [],
|
|
787
|
+
archived: false,
|
|
788
|
+
isFork: false,
|
|
789
|
+
private: false,
|
|
790
|
+
createdAt: "",
|
|
791
|
+
updatedAt: "",
|
|
792
|
+
pushedAt: "",
|
|
793
|
+
score: r.starsToday,
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
async function loadTrending() {
|
|
798
|
+
currentMode = "trending";
|
|
799
|
+
isLoading = true;
|
|
800
|
+
searchBox.visible = false;
|
|
801
|
+
toolbarText.visible = false;
|
|
802
|
+
trendingTabBox.visible = true;
|
|
803
|
+
renderTrendingTabs();
|
|
804
|
+
resultsSelect.options = [{ name: " Loading trending...", description: "", value: null }];
|
|
805
|
+
detailText.content = "";
|
|
806
|
+
statusMgr.set("loading", "Loading trending repos...");
|
|
807
|
+
renderer.requestRender();
|
|
808
|
+
|
|
809
|
+
try {
|
|
810
|
+
const fetched = await fetchTrendingRepos(tabSince(trendingTab));
|
|
811
|
+
const since = tabSince(trendingTab);
|
|
812
|
+
trendingPeriod = since === "daily" ? "today" : since === "weekly" ? "this week" : "this month";
|
|
813
|
+
resultsSelect.options = fetched.map(r => ({
|
|
814
|
+
name: formatTrendingLine(r),
|
|
815
|
+
description: r.description,
|
|
816
|
+
value: trendingRepoToSelectValue(r),
|
|
817
|
+
}));
|
|
818
|
+
if (fetched.length > 0) {
|
|
819
|
+
resultsSelect.setSelectedIndex(0);
|
|
820
|
+
updateDetail(trendingRepoToSelectValue(fetched[0]));
|
|
821
|
+
} else {
|
|
822
|
+
throw new NoResultsError(trendingTab);
|
|
823
|
+
}
|
|
824
|
+
statusMgr.set("success", `${fetched.length} trending repos — ${trendingTab}`);
|
|
825
|
+
appendHistory({ query: `trending:${trendingTab}`, mode: "trending", tab: trendingTab, timestamp: Date.now(), resultCount: fetched.length });
|
|
826
|
+
} catch (err) {
|
|
827
|
+
const msg = err instanceof SearchCliError ? err.userMessage : (err instanceof Error ? err.message : String(err));
|
|
828
|
+
resultsSelect.options = [{ name: " (error)", description: "", value: null }];
|
|
829
|
+
statusMgr.set("error", msg.slice(0, 60));
|
|
830
|
+
}
|
|
831
|
+
isLoading = false;
|
|
832
|
+
renderer.requestRender();
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function showSearchMode() {
|
|
836
|
+
currentMode = "search";
|
|
837
|
+
trendingTabBox.visible = false;
|
|
838
|
+
searchBox.visible = true;
|
|
839
|
+
toolbarText.visible = true;
|
|
840
|
+
searchInput.placeholder = "Search GitHub repos (e.g. rust cli, or language:Rust stars:>100)";
|
|
841
|
+
searchInput.focus();
|
|
842
|
+
renderer.requestRender();
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
async function doSearch(queryText: string, append = false) {
|
|
846
|
+
const q = queryText.trim();
|
|
847
|
+
if (q === "" || isLoading) return;
|
|
848
|
+
|
|
849
|
+
if (!append) currentPage = 1;
|
|
850
|
+
deepDiveActive = false;
|
|
851
|
+
|
|
852
|
+
isLoading = true;
|
|
853
|
+
currentQueryInput = q;
|
|
854
|
+
if (!append) {
|
|
855
|
+
resultsSelect.options = [{ name: " Searching...", description: "", value: null }];
|
|
856
|
+
detailText.content = "";
|
|
857
|
+
}
|
|
858
|
+
statusMgr.set("searching", `Searching for "${q}"`);
|
|
859
|
+
|
|
860
|
+
try {
|
|
861
|
+
const parsed = applyFlagFilters(parseQuery(q), {});
|
|
862
|
+
validateQuery(parsed);
|
|
863
|
+
const provider = new GitHubSearchProvider(logger);
|
|
864
|
+
const options: SearchOptions = {
|
|
865
|
+
limit: currentLimit,
|
|
866
|
+
sort: currentSort,
|
|
867
|
+
json: false,
|
|
868
|
+
verbose: false,
|
|
869
|
+
token: githubToken,
|
|
870
|
+
page: currentPage,
|
|
871
|
+
};
|
|
872
|
+
const response = await provider.search(parsed, options);
|
|
873
|
+
totalCount = response.totalCount;
|
|
874
|
+
const newRepos = rankRepos(response.repos, options.sort);
|
|
875
|
+
|
|
876
|
+
if (append) {
|
|
877
|
+
currentRepos.push(...newRepos);
|
|
878
|
+
} else {
|
|
879
|
+
currentRepos = newRepos;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
if (currentRepos.length === 0) {
|
|
883
|
+
throw new NoResultsError(q);
|
|
884
|
+
} else {
|
|
885
|
+
resultsSelect.options = currentRepos.map((r) => ({
|
|
886
|
+
name: formatResultLine(r),
|
|
887
|
+
description: "",
|
|
888
|
+
value: r,
|
|
889
|
+
}));
|
|
890
|
+
const idx = append ? resultsSelect.options.length - newRepos.length : 0;
|
|
891
|
+
updateDetail(currentRepos[idx]);
|
|
892
|
+
resultsSelect.setSelectedIndex(idx);
|
|
893
|
+
statusMgr.set("success", `Page ${currentPage} — ${currentRepos.length} of ${totalCount.toLocaleString()} results`);
|
|
894
|
+
if (!append) {
|
|
895
|
+
appendHistory({ query: q, mode: "search", timestamp: Date.now(), resultCount: currentRepos.length });
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
} catch (err) {
|
|
899
|
+
currentRepos = [];
|
|
900
|
+
resultsSelect.options = [{ name: " (error)", description: "", value: null }];
|
|
901
|
+
const msg = err instanceof SearchCliError ? err.userMessage : (err instanceof Error ? err.message : String(err));
|
|
902
|
+
statusMgr.set("error", msg.slice(0, 60));
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
isLoading = false;
|
|
906
|
+
renderer.requestRender();
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function changeSort() {
|
|
910
|
+
const idx = SORT_MODES.findIndex((m) => m.key === currentSort);
|
|
911
|
+
const next = SORT_MODES[(idx + 1) % SORT_MODES.length];
|
|
912
|
+
currentSort = next.key;
|
|
913
|
+
setToolbar();
|
|
914
|
+
if (currentRepos.length > 0) {
|
|
915
|
+
currentRepos = rankRepos(currentRepos, currentSort);
|
|
916
|
+
resultsSelect.options = currentRepos.map((r) => ({
|
|
917
|
+
name: formatResultLine(r),
|
|
918
|
+
description: "",
|
|
919
|
+
value: r,
|
|
920
|
+
}));
|
|
921
|
+
resultsSelect.setSelectedIndex(0);
|
|
922
|
+
updateDetail(currentRepos[0]);
|
|
923
|
+
renderer.requestRender();
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function changeLimit() {
|
|
928
|
+
const limits = [10, 25, 50, 100];
|
|
929
|
+
const idx = limits.indexOf(currentLimit);
|
|
930
|
+
currentLimit = limits[(idx + 1) % limits.length];
|
|
931
|
+
setToolbar();
|
|
932
|
+
setStatus(`Limit set to ${currentLimit}. Press Enter to re-search.`);
|
|
933
|
+
renderer.requestRender();
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// ── Wire events ────────────────────────────────────────────────────
|
|
937
|
+
|
|
938
|
+
// Enter in search input → search (resets to search mode)
|
|
939
|
+
searchInput.on("enter", () => {
|
|
940
|
+
showSearchMode();
|
|
941
|
+
doSearch(searchInput.value);
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
// Navigate results → update detail pane
|
|
945
|
+
resultsSelect.on("selectionChanged", () => {
|
|
946
|
+
const opt = resultsSelect.getSelectedOption();
|
|
947
|
+
const repo = opt?.value as Repo | undefined;
|
|
948
|
+
if (repo) {
|
|
949
|
+
if (graphFullscreen) loadChart(repo);
|
|
950
|
+
else updateDetail(repo);
|
|
951
|
+
}
|
|
952
|
+
renderer.requestRender();
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
// Enter on a result → open URL
|
|
956
|
+
resultsSelect.on("itemSelected", () => {
|
|
957
|
+
const opt = resultsSelect.getSelectedOption();
|
|
958
|
+
const repo = opt?.value as Repo | undefined;
|
|
959
|
+
if (repo) openUrl(repo.url);
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
// ── Global keyboard shortcuts ──────────────────────────────────────
|
|
963
|
+
renderer.keyInput.on("keypress", (key) => {
|
|
964
|
+
// ── Overlay-mode handling ────────────────────────────────────────
|
|
965
|
+
if (currentOverlay !== "none") {
|
|
966
|
+
// Escape or q closes any overlay
|
|
967
|
+
if (key.name === "escape" || key.name === "q") {
|
|
968
|
+
showOverlay("none");
|
|
969
|
+
renderer.requestRender();
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// History overlay
|
|
974
|
+
if (currentOverlay === "history") {
|
|
975
|
+
if (key.ctrl && key.name === "d") {
|
|
976
|
+
const sel = historySelect.getSelectedOption();
|
|
977
|
+
if (sel?.value) {
|
|
978
|
+
deleteHistoryEntry((sel.value as any).index);
|
|
979
|
+
refreshHistory();
|
|
980
|
+
setStatus("Deleted history entry");
|
|
981
|
+
}
|
|
982
|
+
renderer.requestRender();
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
if (key.ctrl && key.name === "x") {
|
|
986
|
+
clearHistory();
|
|
987
|
+
refreshHistory();
|
|
988
|
+
setStatus("History cleared");
|
|
989
|
+
renderer.requestRender();
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
// SelectRenderable handles ↑↓; Enter runs the search
|
|
993
|
+
if (key.name === "enter" || key.name === "return") {
|
|
994
|
+
const sel = historySelect.getSelectedOption();
|
|
995
|
+
if (sel?.value) {
|
|
996
|
+
const entry = (sel.value as any).entry;
|
|
997
|
+
showOverlay("none");
|
|
998
|
+
if (entry.mode === "trending") {
|
|
999
|
+
if (entry.tab) trendingTab = entry.tab as typeof TAB_NAMES[number];
|
|
1000
|
+
loadTrending();
|
|
1001
|
+
} else {
|
|
1002
|
+
searchInput.value = entry.query;
|
|
1003
|
+
showSearchMode();
|
|
1004
|
+
doSearch(entry.query);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
return; // all other keys ignored in history overlay
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// Bookmarks overlay
|
|
1013
|
+
if (currentOverlay === "bookmarks") {
|
|
1014
|
+
if (key.name === "d") {
|
|
1015
|
+
const sel = bookmarksSelect.getSelectedOption();
|
|
1016
|
+
if (sel?.value) {
|
|
1017
|
+
removeBookmark((sel.value as any).repo.fullName);
|
|
1018
|
+
refreshBookmarks();
|
|
1019
|
+
setStatus("Bookmark removed");
|
|
1020
|
+
}
|
|
1021
|
+
renderer.requestRender();
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
if (key.name === "enter" || key.name === "return") {
|
|
1025
|
+
const sel = bookmarksSelect.getSelectedOption();
|
|
1026
|
+
if (sel?.value) openUrl((sel.value as any).repo.url);
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// Saved searches overlay
|
|
1033
|
+
if (currentOverlay === "saved") {
|
|
1034
|
+
if (key.name === "d") {
|
|
1035
|
+
const sel = savedSelect.getSelectedOption();
|
|
1036
|
+
if (sel?.value) {
|
|
1037
|
+
deleteSavedSearch((sel.value as any).name);
|
|
1038
|
+
refreshSavedSearches();
|
|
1039
|
+
setStatus("Saved search deleted");
|
|
1040
|
+
}
|
|
1041
|
+
renderer.requestRender();
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
if (key.name === "enter" || key.name === "return") {
|
|
1045
|
+
const sel = savedSelect.getSelectedOption();
|
|
1046
|
+
if (sel?.value) {
|
|
1047
|
+
const s = sel.value as any;
|
|
1048
|
+
showOverlay("none");
|
|
1049
|
+
touchSavedSearch(s.name);
|
|
1050
|
+
if (s.mode === "trending") {
|
|
1051
|
+
if (s.tab) trendingTab = s.tab as typeof TAB_NAMES[number];
|
|
1052
|
+
loadTrending();
|
|
1053
|
+
} else {
|
|
1054
|
+
searchInput.value = s.query;
|
|
1055
|
+
showSearchMode();
|
|
1056
|
+
currentSort = s.sort;
|
|
1057
|
+
currentLimit = s.limit;
|
|
1058
|
+
setToolbar();
|
|
1059
|
+
doSearch(s.query);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// Topics explorer overlay
|
|
1068
|
+
if (currentOverlay === "topics") {
|
|
1069
|
+
if (key.name === "enter" || key.name === "return") {
|
|
1070
|
+
const sel = topicsSelect.getSelectedOption();
|
|
1071
|
+
if (sel?.value) {
|
|
1072
|
+
const topic = sel.value as any;
|
|
1073
|
+
showOverlay("none");
|
|
1074
|
+
searchInput.value = `topic:${topic.name}`;
|
|
1075
|
+
showSearchMode();
|
|
1076
|
+
doSearch(`topic:${topic.name}`);
|
|
1077
|
+
}
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// Export overlay
|
|
1084
|
+
if (currentOverlay === "export") {
|
|
1085
|
+
if (key.name === "enter" || key.name === "return") {
|
|
1086
|
+
const sel = exportSelect.getSelectedOption();
|
|
1087
|
+
if (sel?.value && currentRepos.length > 0) {
|
|
1088
|
+
const format = sel.value as ExportFormat;
|
|
1089
|
+
const path = exportToFile(currentRepos, format);
|
|
1090
|
+
showOverlay("none");
|
|
1091
|
+
setStatus(`✓ Exported to ${path}`);
|
|
1092
|
+
} else {
|
|
1093
|
+
setStatus("No results to export");
|
|
1094
|
+
showOverlay("none");
|
|
1095
|
+
}
|
|
1096
|
+
renderer.requestRender();
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// Compare overlay: Esc closes
|
|
1103
|
+
if (currentOverlay === "compare") {
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// Notifications overlay
|
|
1108
|
+
if (currentOverlay === "notifications") {
|
|
1109
|
+
if (key.ctrl && key.name === "c") {
|
|
1110
|
+
dismissAll();
|
|
1111
|
+
refreshNotifications();
|
|
1112
|
+
renderer.requestRender();
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
if (key.name === "d") {
|
|
1116
|
+
const notifs = getNotifications();
|
|
1117
|
+
if (notifs.length > 0) {
|
|
1118
|
+
dismissNotification(notifs[0].id);
|
|
1119
|
+
refreshNotifications();
|
|
1120
|
+
setStatus("Notification dismissed");
|
|
1121
|
+
}
|
|
1122
|
+
renderer.requestRender();
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// Share overlay
|
|
1129
|
+
if (currentOverlay === "share") {
|
|
1130
|
+
if (key.name === "enter" || key.name === "return") {
|
|
1131
|
+
const sel = shareSelect.getSelectedOption();
|
|
1132
|
+
if (sel?.value) {
|
|
1133
|
+
const opt = resultsSelect.getSelectedOption();
|
|
1134
|
+
const repo = opt?.value as Repo | undefined;
|
|
1135
|
+
if (repo) {
|
|
1136
|
+
const text = formatShare(repo, sel.value as ShareFormat);
|
|
1137
|
+
copyToClipboard(text).then((ok) => {
|
|
1138
|
+
showOverlay("none");
|
|
1139
|
+
setStatus(ok ? "✓ Copied!" : "Clipboard not available");
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
renderer.requestRender();
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
// Help overlay: any key closes
|
|
1150
|
+
if (currentOverlay === "help") {
|
|
1151
|
+
showOverlay("none");
|
|
1152
|
+
renderer.requestRender();
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// ── Main view handling (no overlay active) ────────────────────────
|
|
1158
|
+
|
|
1159
|
+
// Always allow q to quit
|
|
1160
|
+
if (key.name === "q") {
|
|
1161
|
+
saveSession({ mode: currentMode, query: currentQueryInput, sort: currentSort, limit: currentLimit, trendingTab });
|
|
1162
|
+
cleanup();
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// '?' / Ctrl+H toggle help overlay
|
|
1167
|
+
if (key.name === "?" || (key.name === "h" && key.ctrl)) {
|
|
1168
|
+
refreshHistory(); // refresh in background
|
|
1169
|
+
showOverlay("help");
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// Ctrl+R — search history overlay
|
|
1174
|
+
if (key.ctrl && key.name === "r") {
|
|
1175
|
+
refreshHistory();
|
|
1176
|
+
showOverlay("history");
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// Ctrl+S — save current search
|
|
1181
|
+
if (key.ctrl && key.name === "s") {
|
|
1182
|
+
if (currentQueryInput) {
|
|
1183
|
+
const name = currentQueryInput.length > 40
|
|
1184
|
+
? currentQueryInput.slice(0, 37) + "..."
|
|
1185
|
+
: currentQueryInput;
|
|
1186
|
+
saveSearch(name, currentQueryInput, currentMode, currentSort, currentLimit, currentMode === "trending" ? trendingTab : undefined);
|
|
1187
|
+
setStatus(`✓ Saved as "${name}"`);
|
|
1188
|
+
} else {
|
|
1189
|
+
setStatus("No search to save");
|
|
1190
|
+
}
|
|
1191
|
+
renderer.requestRender();
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
// Ctrl+O — saved searches panel
|
|
1196
|
+
if (key.ctrl && key.name === "o") {
|
|
1197
|
+
refreshSavedSearches();
|
|
1198
|
+
showOverlay("saved");
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// Ctrl+N — notifications panel
|
|
1203
|
+
if (key.ctrl && key.name === "n") {
|
|
1204
|
+
refreshNotifications();
|
|
1205
|
+
showOverlay("notifications");
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// Ctrl+P — share repo
|
|
1210
|
+
if (key.ctrl && key.name === "p") {
|
|
1211
|
+
const opt = resultsSelect.getSelectedOption();
|
|
1212
|
+
const repo = opt?.value as Repo | undefined;
|
|
1213
|
+
if (!repo) {
|
|
1214
|
+
setStatus("No repo selected to share");
|
|
1215
|
+
renderer.requestRender();
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
showOverlay("share");
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
// 'b' — toggle bookmark on selected repo
|
|
1223
|
+
if (key.name === "b" && !key.shift) {
|
|
1224
|
+
const opt = resultsSelect.getSelectedOption();
|
|
1225
|
+
const repo = opt?.value as Repo | undefined;
|
|
1226
|
+
if (repo) {
|
|
1227
|
+
const added = toggleBookmark(repo);
|
|
1228
|
+
setStatus(added ? `Bookmarked ${repo.fullName}` : `Unbookmarked ${repo.fullName}`);
|
|
1229
|
+
renderer.requestRender();
|
|
1230
|
+
}
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
// 'B' (shift+b) — open bookmarks panel
|
|
1235
|
+
if (key.name === "b" && key.shift) {
|
|
1236
|
+
refreshBookmarks();
|
|
1237
|
+
showOverlay("bookmarks");
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
// Tab auto-completes qualifiers in search input
|
|
1242
|
+
if (key.name === "tab") {
|
|
1243
|
+
if (searchBox.visible) {
|
|
1244
|
+
const val = searchInput.value;
|
|
1245
|
+
const lastSpace = val.lastIndexOf(" ");
|
|
1246
|
+
const currentWord = lastSpace >= 0 ? val.slice(lastSpace + 1) : val;
|
|
1247
|
+
const suggestions = suggestFor(currentWord);
|
|
1248
|
+
if (suggestions.length > 0) {
|
|
1249
|
+
const newVal = lastSpace >= 0
|
|
1250
|
+
? val.slice(0, lastSpace + 1) + suggestions[0]
|
|
1251
|
+
: suggestions[0];
|
|
1252
|
+
searchInput.value = newVal;
|
|
1253
|
+
renderer.requestRender();
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// '/' focuses search, switches to search mode
|
|
1260
|
+
if (key.name === "/") {
|
|
1261
|
+
showSearchMode();
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
if (key.name === "g") {
|
|
1266
|
+
toggleGraph();
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
if (key.name === "t") {
|
|
1271
|
+
if (currentMode === "trending") {
|
|
1272
|
+
showSearchMode();
|
|
1273
|
+
} else {
|
|
1274
|
+
loadTrending();
|
|
1275
|
+
}
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// Number keys 1-5 switch trending tabs (only in trending mode)
|
|
1280
|
+
if (/^[1-5]$/.test(key.name)) {
|
|
1281
|
+
if (currentMode === "trending") {
|
|
1282
|
+
const idx = parseInt(key.name) - 1;
|
|
1283
|
+
if (idx >= 0 && idx < TAB_NAMES.length) {
|
|
1284
|
+
trendingTab = TAB_NAMES[idx];
|
|
1285
|
+
loadTrending();
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
if (key.name === "r") {
|
|
1292
|
+
if (currentMode === "trending") {
|
|
1293
|
+
loadTrending();
|
|
1294
|
+
} else {
|
|
1295
|
+
doSearch(searchInput.value || currentQueryInput);
|
|
1296
|
+
}
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
if (key.name === "s") {
|
|
1301
|
+
if (currentMode === "search") changeSort();
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
if (key.name === "l") {
|
|
1306
|
+
if (currentMode === "search") {
|
|
1307
|
+
changeLimit();
|
|
1308
|
+
} else if (currentMode === "trending") {
|
|
1309
|
+
const idx = TAB_NAMES.indexOf(trendingTab);
|
|
1310
|
+
if (idx < TAB_NAMES.length - 1) { trendingTab = TAB_NAMES[idx + 1]; loadTrending(); }
|
|
1311
|
+
}
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
if (key.name === "o") {
|
|
1316
|
+
const opt = resultsSelect.getSelectedOption();
|
|
1317
|
+
const repo = opt?.value as Repo | undefined;
|
|
1318
|
+
if (repo) openUrl(repo.url);
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
// Left/right/h arrows switch trending tabs
|
|
1323
|
+
if (key.name === "left" || key.name === "h") {
|
|
1324
|
+
if (currentMode === "trending") {
|
|
1325
|
+
const idx = TAB_NAMES.indexOf(trendingTab);
|
|
1326
|
+
if (idx > 0) { trendingTab = TAB_NAMES[idx - 1]; loadTrending(); }
|
|
1327
|
+
}
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
// ── P2: Power Tool keys ───────────────────────────────────────────
|
|
1332
|
+
|
|
1333
|
+
// 'd' — toggle deep-dive on selected repo
|
|
1334
|
+
if (key.name === "d") {
|
|
1335
|
+
const opt = resultsSelect.getSelectedOption();
|
|
1336
|
+
const repo = opt?.value as Repo | undefined;
|
|
1337
|
+
if (!repo) return;
|
|
1338
|
+
deepDiveActive = !deepDiveActive;
|
|
1339
|
+
if (deepDiveActive) {
|
|
1340
|
+
detailText.content = " Loading deep-dive...";
|
|
1341
|
+
renderer.requestRender();
|
|
1342
|
+
fetchDeepDive(repo, githubToken)
|
|
1343
|
+
.then((data) => {
|
|
1344
|
+
detailText.content = buildDeepDiveText(data);
|
|
1345
|
+
renderer.requestRender();
|
|
1346
|
+
})
|
|
1347
|
+
.catch(() => {
|
|
1348
|
+
detailText.content = " Failed to load deep-dive";
|
|
1349
|
+
renderer.requestRender();
|
|
1350
|
+
});
|
|
1351
|
+
} else {
|
|
1352
|
+
updateDetail(repo);
|
|
1353
|
+
renderer.requestRender();
|
|
1354
|
+
}
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
// 'c' — toggle compare list for selected repo
|
|
1359
|
+
if (key.name === "c" && !key.shift) {
|
|
1360
|
+
const opt = resultsSelect.getSelectedOption();
|
|
1361
|
+
const repo = opt?.value as Repo | undefined;
|
|
1362
|
+
if (!repo) return;
|
|
1363
|
+
const idx = compareList.findIndex((r) => r.fullName === repo.fullName);
|
|
1364
|
+
if (idx >= 0) {
|
|
1365
|
+
compareList.splice(idx, 1);
|
|
1366
|
+
setStatus(`Removed ${repo.fullName} from comparison`);
|
|
1367
|
+
} else {
|
|
1368
|
+
compareList.push(repo);
|
|
1369
|
+
setStatus(`${repo.fullName} added to comparison (${compareList.length} selected)`);
|
|
1370
|
+
}
|
|
1371
|
+
renderer.requestRender();
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
// 'C' (Shift+c) — show comparison view
|
|
1376
|
+
if (key.name === "c" && key.shift) {
|
|
1377
|
+
refreshCompare();
|
|
1378
|
+
showOverlay("compare");
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
// 'E' (Shift+e) — topic explorer
|
|
1383
|
+
if (key.name === "e" && key.shift) {
|
|
1384
|
+
refreshTopics();
|
|
1385
|
+
showOverlay("topics");
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// Ctrl+E — export
|
|
1390
|
+
if (key.ctrl && key.name === "e") {
|
|
1391
|
+
showOverlay("export");
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
// PageDown / Ctrl+F — next page
|
|
1396
|
+
if (key.name === "pagedown" || (key.ctrl && key.name === "f")) {
|
|
1397
|
+
if (currentMode === "search" && currentQueryInput) {
|
|
1398
|
+
currentPage++;
|
|
1399
|
+
doSearch(currentQueryInput, true);
|
|
1400
|
+
}
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
// PageUp / Ctrl+B — scroll to top of results
|
|
1405
|
+
if (key.name === "pageup" || (key.ctrl && key.name === "b")) {
|
|
1406
|
+
if (currentRepos.length > 0) {
|
|
1407
|
+
resultsSelect.setSelectedIndex(0);
|
|
1408
|
+
updateDetail(currentRepos[0]);
|
|
1409
|
+
renderer.requestRender();
|
|
1410
|
+
}
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1413
|
+
});
|
|
1414
|
+
|
|
1415
|
+
// ── Start ──────────────────────────────────────────────────────────
|
|
1416
|
+
renderer.start();
|
|
1417
|
+
if (currentMode === "trending") {
|
|
1418
|
+
loadTrending();
|
|
1419
|
+
} else {
|
|
1420
|
+
searchInput.value = currentQueryInput;
|
|
1421
|
+
searchInput.focus();
|
|
1422
|
+
if (currentQueryInput) doSearch(currentQueryInput);
|
|
1423
|
+
}
|
|
1424
|
+
renderer.requestRender();
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
// ── Utilities ─────────────────────────────────────────────────────────
|
|
1428
|
+
|
|
1429
|
+
function formatResultLine(repo: Repo): string {
|
|
1430
|
+
const stars = formatStars(repo.stars);
|
|
1431
|
+
const lang = (repo.language ?? "?").padEnd(12).slice(0, 12);
|
|
1432
|
+
return `${repo.fullName} ${stars}`;
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
function formatStars(n: number): string {
|
|
1436
|
+
if (n >= 1000) return `★ ${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`;
|
|
1437
|
+
return `★ ${n}`;
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
function formatToolbar(sort: SortStrategy, limit: number): string {
|
|
1441
|
+
const sortLabel = SORT_MODES.find((m) => m.key === sort)?.label ?? sort;
|
|
1442
|
+
return ` sort: ${sortLabel} limit: ${limit} (s=cycle sort l=cycle limit)`;
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
function openUrl(url: string): void {
|
|
1446
|
+
try {
|
|
1447
|
+
const p = process.platform;
|
|
1448
|
+
if (p === "win32") Bun.spawn(["cmd", "/c", "start", "", url]);
|
|
1449
|
+
else if (p === "darwin") Bun.spawn(["open", url]);
|
|
1450
|
+
else Bun.spawn(["xdg-open", url]);
|
|
1451
|
+
} catch {
|
|
1452
|
+
// non-critical
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
function cleanup(): void {
|
|
1457
|
+
process.exit(0);
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
// ── Auto-run ──────────────────────────────────────────────────────────
|
|
1461
|
+
if (import.meta.main) {
|
|
1462
|
+
launchBrowser();
|
|
1463
|
+
}
|