github-search-cli 0.8.1 → 0.9.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
@@ -11,11 +11,9 @@ import {
11
11
  dim,
12
12
  t,
13
13
  } from "@opentui/core";
14
- import type { Repo } from "./types";
14
+ import type { Repo, ParsedQuery } from "./types";
15
15
  import { openUrl } from "./open-url";
16
- import { NetworkError } from "./errors";
17
- import type { GitHubSearchEnvelope } from "./search";
18
-
16
+ import { SearchModule, TrendingAdapter } from "./search";
19
17
  // ─── Tokyo Night palette ──────────────────────────────────────────────
20
18
  const C = {
21
19
  bg: "#1a1b26",
@@ -40,7 +38,6 @@ const C = {
40
38
  rankTop3: "#ff9e64", // bronze-ish orange
41
39
  rankRest: "#565f89", // muted gray
42
40
  };
43
-
44
41
  // ─── Language → color mapping ─────────────────────────────────────────
45
42
  const LANG_COLORS: Record<string, string> = {
46
43
  Rust: "#e0af68",
@@ -62,26 +59,19 @@ const LANG_COLORS: Record<string, string> = {
62
59
  Swift: "#f7768e",
63
60
  Kotlin: "#7aa2f7",
64
61
  };
65
-
66
62
  function langColor(lang: string): string {
67
63
  return LANG_COLORS[lang] ?? C.muted;
68
64
  }
69
-
70
65
  function rankColor(rank: number): string {
71
66
  if (rank === 1) return C.rankTop1;
72
67
  if (rank === 2) return C.rankTop2;
73
68
  if (rank === 3) return C.rankTop3;
74
69
  return C.rankRest;
75
70
  }
76
-
77
71
  // ─── Tab definitions ──────────────────────────────────────────────────
78
72
  export const TAB_NAMES = ["Today", "This Week", "This Month"] as const;
79
73
  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
- */
74
+ /** Map TabName to github.com/trending?since= param */
85
75
  export function tabSince(tab: TabName): "daily" | "weekly" | "monthly" {
86
76
  switch (tab) {
87
77
  case "Today": return "daily";
@@ -89,64 +79,7 @@ export function tabSince(tab: TabName): "daily" | "weekly" | "monthly" {
89
79
  default: return "monthly";
90
80
  }
91
81
  }
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
82
  // ─── Number formatting ────────────────────────────────────────────────
149
-
150
83
  /** 1234 → "1.2k", 1234567 → "1.2M", 999999 → "1M" */
151
84
  export function fmtStars(n: number): string {
152
85
  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
@@ -154,15 +87,12 @@ export function fmtStars(n: number): string {
154
87
  if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
155
88
  return String(n);
156
89
  }
157
-
158
90
  /** Format signed number: 1204 → "+1.2k" */
159
91
  export function fmtSigned(n: number): string {
160
92
  const abs = fmtStars(Math.abs(n));
161
93
  return n >= 0 ? `+${abs}` : `-${abs}`;
162
94
  }
163
-
164
95
  // ─── Row formatting ───────────────────────────────────────────────────
165
-
166
96
  /**
167
97
  * Format one repo as a StyledText for per-chunk coloring.
168
98
  *
@@ -178,25 +108,19 @@ export function fmtSigned(n: number): string {
178
108
  * - Growth: green bold
179
109
  * - Description: dim muted gray
180
110
  */
181
- export function formatRepoLine(repo: TrendingRepo): StyledText {
182
- const rank = String(repo.rank).padStart(2, "0");
111
+ export function formatRepoLine(repo: Repo, rank: number): StyledText {
112
+ const rankStr = String(rank).padStart(2, "0");
183
113
  const full = `${repo.owner}/${repo.name}`;
184
- const lc = langColor(repo.language);
185
- const rc = rankColor(repo.rank);
114
+ const lc = langColor(repo.language ?? "");
115
+ const rc = rankColor(rank);
186
116
  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))}`;
117
+ const arrow = repo.score > 0 ? "▲" : repo.score < 0 ? "▼" : "—";
118
+ const growth = `${arrow} ${fmtSigned(repo.score)} today`;
119
+ const desc = (repo.description ?? "").length > 60
120
+ ? (repo.description ?? "").slice(0, 57) + "..."
121
+ : (repo.description ?? "");
122
+ const line1 = t`${dim(fg(C.rankBg)(`[${rankStr}]`))} ${bold(fg("#c0caf5")(full))} ${fg(lc)(`● ${repo.language}`)} ${fg(C.gold)(`${star} ${fmtStars(repo.stars)}`)} ${bold(fg(C.green)(growth))}`;
196
123
  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
124
  const chunks = [
201
125
  ...line1.chunks,
202
126
  { text: "\n", __isChunk: true as const, fg: C.text },
@@ -204,7 +128,6 @@ export function formatRepoLine(repo: TrendingRepo): StyledText {
204
128
  ];
205
129
  return new StyledText(chunks);
206
130
  }
207
-
208
131
  // ─── Tab query builder ────────────────────────────────────────────────
209
132
  /**
210
133
  * Build a GitHub search query string for the given time range tab.
@@ -227,7 +150,6 @@ export function getTrendingQuery(tab: TabName): string {
227
150
  const d = new Date(now - days * msPerDay);
228
151
  return `created:>${d.toISOString().slice(0, 10)} sort:stars`;
229
152
  }
230
-
231
153
  // ─── Main TUI entry ───────────────────────────────────────────────────
232
154
  export async function launchTrending(): Promise<void> {
233
155
  let renderer;
@@ -241,23 +163,19 @@ export async function launchTrending(): Promise<void> {
241
163
  process.exit(1);
242
164
  return;
243
165
  }
244
-
245
166
  const root = renderer.root;
246
167
  root.flexDirection = "column";
247
168
  root.backgroundColor = C.bg;
248
-
249
169
  // ── State ──
250
170
  let selectedTab = 1; // "This Week" default
251
171
  let currentPeriod = "this week";
252
- let repos: TrendingRepo[] = [];
172
+ let repos: Repo[] = [];
253
173
  let selectedRepoIdx = 0;
254
174
  let isLoading = false;
255
-
256
175
  // ── Fetch from github.com/trending ──
257
176
  async function loadTab(index: number) {
258
177
  if (index < 0 || index >= TAB_NAMES.length) return;
259
178
  selectedTab = index;
260
- // Map tab to data period label ("This Year" and "All Time" use monthly data)
261
179
  const since = tabSince(TAB_NAMES[index]);
262
180
  currentPeriod = since === "daily" ? "today" : since === "weekly" ? "this week" : "this month";
263
181
  isLoading = true;
@@ -271,11 +189,18 @@ export async function launchTrending(): Promise<void> {
271
189
  });
272
190
  scrollBox.add(loadingText);
273
191
  renderer.requestRender();
274
-
275
192
  try {
276
- const fetched = await fetchTrendingRepos(tabSince(TAB_NAMES[index]));
193
+ const searchModule = new SearchModule(new TrendingAdapter());
194
+ const parsed: ParsedQuery = { keywords: [], qualifiers: [], raw: "trending" };
195
+ const response = await searchModule.search(parsed, {
196
+ limit: 25,
197
+ sort: "stars",
198
+ json: false,
199
+ verbose: false,
200
+ trendingSince: tabSince(TAB_NAMES[index]),
201
+ });
277
202
  scrollBox.remove(loadingText);
278
- repos = fetched;
203
+ repos = response.repos;
279
204
  selectedRepoIdx = 0;
280
205
  rebuildList();
281
206
  } catch (err) {
@@ -292,15 +217,12 @@ export async function launchTrending(): Promise<void> {
292
217
  renderTabBar();
293
218
  renderer.requestRender();
294
219
  }
295
-
296
220
  // ── Switch tab ──
297
221
  function switchTab(index: number) {
298
222
  if (index < 0 || index >= TAB_NAMES.length || index === selectedTab) return;
299
223
  loadTab(index);
300
224
  }
301
-
302
225
  // ── Outer frame ──
303
- // Rounded border around the entire app for visual polish.
304
226
  const outerBox = new BoxRenderable(renderer, {
305
227
  flexGrow: 1,
306
228
  flexDirection: "column",
@@ -311,7 +233,6 @@ export async function launchTrending(): Promise<void> {
311
233
  paddingX: 0,
312
234
  paddingY: 0,
313
235
  });
314
-
315
236
  // ── Header bar ──
316
237
  const headerBox = new BoxRenderable(renderer, {
317
238
  height: 1,
@@ -324,7 +245,6 @@ export async function launchTrending(): Promise<void> {
324
245
  });
325
246
  headerBox.add(headerText);
326
247
  outerBox.add(headerBox);
327
-
328
248
  // ── Tab bar ──
329
249
  const tabBox = new BoxRenderable(renderer, {
330
250
  flexDirection: "row",
@@ -333,7 +253,6 @@ export async function launchTrending(): Promise<void> {
333
253
  paddingX: 1,
334
254
  });
335
255
  const tabTexts: TextRenderable[] = [];
336
-
337
256
  function renderTabBar() {
338
257
  for (const t of tabTexts) tabBox.remove(t);
339
258
  tabTexts.length = 0;
@@ -362,8 +281,6 @@ export async function launchTrending(): Promise<void> {
362
281
  }
363
282
  renderTabBar();
364
283
  outerBox.add(tabBox);
365
-
366
- // Subtle horizontal divider between tab bar and list
367
284
  const divider = new TextRenderable(renderer, {
368
285
  content: "─".repeat(80),
369
286
  color: C.border,
@@ -371,7 +288,6 @@ export async function launchTrending(): Promise<void> {
371
288
  backgroundColor: C.bg,
372
289
  });
373
290
  outerBox.add(divider);
374
-
375
291
  // ── Repo list (scrollable) ──
376
292
  const scrollBox = new ScrollBoxRenderable(renderer, {
377
293
  flexGrow: 1,
@@ -380,48 +296,38 @@ export async function launchTrending(): Promise<void> {
380
296
  scrollX: false,
381
297
  viewportCulling: true,
382
298
  });
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
299
  const rowBoxes: BoxRenderable[] = [];
387
300
  const rowTexts: TextRenderable[] = [];
388
-
389
301
  function rebuildList() {
390
302
  for (const rb of rowBoxes) scrollBox.remove(rb);
391
303
  rowBoxes.length = 0;
392
304
  rowTexts.length = 0;
393
-
394
305
  repos.forEach((r, i) => {
395
306
  const isEven = i % 2 === 0;
396
307
  const isSelected = i === selectedRepoIdx;
397
308
  const rowBg = isSelected ? C.selectionBg : (isEven ? C.bg : C.surface);
398
-
399
309
  const rank = i + 1;
400
310
  const rc = rankColor(rank);
401
311
  const lc = langColor(r.language ?? "");
402
312
  const desc = (r.description ?? "").length > 55
403
313
  ? (r.description ?? "").slice(0, 52) + "..."
404
314
  : (r.description ?? "");
405
- const arrow = r.starsToday > 0 ? "▲" : r.starsToday < 0 ? "▼" : "—";
406
- const growthStr = `${arrow} ${fmtStars(Math.abs(r.starsToday))} ${currentPeriod}`;
407
-
315
+ const arrow = r.score > 0 ? "▲" : r.score < 0 ? "▼" : "—";
316
+ const growthStr = `${arrow} ${fmtStars(Math.abs(r.score))} ${currentPeriod}`;
408
317
  const rankStr = `[${String(rank).padStart(2, "0")}]`;
409
318
  const nameStr = `${r.owner}/${r.name}`.padEnd(30).slice(0, 30);
410
319
  const langStr = `● ${r.language || "—"}`.padEnd(14).slice(0, 14);
411
320
  const starsStr = `★ ${fmtStars(r.stars)}`.padEnd(12).slice(0, 12);
412
-
413
321
  const rankText = new TextRenderable(renderer, {
414
322
  content: t`${bold(fg(rc)(rankStr))}`,
415
323
  backgroundColor: C.rankBg,
416
324
  color: rc,
417
325
  height: 1,
418
326
  });
419
-
420
327
  const descStr = desc.length > 60 ? desc.slice(0, 57) + "..." : desc;
421
328
  const pad = " ";
422
329
  const line1 = t`${bold(fg("#c0caf5")(nameStr))}${fg(lc)(langStr)}${fg(C.muted)(starsStr)}${bold(fg(C.green)(growthStr))}`;
423
330
  const line2 = t`${fg(C.descText)(`${pad}${descStr}`)}`;
424
-
425
331
  const chunks = [
426
332
  ...line1.chunks,
427
333
  { text: "\n", __isChunk: true as const, fg: C.text },
@@ -432,21 +338,18 @@ export async function launchTrending(): Promise<void> {
432
338
  backgroundColor: rowBg,
433
339
  height: 2,
434
340
  });
435
-
436
341
  const rowBox = new BoxRenderable(renderer, {
437
342
  flexDirection: "row",
438
343
  backgroundColor: rowBg,
439
344
  });
440
345
  rowBox.add(rankText);
441
346
  rowBox.add(rowText);
442
-
443
347
  rowBoxes.push(rowBox);
444
348
  rowTexts.push(rowText);
445
349
  scrollBox.add(rowBox);
446
350
  });
447
351
  }
448
352
  outerBox.add(scrollBox);
449
-
450
353
  // ── Footer bar ──
451
354
  const footerBox = new BoxRenderable(renderer, {
452
355
  height: 1,
@@ -460,24 +363,18 @@ export async function launchTrending(): Promise<void> {
460
363
  });
461
364
  footerBox.add(footerText);
462
365
  outerBox.add(footerBox);
463
-
464
366
  root.add(outerBox);
465
-
466
367
  // ── Helpers ──
467
368
  function openSelectedRepo() {
468
369
  if (selectedRepoIdx < 0 || selectedRepoIdx >= repos.length) return;
469
370
  const r = repos[selectedRepoIdx];
470
371
  openUrl(`https://github.com/${r.owner}/${r.name}`);
471
372
  }
472
-
473
- // Toggle selection highlight on the row boxes without rebuilding everything.
474
- // OpenTUI key names for arrows: "up", "down", "left", "right"
475
373
  function moveSelection(delta: number) {
476
374
  const oldIdx = selectedRepoIdx;
477
375
  const newIdx = Math.max(0, Math.min(repos.length - 1, oldIdx + delta));
478
376
  if (newIdx === oldIdx) return;
479
377
  selectedRepoIdx = newIdx;
480
-
481
378
  const setBg = (idx: number, selected: boolean) => {
482
379
  if (idx < 0 || idx >= rowBoxes.length) return;
483
380
  const bg = selected ? C.selectionBg : (idx % 2 === 0 ? C.bg : C.surface);
@@ -488,24 +385,17 @@ export async function launchTrending(): Promise<void> {
488
385
  setBg(newIdx, true);
489
386
  renderer.requestRender();
490
387
  }
491
-
492
388
  // ── Global key events ──
493
389
  renderer.keyInput.on("keypress", (key) => {
494
390
  const name = key.name || "";
495
-
496
- // Quit
497
391
  if (name === "q") {
498
392
  process.exit(0);
499
393
  return;
500
394
  }
501
-
502
- // Number keys 1-3 switch tabs
503
395
  if (/^[1-3]$/.test(name)) {
504
396
  switchTab(parseInt(name, 10) - 1);
505
397
  return;
506
398
  }
507
-
508
- // Tab navigation: left/right arrows or h/l
509
399
  if (name === "left" || name === "h") {
510
400
  switchTab(selectedTab - 1);
511
401
  return;
@@ -514,8 +404,6 @@ export async function launchTrending(): Promise<void> {
514
404
  switchTab(selectedTab + 1);
515
405
  return;
516
406
  }
517
-
518
- // Repo navigation: up/down arrows or j/k
519
407
  if (name === "up" || name === "k") {
520
408
  moveSelection(-1);
521
409
  return;
@@ -524,36 +412,25 @@ export async function launchTrending(): Promise<void> {
524
412
  moveSelection(1);
525
413
  return;
526
414
  }
527
-
528
- // Enter or 'o' opens selected repo
529
415
  if (name === "enter" || name === "return" || name === "o") {
530
416
  openSelectedRepo();
531
417
  return;
532
418
  }
533
-
534
- // '/' jumps to top
535
419
  if (name === "/") {
536
420
  moveSelection(-selectedRepoIdx);
537
421
  return;
538
422
  }
539
-
540
- // 'r' refreshes from API
541
423
  if (name === "r") {
542
424
  loadTab(selectedTab);
543
425
  return;
544
426
  }
545
427
  });
546
-
547
428
  // ── Start ──
548
429
  renderer.start();
549
430
  renderer.requestRender();
550
- // Fetch real trending data on launch
551
431
  loadTab(selectedTab);
552
432
  }
553
-
554
433
  // ── 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
434
  if (import.meta.main && !process.env.GHFIND_BUNDLED) {
558
435
  launchTrending();
559
- }
436
+ }