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