github-search-cli 0.8.0 → 0.8.1

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/src/trending.ts CHANGED
@@ -1,557 +1,559 @@
1
- #!/usr/bin/env node
2
- /** trending.ts — "Trending Repositories" view for the GitHub repo browser CLI. */
3
- import {
4
- createCliRenderer,
5
- BoxRenderable,
6
- TextRenderable,
7
- ScrollBoxRenderable,
8
- StyledText,
9
- bold,
10
- fg,
11
- dim,
12
- t,
13
- } from "@opentui/core";
14
- import type { Repo } from "./types";
15
- import { openUrl } from "./open-url";
16
- import { NetworkError } from "./errors";
17
- import type { GitHubSearchEnvelope } from "./search";
18
-
19
- // ─── Tokyo Night palette ──────────────────────────────────────────────
20
- const C = {
21
- bg: "#1a1b26",
22
- surface: "#16161e",
23
- text: "#a9b1d6",
24
- muted: "#565f89",
25
- cyan: "#7dcfff",
26
- purple: "#bb9af7",
27
- green: "#9ece6a",
28
- gold: "#e0af68",
29
- orange: "#ff9e64",
30
- red: "#f7768e",
31
- blue: "#7aa2f7",
32
- lightBlue: "#b4f9f8",
33
- selectionBg: "#3b6ce8",
34
- selectionText: "#c0caf5",
35
- border: "#363b54",
36
- descText: "#6c7086", // lighter muted for descriptions
37
- rankBg: "#2a2e42",
38
- rankTop1: "#e0af68", // gold
39
- rankTop2: "#9ece6a", // silver-ish green
40
- rankTop3: "#ff9e64", // bronze-ish orange
41
- rankRest: "#565f89", // muted gray
42
- };
43
-
44
- // ─── Language → color mapping ─────────────────────────────────────────
45
- const LANG_COLORS: Record<string, string> = {
46
- Rust: "#e0af68",
47
- TypeScript: "#7dcfff",
48
- JavaScript: "#e0d068",
49
- Python: "#7aa2f7",
50
- Go: "#73daca",
51
- Java: "#f7768e",
52
- Ruby: "#f7768e",
53
- C: "#f7768e",
54
- "C++": "#f7768e",
55
- "C#": "#f7768e",
56
- Shell: "#9ece6a",
57
- Zig: "#e0af68",
58
- Lua: "#7dcfff",
59
- Haskell: "#bb9af7",
60
- Elixir: "#bb9af7",
61
- Dart: "#73daca",
62
- Swift: "#f7768e",
63
- Kotlin: "#7aa2f7",
64
- };
65
-
66
- function langColor(lang: string): string {
67
- return LANG_COLORS[lang] ?? C.muted;
68
- }
69
-
70
- function rankColor(rank: number): string {
71
- if (rank === 1) return C.rankTop1;
72
- if (rank === 2) return C.rankTop2;
73
- if (rank === 3) return C.rankTop3;
74
- return C.rankRest;
75
- }
76
-
77
- // ─── Tab definitions ──────────────────────────────────────────────────
78
- export const TAB_NAMES = ["Today", "This Week", "This Month"] as const;
79
- export type TabName = (typeof TAB_NAMES)[number];
80
-
81
- /** Map TabName to GitHub API date range.
82
- * We use the search API with `created:>DATE&sort=stars` so only
83
- * daily/weekly/monthly ranges are meaningful.
84
- */
85
- export function tabSince(tab: TabName): "daily" | "weekly" | "monthly" {
86
- switch (tab) {
87
- case "Today": return "daily";
88
- case "This Week": return "weekly";
89
- default: return "monthly";
90
- }
91
- }
92
-
93
- // ─── Types ────────────────────────────────────────────────────────────
94
- export interface TrendingRepo {
95
- rank: number;
96
- owner: string;
97
- name: string;
98
- stars: number;
99
- starsToday: number;
100
- language: string;
101
- description: string;
102
- }
103
-
104
- // ─── Mock data ────────────────────────────────────────────────────────
105
-
106
- /**
107
- * Fetch trending repos from the GitHub search API instead of HTML scraping.
108
- * Uses `created:>DATE&sort=stars` queries since the API doesn't expose a
109
- * dedicated trending endpoint.
110
- *
111
- * Note: `starsToday` is always 0 because the API doesn't provide period growth.
112
- * The growth indicator is hidden when 0.
113
- */
114
- export async function fetchTrendingRepos(since: "daily" | "weekly" | "monthly"): Promise<TrendingRepo[]> {
115
- const now = Date.now();
116
- const msPerDay = 86_400_000;
117
- const days = since === "daily" ? 1 : since === "weekly" ? 7 : 30;
118
- const date = new Date(now - days * msPerDay).toISOString().slice(0, 10);
119
-
120
- const url = `https://api.github.com/search/repositories?q=created:>${date}&sort=stars&order=desc&per_page=25`;
121
- let res: Response;
122
- try {
123
- res = await fetch(url, {
124
- headers: {
125
- Accept: "application/vnd.github+json",
126
- "User-Agent": "ghfind",
127
- },
128
- });
129
- } catch {
130
- throw new NetworkError();
131
- }
132
- if (!res.ok) throw new NetworkError();
133
- const data = (await res.json()) as GitHubSearchEnvelope;
134
-
135
- return data.items.map((item, i) => ({
136
- rank: i + 1,
137
- owner: item.owner?.login ?? "?",
138
- name: item.name,
139
- stars: item.stargazers_count,
140
- starsToday: 0, // API doesn't provide period growth
141
- language: item.language ?? "",
142
- description: item.description ?? "",
143
- }));
144
- }
145
-
146
-
147
-
148
- // ─── Number formatting ────────────────────────────────────────────────
149
-
150
- /** 1234 → "1.2k", 1234567 → "1.2M", 999999 → "1M" */
151
- export function fmtStars(n: number): string {
152
- if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
153
- if (n >= 999_500) return "1M"; // ponytail: round 999500+ up before showing 1000k
154
- if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
155
- return String(n);
156
- }
157
-
158
- /** Format signed number: 1204 → "+1.2k" */
159
- export function fmtSigned(n: number): string {
160
- const abs = fmtStars(Math.abs(n));
161
- return n >= 0 ? `+${abs}` : `-${abs}`;
162
- }
163
-
164
- // ─── Row formatting ───────────────────────────────────────────────────
165
-
166
- /**
167
- * Format one repo as a StyledText for per-chunk coloring.
168
- *
169
- * Layout (two lines):
170
- * "[01] owner/name ● Rust ★ 89.2k ▲ +235 today"
171
- * " Build smaller, faster desktop apps."
172
- *
173
- * Uses OpenTUI's StyledText + bold/fg/dim helpers for per-element colors:
174
- * - Rank badge: colored background via bracket style, rank color
175
- * - owner/name: bold white
176
- * - Language dot: language-specific color
177
- * - Stars: gold
178
- * - Growth: green bold
179
- * - Description: dim muted gray
180
- */
181
- export function formatRepoLine(repo: TrendingRepo): StyledText {
182
- const rank = String(repo.rank).padStart(2, "0");
183
- const full = `${repo.owner}/${repo.name}`;
184
- const lc = langColor(repo.language);
185
- const rc = rankColor(repo.rank);
186
- const star = "★";
187
- const arrow = repo.starsToday > 0 ? "▲" : "▼";
188
- const growth = `${arrow} ${fmtSigned(repo.starsToday)} today`;
189
- const desc = repo.description.length > 60
190
- ? repo.description.slice(0, 57) + "..."
191
- : repo.description;
192
-
193
- // Build the styled line using OpenTUI's `t` template literal tag.
194
- // Each interpolated value is a TextChunk with its own fg/bg/attributes.
195
- const line1 = t`${dim(fg(C.rankBg)(`[${rank}]`))} ${bold(fg("#c0caf5")(full))} ${fg(lc)(`● ${repo.language}`)} ${fg(C.gold)(`${star} ${fmtStars(repo.stars)}`)} ${bold(fg(C.green)(growth))}`;
196
- const line2 = t` ${dim(fg(C.muted)(desc))}`;
197
-
198
- // Combine into a single StyledText with a newline between lines.
199
- // StyledText constructor accepts an array of TextChunk objects.
200
- const chunks = [
201
- ...line1.chunks,
202
- { text: "\n", __isChunk: true as const, fg: C.text },
203
- ...line2.chunks,
204
- ];
205
- return new StyledText(chunks);
206
- }
207
-
208
- // ─── Tab query builder ────────────────────────────────────────────────
209
- /**
210
- * Build a GitHub search query string for the given time range tab.
211
- *
212
- * Examples:
213
- * "Today" → "created:>2026-07-09 sort:stars"
214
- * "This Week" → "created:>2026-07-03 sort:stars"
215
- * "All Time" → "sort:stars"
216
- */
217
- export function getTrendingQuery(tab: TabName): string {
218
- const now = Date.now();
219
- const msPerDay = 86_400_000;
220
- const ranges: Record<TabName, number> = {
221
- Today: 1,
222
- "This Week": 7,
223
- "This Month": 30,
224
- };
225
- const days = ranges[tab];
226
- if (days === 0) return "sort:stars";
227
- const d = new Date(now - days * msPerDay);
228
- return `created:>${d.toISOString().slice(0, 10)} sort:stars`;
229
- }
230
-
231
- // ─── Main TUI entry ───────────────────────────────────────────────────
232
- export async function launchTrending(): Promise<void> {
233
- let renderer;
234
- try {
235
- renderer = await createCliRenderer();
236
- } catch (err) {
237
- console.error(
238
- "Failed to start the interactive browser.\n",
239
- `Reason: ${err instanceof Error ? err.message : String(err)}\n`,
240
- );
241
- process.exit(1);
242
- return;
243
- }
244
-
245
- const root = renderer.root;
246
- root.flexDirection = "column";
247
- root.backgroundColor = C.bg;
248
-
249
- // ── State ──
250
- let selectedTab = 1; // "This Week" default
251
- let currentPeriod = "this week";
252
- let repos: TrendingRepo[] = [];
253
- let selectedRepoIdx = 0;
254
- let isLoading = false;
255
-
256
- // ── Fetch from github.com/trending ──
257
- async function loadTab(index: number) {
258
- if (index < 0 || index >= TAB_NAMES.length) return;
259
- selectedTab = index;
260
- // Map tab to data period label ("This Year" and "All Time" use monthly data)
261
- const since = tabSince(TAB_NAMES[index]);
262
- currentPeriod = since === "daily" ? "today" : since === "weekly" ? "this week" : "this month";
263
- isLoading = true;
264
- for (const rb of rowBoxes) scrollBox.remove(rb);
265
- rowBoxes.length = 0;
266
- rowTexts.length = 0;
267
- const loadingText = new TextRenderable(renderer, {
268
- content: t`${dim(fg(C.muted)(` Loading trending repos...`))}`,
269
- backgroundColor: C.bg,
270
- height: 1,
271
- });
272
- scrollBox.add(loadingText);
273
- renderer.requestRender();
274
-
275
- try {
276
- const fetched = await fetchTrendingRepos(tabSince(TAB_NAMES[index]));
277
- scrollBox.remove(loadingText);
278
- repos = fetched;
279
- selectedRepoIdx = 0;
280
- rebuildList();
281
- } catch (err) {
282
- scrollBox.remove(loadingText);
283
- const msg = err instanceof Error ? err.message : String(err);
284
- const errText = new TextRenderable(renderer, {
285
- content: t`${fg(C.red)(` Error: ${msg.slice(0, 70)}`)}`,
286
- backgroundColor: C.bg,
287
- height: 1,
288
- });
289
- scrollBox.add(errText);
290
- }
291
- isLoading = false;
292
- renderTabBar();
293
- renderer.requestRender();
294
- }
295
-
296
- // ── Switch tab ──
297
- function switchTab(index: number) {
298
- if (index < 0 || index >= TAB_NAMES.length || index === selectedTab) return;
299
- loadTab(index);
300
- }
301
-
302
- // ── Outer frame ──
303
- // Rounded border around the entire app for visual polish.
304
- const outerBox = new BoxRenderable(renderer, {
305
- flexGrow: 1,
306
- flexDirection: "column",
307
- backgroundColor: C.bg,
308
- border: true,
309
- borderColor: C.border,
310
- borderStyle: "rounded",
311
- paddingX: 0,
312
- paddingY: 0,
313
- });
314
-
315
- // ── Header bar ──
316
- const headerBox = new BoxRenderable(renderer, {
317
- height: 1,
318
- backgroundColor: C.bg,
319
- paddingX: 1,
320
- });
321
- const headerText = new TextRenderable(renderer, {
322
- content: t`${bold(fg(C.purple)("⟠ tulipsearch"))}${fg(C.muted)(" — Trending")}`,
323
- height: 1,
324
- });
325
- headerBox.add(headerText);
326
- outerBox.add(headerBox);
327
-
328
- // ── Tab bar ──
329
- const tabBox = new BoxRenderable(renderer, {
330
- flexDirection: "row",
331
- height: 1,
332
- backgroundColor: C.bg,
333
- paddingX: 1,
334
- });
335
- const tabTexts: TextRenderable[] = [];
336
-
337
- function renderTabBar() {
338
- for (const t of tabTexts) tabBox.remove(t);
339
- tabTexts.length = 0;
340
- TAB_NAMES.forEach((name, i) => {
341
- const isActive = i === selectedTab;
342
- const label = ` ${name} `;
343
- const tt = new TextRenderable(renderer, {
344
- content: isActive ? t`${bold(fg(C.bg)(label))}` : t`${fg(C.muted)(label)}`,
345
- color: isActive ? C.bg : C.muted,
346
- backgroundColor: isActive ? C.cyan : C.bg,
347
- height: 1,
348
- });
349
- tabTexts.push(tt);
350
- tabBox.add(tt);
351
- if (i < TAB_NAMES.length - 1) {
352
- const sp = new TextRenderable(renderer, {
353
- content: " ",
354
- color: C.muted,
355
- backgroundColor: C.bg,
356
- height: 1,
357
- });
358
- tabTexts.push(sp);
359
- tabBox.add(sp);
360
- }
361
- });
362
- }
363
- renderTabBar();
364
- outerBox.add(tabBox);
365
-
366
- // Subtle horizontal divider between tab bar and list
367
- const divider = new TextRenderable(renderer, {
368
- content: "─".repeat(80),
369
- color: C.border,
370
- height: 1,
371
- backgroundColor: C.bg,
372
- });
373
- outerBox.add(divider);
374
-
375
- // ── Repo list (scrollable) ──
376
- const scrollBox = new ScrollBoxRenderable(renderer, {
377
- flexGrow: 1,
378
- backgroundColor: C.bg,
379
- scrollY: true,
380
- scrollX: false,
381
- viewportCulling: true,
382
- });
383
-
384
- // We render rows as individual BoxRenderables inside the scroll area.
385
- // Each row has alternating background and a left-border accent on selection.
386
- const rowBoxes: BoxRenderable[] = [];
387
- const rowTexts: TextRenderable[] = [];
388
-
389
- function rebuildList() {
390
- for (const rb of rowBoxes) scrollBox.remove(rb);
391
- rowBoxes.length = 0;
392
- rowTexts.length = 0;
393
-
394
- repos.forEach((r, i) => {
395
- const isEven = i % 2 === 0;
396
- const isSelected = i === selectedRepoIdx;
397
- const rowBg = isSelected ? C.selectionBg : (isEven ? C.bg : C.surface);
398
-
399
- const rank = i + 1;
400
- const rc = rankColor(rank);
401
- const lc = langColor(r.language ?? "");
402
- const desc = (r.description ?? "").length > 55
403
- ? (r.description ?? "").slice(0, 52) + "..."
404
- : (r.description ?? "");
405
- const arrow = r.starsToday > 0 ? "▲" : r.starsToday < 0 ? "▼" : "—";
406
- const growthStr = `${arrow} ${fmtStars(Math.abs(r.starsToday))} ${currentPeriod}`;
407
-
408
- const rankStr = `[${String(rank).padStart(2, "0")}]`;
409
- const nameStr = `${r.owner}/${r.name}`.padEnd(30).slice(0, 30);
410
- const langStr = `● ${r.language || "—"}`.padEnd(14).slice(0, 14);
411
- const starsStr = `★ ${fmtStars(r.stars)}`.padEnd(12).slice(0, 12);
412
-
413
- const rankText = new TextRenderable(renderer, {
414
- content: t`${bold(fg(rc)(rankStr))}`,
415
- backgroundColor: C.rankBg,
416
- color: rc,
417
- height: 1,
418
- });
419
-
420
- const descStr = desc.length > 60 ? desc.slice(0, 57) + "..." : desc;
421
- const pad = " ";
422
- const line1 = t`${bold(fg("#c0caf5")(nameStr))}${fg(lc)(langStr)}${fg(C.muted)(starsStr)}${bold(fg(C.green)(growthStr))}`;
423
- const line2 = t`${fg(C.descText)(`${pad}${descStr}`)}`;
424
-
425
- const chunks = [
426
- ...line1.chunks,
427
- { text: "\n", __isChunk: true as const, fg: C.text },
428
- ...line2.chunks,
429
- ];
430
- const rowText = new TextRenderable(renderer, {
431
- content: new StyledText(chunks),
432
- backgroundColor: rowBg,
433
- height: 2,
434
- });
435
-
436
- const rowBox = new BoxRenderable(renderer, {
437
- flexDirection: "row",
438
- backgroundColor: rowBg,
439
- });
440
- rowBox.add(rankText);
441
- rowBox.add(rowText);
442
-
443
- rowBoxes.push(rowBox);
444
- rowTexts.push(rowText);
445
- scrollBox.add(rowBox);
446
- });
447
- }
448
- outerBox.add(scrollBox);
449
-
450
- // ── Footer bar ──
451
- const footerBox = new BoxRenderable(renderer, {
452
- height: 1,
453
- backgroundColor: C.bg,
454
- paddingX: 1,
455
- });
456
- const footerText = new TextRenderable(renderer, {
457
- content: " ↑↓ navigate ↵ open / search 1-5 tab q quit r refresh",
458
- color: C.muted,
459
- height: 1,
460
- });
461
- footerBox.add(footerText);
462
- outerBox.add(footerBox);
463
-
464
- root.add(outerBox);
465
-
466
- // ── Helpers ──
467
- function openSelectedRepo() {
468
- if (selectedRepoIdx < 0 || selectedRepoIdx >= repos.length) return;
469
- const r = repos[selectedRepoIdx];
470
- openUrl(`https://github.com/${r.owner}/${r.name}`);
471
- }
472
-
473
- // Toggle selection highlight on the row boxes without rebuilding everything.
474
- // OpenTUI key names for arrows: "up", "down", "left", "right"
475
- function moveSelection(delta: number) {
476
- const oldIdx = selectedRepoIdx;
477
- const newIdx = Math.max(0, Math.min(repos.length - 1, oldIdx + delta));
478
- if (newIdx === oldIdx) return;
479
- selectedRepoIdx = newIdx;
480
-
481
- const setBg = (idx: number, selected: boolean) => {
482
- if (idx < 0 || idx >= rowBoxes.length) return;
483
- const bg = selected ? C.selectionBg : (idx % 2 === 0 ? C.bg : C.surface);
484
- rowBoxes[idx].backgroundColor = bg;
485
- if (rowTexts[idx]) rowTexts[idx].backgroundColor = bg;
486
- };
487
- setBg(oldIdx, false);
488
- setBg(newIdx, true);
489
- renderer.requestRender();
490
- }
491
-
492
- // ── Global key events ──
493
- renderer.keyInput.on("keypress", (key) => {
494
- const name = key.name || "";
495
-
496
- // Quit
497
- if (name === "q") {
498
- process.exit(0);
499
- return;
500
- }
501
-
502
- // Number keys 1-3 switch tabs
503
- if (/^[1-3]$/.test(name)) {
504
- switchTab(parseInt(name, 10) - 1);
505
- return;
506
- }
507
-
508
- // Tab navigation: left/right arrows or h/l
509
- if (name === "left" || name === "h") {
510
- switchTab(selectedTab - 1);
511
- return;
512
- }
513
- if (name === "right" || name === "l") {
514
- switchTab(selectedTab + 1);
515
- return;
516
- }
517
-
518
- // Repo navigation: up/down arrows or j/k
519
- if (name === "up" || name === "k") {
520
- moveSelection(-1);
521
- return;
522
- }
523
- if (name === "down" || name === "j") {
524
- moveSelection(1);
525
- return;
526
- }
527
-
528
- // Enter or 'o' opens selected repo
529
- if (name === "enter" || name === "return" || name === "o") {
530
- openSelectedRepo();
531
- return;
532
- }
533
-
534
- // '/' jumps to top
535
- if (name === "/") {
536
- moveSelection(-selectedRepoIdx);
537
- return;
538
- }
539
-
540
- // 'r' refreshes from API
541
- if (name === "r") {
542
- loadTab(selectedTab);
543
- return;
544
- }
545
- });
546
-
547
- // ── Start ──
548
- renderer.start();
549
- renderer.requestRender();
550
- // Fetch real trending data on launch
551
- loadTab(selectedTab);
552
- }
553
-
554
- // ── Auto-run ──────────────────────────────────────────────────────────
555
- if (import.meta.main) {
556
- launchTrending();
1
+ #!/usr/bin/env node
2
+ /** trending.ts — "Trending Repositories" view for the GitHub repo browser CLI. */
3
+ import {
4
+ createCliRenderer,
5
+ BoxRenderable,
6
+ TextRenderable,
7
+ ScrollBoxRenderable,
8
+ StyledText,
9
+ bold,
10
+ fg,
11
+ dim,
12
+ t,
13
+ } from "@opentui/core";
14
+ import type { Repo } from "./types";
15
+ import { openUrl } from "./open-url";
16
+ import { NetworkError } from "./errors";
17
+ import type { GitHubSearchEnvelope } from "./search";
18
+
19
+ // ─── Tokyo Night palette ──────────────────────────────────────────────
20
+ const C = {
21
+ bg: "#1a1b26",
22
+ surface: "#16161e",
23
+ text: "#a9b1d6",
24
+ muted: "#565f89",
25
+ cyan: "#7dcfff",
26
+ purple: "#bb9af7",
27
+ green: "#9ece6a",
28
+ gold: "#e0af68",
29
+ orange: "#ff9e64",
30
+ red: "#f7768e",
31
+ blue: "#7aa2f7",
32
+ lightBlue: "#b4f9f8",
33
+ selectionBg: "#3b6ce8",
34
+ selectionText: "#c0caf5",
35
+ border: "#363b54",
36
+ descText: "#6c7086", // lighter muted for descriptions
37
+ rankBg: "#2a2e42",
38
+ rankTop1: "#e0af68", // gold
39
+ rankTop2: "#9ece6a", // silver-ish green
40
+ rankTop3: "#ff9e64", // bronze-ish orange
41
+ rankRest: "#565f89", // muted gray
42
+ };
43
+
44
+ // ─── Language → color mapping ─────────────────────────────────────────
45
+ const LANG_COLORS: Record<string, string> = {
46
+ Rust: "#e0af68",
47
+ TypeScript: "#7dcfff",
48
+ JavaScript: "#e0d068",
49
+ Python: "#7aa2f7",
50
+ Go: "#73daca",
51
+ Java: "#f7768e",
52
+ Ruby: "#f7768e",
53
+ C: "#f7768e",
54
+ "C++": "#f7768e",
55
+ "C#": "#f7768e",
56
+ Shell: "#9ece6a",
57
+ Zig: "#e0af68",
58
+ Lua: "#7dcfff",
59
+ Haskell: "#bb9af7",
60
+ Elixir: "#bb9af7",
61
+ Dart: "#73daca",
62
+ Swift: "#f7768e",
63
+ Kotlin: "#7aa2f7",
64
+ };
65
+
66
+ function langColor(lang: string): string {
67
+ return LANG_COLORS[lang] ?? C.muted;
68
+ }
69
+
70
+ function rankColor(rank: number): string {
71
+ if (rank === 1) return C.rankTop1;
72
+ if (rank === 2) return C.rankTop2;
73
+ if (rank === 3) return C.rankTop3;
74
+ return C.rankRest;
75
+ }
76
+
77
+ // ─── Tab definitions ──────────────────────────────────────────────────
78
+ export const TAB_NAMES = ["Today", "This Week", "This Month"] as const;
79
+ export type TabName = (typeof TAB_NAMES)[number];
80
+
81
+ /** Map TabName to GitHub API date range.
82
+ * We use the search API with `created:>DATE&sort=stars` so only
83
+ * daily/weekly/monthly ranges are meaningful.
84
+ */
85
+ export function tabSince(tab: TabName): "daily" | "weekly" | "monthly" {
86
+ switch (tab) {
87
+ case "Today": return "daily";
88
+ case "This Week": return "weekly";
89
+ default: return "monthly";
90
+ }
91
+ }
92
+
93
+ // ─── Types ────────────────────────────────────────────────────────────
94
+ export interface TrendingRepo {
95
+ rank: number;
96
+ owner: string;
97
+ name: string;
98
+ stars: number;
99
+ starsToday: number;
100
+ language: string;
101
+ description: string;
102
+ }
103
+
104
+ // ─── Mock data ────────────────────────────────────────────────────────
105
+
106
+ /**
107
+ * Fetch trending repos from the GitHub search API instead of HTML scraping.
108
+ * Uses `created:>DATE&sort=stars` queries since the API doesn't expose a
109
+ * dedicated trending endpoint.
110
+ *
111
+ * Note: `starsToday` is always 0 because the API doesn't provide period growth.
112
+ * The growth indicator is hidden when 0.
113
+ */
114
+ export async function fetchTrendingRepos(since: "daily" | "weekly" | "monthly"): Promise<TrendingRepo[]> {
115
+ const now = Date.now();
116
+ const msPerDay = 86_400_000;
117
+ const days = since === "daily" ? 1 : since === "weekly" ? 7 : 30;
118
+ const date = new Date(now - days * msPerDay).toISOString().slice(0, 10);
119
+
120
+ const url = `https://api.github.com/search/repositories?q=created:>${date}&sort=stars&order=desc&per_page=25`;
121
+ let res: Response;
122
+ try {
123
+ res = await fetch(url, {
124
+ headers: {
125
+ Accept: "application/vnd.github+json",
126
+ "User-Agent": "ghfind",
127
+ },
128
+ });
129
+ } catch {
130
+ throw new NetworkError();
131
+ }
132
+ if (!res.ok) throw new NetworkError();
133
+ const data = (await res.json()) as GitHubSearchEnvelope;
134
+
135
+ return data.items.map((item, i) => ({
136
+ rank: i + 1,
137
+ owner: item.owner?.login ?? "?",
138
+ name: item.name,
139
+ stars: item.stargazers_count,
140
+ starsToday: 0, // API doesn't provide period growth
141
+ language: item.language ?? "",
142
+ description: item.description ?? "",
143
+ }));
144
+ }
145
+
146
+
147
+
148
+ // ─── Number formatting ────────────────────────────────────────────────
149
+
150
+ /** 1234 → "1.2k", 1234567 → "1.2M", 999999 → "1M" */
151
+ export function fmtStars(n: number): string {
152
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
153
+ if (n >= 999_500) return "1M"; // ponytail: round 999500+ up before showing 1000k
154
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
155
+ return String(n);
156
+ }
157
+
158
+ /** Format signed number: 1204 → "+1.2k" */
159
+ export function fmtSigned(n: number): string {
160
+ const abs = fmtStars(Math.abs(n));
161
+ return n >= 0 ? `+${abs}` : `-${abs}`;
162
+ }
163
+
164
+ // ─── Row formatting ───────────────────────────────────────────────────
165
+
166
+ /**
167
+ * Format one repo as a StyledText for per-chunk coloring.
168
+ *
169
+ * Layout (two lines):
170
+ * "[01] owner/name ● Rust ★ 89.2k ▲ +235 today"
171
+ * " Build smaller, faster desktop apps."
172
+ *
173
+ * Uses OpenTUI's StyledText + bold/fg/dim helpers for per-element colors:
174
+ * - Rank badge: colored background via bracket style, rank color
175
+ * - owner/name: bold white
176
+ * - Language dot: language-specific color
177
+ * - Stars: gold
178
+ * - Growth: green bold
179
+ * - Description: dim muted gray
180
+ */
181
+ export function formatRepoLine(repo: TrendingRepo): StyledText {
182
+ const rank = String(repo.rank).padStart(2, "0");
183
+ const full = `${repo.owner}/${repo.name}`;
184
+ const lc = langColor(repo.language);
185
+ const rc = rankColor(repo.rank);
186
+ const star = "★";
187
+ const arrow = repo.starsToday > 0 ? "▲" : "▼";
188
+ const growth = `${arrow} ${fmtSigned(repo.starsToday)} today`;
189
+ const desc = repo.description.length > 60
190
+ ? repo.description.slice(0, 57) + "..."
191
+ : repo.description;
192
+
193
+ // Build the styled line using OpenTUI's `t` template literal tag.
194
+ // Each interpolated value is a TextChunk with its own fg/bg/attributes.
195
+ const line1 = t`${dim(fg(C.rankBg)(`[${rank}]`))} ${bold(fg("#c0caf5")(full))} ${fg(lc)(`● ${repo.language}`)} ${fg(C.gold)(`${star} ${fmtStars(repo.stars)}`)} ${bold(fg(C.green)(growth))}`;
196
+ const line2 = t` ${dim(fg(C.muted)(desc))}`;
197
+
198
+ // Combine into a single StyledText with a newline between lines.
199
+ // StyledText constructor accepts an array of TextChunk objects.
200
+ const chunks = [
201
+ ...line1.chunks,
202
+ { text: "\n", __isChunk: true as const, fg: C.text },
203
+ ...line2.chunks,
204
+ ];
205
+ return new StyledText(chunks);
206
+ }
207
+
208
+ // ─── Tab query builder ────────────────────────────────────────────────
209
+ /**
210
+ * Build a GitHub search query string for the given time range tab.
211
+ *
212
+ * Examples:
213
+ * "Today" → "created:>2026-07-09 sort:stars"
214
+ * "This Week" → "created:>2026-07-03 sort:stars"
215
+ * "All Time" → "sort:stars"
216
+ */
217
+ export function getTrendingQuery(tab: TabName): string {
218
+ const now = Date.now();
219
+ const msPerDay = 86_400_000;
220
+ const ranges: Record<TabName, number> = {
221
+ Today: 1,
222
+ "This Week": 7,
223
+ "This Month": 30,
224
+ };
225
+ const days = ranges[tab];
226
+ if (days === 0) return "sort:stars";
227
+ const d = new Date(now - days * msPerDay);
228
+ return `created:>${d.toISOString().slice(0, 10)} sort:stars`;
229
+ }
230
+
231
+ // ─── Main TUI entry ───────────────────────────────────────────────────
232
+ export async function launchTrending(): Promise<void> {
233
+ let renderer;
234
+ try {
235
+ renderer = await createCliRenderer();
236
+ } catch (err) {
237
+ console.error(
238
+ "Failed to start the interactive browser.\n",
239
+ `Reason: ${err instanceof Error ? err.message : String(err)}\n`,
240
+ );
241
+ process.exit(1);
242
+ return;
243
+ }
244
+
245
+ const root = renderer.root;
246
+ root.flexDirection = "column";
247
+ root.backgroundColor = C.bg;
248
+
249
+ // ── State ──
250
+ let selectedTab = 1; // "This Week" default
251
+ let currentPeriod = "this week";
252
+ let repos: TrendingRepo[] = [];
253
+ let selectedRepoIdx = 0;
254
+ let isLoading = false;
255
+
256
+ // ── Fetch from github.com/trending ──
257
+ async function loadTab(index: number) {
258
+ if (index < 0 || index >= TAB_NAMES.length) return;
259
+ selectedTab = index;
260
+ // Map tab to data period label ("This Year" and "All Time" use monthly data)
261
+ const since = tabSince(TAB_NAMES[index]);
262
+ currentPeriod = since === "daily" ? "today" : since === "weekly" ? "this week" : "this month";
263
+ isLoading = true;
264
+ for (const rb of rowBoxes) scrollBox.remove(rb);
265
+ rowBoxes.length = 0;
266
+ rowTexts.length = 0;
267
+ const loadingText = new TextRenderable(renderer, {
268
+ content: t`${dim(fg(C.muted)(` Loading trending repos...`))}`,
269
+ backgroundColor: C.bg,
270
+ height: 1,
271
+ });
272
+ scrollBox.add(loadingText);
273
+ renderer.requestRender();
274
+
275
+ try {
276
+ const fetched = await fetchTrendingRepos(tabSince(TAB_NAMES[index]));
277
+ scrollBox.remove(loadingText);
278
+ repos = fetched;
279
+ selectedRepoIdx = 0;
280
+ rebuildList();
281
+ } catch (err) {
282
+ scrollBox.remove(loadingText);
283
+ const msg = err instanceof Error ? err.message : String(err);
284
+ const errText = new TextRenderable(renderer, {
285
+ content: t`${fg(C.red)(` Error: ${msg.slice(0, 70)}`)}`,
286
+ backgroundColor: C.bg,
287
+ height: 1,
288
+ });
289
+ scrollBox.add(errText);
290
+ }
291
+ isLoading = false;
292
+ renderTabBar();
293
+ renderer.requestRender();
294
+ }
295
+
296
+ // ── Switch tab ──
297
+ function switchTab(index: number) {
298
+ if (index < 0 || index >= TAB_NAMES.length || index === selectedTab) return;
299
+ loadTab(index);
300
+ }
301
+
302
+ // ── Outer frame ──
303
+ // Rounded border around the entire app for visual polish.
304
+ const outerBox = new BoxRenderable(renderer, {
305
+ flexGrow: 1,
306
+ flexDirection: "column",
307
+ backgroundColor: C.bg,
308
+ border: true,
309
+ borderColor: C.border,
310
+ borderStyle: "rounded",
311
+ paddingX: 0,
312
+ paddingY: 0,
313
+ });
314
+
315
+ // ── Header bar ──
316
+ const headerBox = new BoxRenderable(renderer, {
317
+ height: 1,
318
+ backgroundColor: C.bg,
319
+ paddingX: 1,
320
+ });
321
+ const headerText = new TextRenderable(renderer, {
322
+ content: t`${bold(fg(C.purple)("⟠ tulipsearch"))}${fg(C.muted)(" — Trending")}`,
323
+ height: 1,
324
+ });
325
+ headerBox.add(headerText);
326
+ outerBox.add(headerBox);
327
+
328
+ // ── Tab bar ──
329
+ const tabBox = new BoxRenderable(renderer, {
330
+ flexDirection: "row",
331
+ height: 1,
332
+ backgroundColor: C.bg,
333
+ paddingX: 1,
334
+ });
335
+ const tabTexts: TextRenderable[] = [];
336
+
337
+ function renderTabBar() {
338
+ for (const t of tabTexts) tabBox.remove(t);
339
+ tabTexts.length = 0;
340
+ TAB_NAMES.forEach((name, i) => {
341
+ const isActive = i === selectedTab;
342
+ const label = ` ${name} `;
343
+ const tt = new TextRenderable(renderer, {
344
+ content: isActive ? t`${bold(fg(C.bg)(label))}` : t`${fg(C.muted)(label)}`,
345
+ color: isActive ? C.bg : C.muted,
346
+ backgroundColor: isActive ? C.cyan : C.bg,
347
+ height: 1,
348
+ });
349
+ tabTexts.push(tt);
350
+ tabBox.add(tt);
351
+ if (i < TAB_NAMES.length - 1) {
352
+ const sp = new TextRenderable(renderer, {
353
+ content: " ",
354
+ color: C.muted,
355
+ backgroundColor: C.bg,
356
+ height: 1,
357
+ });
358
+ tabTexts.push(sp);
359
+ tabBox.add(sp);
360
+ }
361
+ });
362
+ }
363
+ renderTabBar();
364
+ outerBox.add(tabBox);
365
+
366
+ // Subtle horizontal divider between tab bar and list
367
+ const divider = new TextRenderable(renderer, {
368
+ content: "─".repeat(80),
369
+ color: C.border,
370
+ height: 1,
371
+ backgroundColor: C.bg,
372
+ });
373
+ outerBox.add(divider);
374
+
375
+ // ── Repo list (scrollable) ──
376
+ const scrollBox = new ScrollBoxRenderable(renderer, {
377
+ flexGrow: 1,
378
+ backgroundColor: C.bg,
379
+ scrollY: true,
380
+ scrollX: false,
381
+ viewportCulling: true,
382
+ });
383
+
384
+ // We render rows as individual BoxRenderables inside the scroll area.
385
+ // Each row has alternating background and a left-border accent on selection.
386
+ const rowBoxes: BoxRenderable[] = [];
387
+ const rowTexts: TextRenderable[] = [];
388
+
389
+ function rebuildList() {
390
+ for (const rb of rowBoxes) scrollBox.remove(rb);
391
+ rowBoxes.length = 0;
392
+ rowTexts.length = 0;
393
+
394
+ repos.forEach((r, i) => {
395
+ const isEven = i % 2 === 0;
396
+ const isSelected = i === selectedRepoIdx;
397
+ const rowBg = isSelected ? C.selectionBg : (isEven ? C.bg : C.surface);
398
+
399
+ const rank = i + 1;
400
+ const rc = rankColor(rank);
401
+ const lc = langColor(r.language ?? "");
402
+ const desc = (r.description ?? "").length > 55
403
+ ? (r.description ?? "").slice(0, 52) + "..."
404
+ : (r.description ?? "");
405
+ const arrow = r.starsToday > 0 ? "▲" : r.starsToday < 0 ? "▼" : "—";
406
+ const growthStr = `${arrow} ${fmtStars(Math.abs(r.starsToday))} ${currentPeriod}`;
407
+
408
+ const rankStr = `[${String(rank).padStart(2, "0")}]`;
409
+ const nameStr = `${r.owner}/${r.name}`.padEnd(30).slice(0, 30);
410
+ const langStr = `● ${r.language || "—"}`.padEnd(14).slice(0, 14);
411
+ const starsStr = `★ ${fmtStars(r.stars)}`.padEnd(12).slice(0, 12);
412
+
413
+ const rankText = new TextRenderable(renderer, {
414
+ content: t`${bold(fg(rc)(rankStr))}`,
415
+ backgroundColor: C.rankBg,
416
+ color: rc,
417
+ height: 1,
418
+ });
419
+
420
+ const descStr = desc.length > 60 ? desc.slice(0, 57) + "..." : desc;
421
+ const pad = " ";
422
+ const line1 = t`${bold(fg("#c0caf5")(nameStr))}${fg(lc)(langStr)}${fg(C.muted)(starsStr)}${bold(fg(C.green)(growthStr))}`;
423
+ const line2 = t`${fg(C.descText)(`${pad}${descStr}`)}`;
424
+
425
+ const chunks = [
426
+ ...line1.chunks,
427
+ { text: "\n", __isChunk: true as const, fg: C.text },
428
+ ...line2.chunks,
429
+ ];
430
+ const rowText = new TextRenderable(renderer, {
431
+ content: new StyledText(chunks),
432
+ backgroundColor: rowBg,
433
+ height: 2,
434
+ });
435
+
436
+ const rowBox = new BoxRenderable(renderer, {
437
+ flexDirection: "row",
438
+ backgroundColor: rowBg,
439
+ });
440
+ rowBox.add(rankText);
441
+ rowBox.add(rowText);
442
+
443
+ rowBoxes.push(rowBox);
444
+ rowTexts.push(rowText);
445
+ scrollBox.add(rowBox);
446
+ });
447
+ }
448
+ outerBox.add(scrollBox);
449
+
450
+ // ── Footer bar ──
451
+ const footerBox = new BoxRenderable(renderer, {
452
+ height: 1,
453
+ backgroundColor: C.bg,
454
+ paddingX: 1,
455
+ });
456
+ const footerText = new TextRenderable(renderer, {
457
+ content: " ↑↓ navigate ↵ open / search 1-5 tab q quit r refresh",
458
+ color: C.muted,
459
+ height: 1,
460
+ });
461
+ footerBox.add(footerText);
462
+ outerBox.add(footerBox);
463
+
464
+ root.add(outerBox);
465
+
466
+ // ── Helpers ──
467
+ function openSelectedRepo() {
468
+ if (selectedRepoIdx < 0 || selectedRepoIdx >= repos.length) return;
469
+ const r = repos[selectedRepoIdx];
470
+ openUrl(`https://github.com/${r.owner}/${r.name}`);
471
+ }
472
+
473
+ // Toggle selection highlight on the row boxes without rebuilding everything.
474
+ // OpenTUI key names for arrows: "up", "down", "left", "right"
475
+ function moveSelection(delta: number) {
476
+ const oldIdx = selectedRepoIdx;
477
+ const newIdx = Math.max(0, Math.min(repos.length - 1, oldIdx + delta));
478
+ if (newIdx === oldIdx) return;
479
+ selectedRepoIdx = newIdx;
480
+
481
+ const setBg = (idx: number, selected: boolean) => {
482
+ if (idx < 0 || idx >= rowBoxes.length) return;
483
+ const bg = selected ? C.selectionBg : (idx % 2 === 0 ? C.bg : C.surface);
484
+ rowBoxes[idx].backgroundColor = bg;
485
+ if (rowTexts[idx]) rowTexts[idx].backgroundColor = bg;
486
+ };
487
+ setBg(oldIdx, false);
488
+ setBg(newIdx, true);
489
+ renderer.requestRender();
490
+ }
491
+
492
+ // ── Global key events ──
493
+ renderer.keyInput.on("keypress", (key) => {
494
+ const name = key.name || "";
495
+
496
+ // Quit
497
+ if (name === "q") {
498
+ process.exit(0);
499
+ return;
500
+ }
501
+
502
+ // Number keys 1-3 switch tabs
503
+ if (/^[1-3]$/.test(name)) {
504
+ switchTab(parseInt(name, 10) - 1);
505
+ return;
506
+ }
507
+
508
+ // Tab navigation: left/right arrows or h/l
509
+ if (name === "left" || name === "h") {
510
+ switchTab(selectedTab - 1);
511
+ return;
512
+ }
513
+ if (name === "right" || name === "l") {
514
+ switchTab(selectedTab + 1);
515
+ return;
516
+ }
517
+
518
+ // Repo navigation: up/down arrows or j/k
519
+ if (name === "up" || name === "k") {
520
+ moveSelection(-1);
521
+ return;
522
+ }
523
+ if (name === "down" || name === "j") {
524
+ moveSelection(1);
525
+ return;
526
+ }
527
+
528
+ // Enter or 'o' opens selected repo
529
+ if (name === "enter" || name === "return" || name === "o") {
530
+ openSelectedRepo();
531
+ return;
532
+ }
533
+
534
+ // '/' jumps to top
535
+ if (name === "/") {
536
+ moveSelection(-selectedRepoIdx);
537
+ return;
538
+ }
539
+
540
+ // 'r' refreshes from API
541
+ if (name === "r") {
542
+ loadTab(selectedTab);
543
+ return;
544
+ }
545
+ });
546
+
547
+ // ── Start ──
548
+ renderer.start();
549
+ renderer.requestRender();
550
+ // Fetch real trending data on launch
551
+ loadTab(selectedTab);
552
+ }
553
+
554
+ // ── Auto-run ──────────────────────────────────────────────────────────
555
+ // Only launch when run directly (bun run src/trending.ts), not when bundled
556
+ // into cli.js or imported as a module.
557
+ if (import.meta.main && !process.env.GHFIND_BUNDLED) {
558
+ launchTrending();
557
559
  }