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.
@@ -0,0 +1,229 @@
1
+ /**
2
+ * SearchState — owns all TUI state and domain calls.
3
+ *
4
+ * The TUI view layer reads from SearchState and dispatches actions.
5
+ * This module is testable without the OpenTUI renderer.
6
+ */
7
+ import type { Repo, SearchOptions, SortStrategy, SessionState } from "./types";
8
+ import type { TabName } from "./trending";
9
+ import { tabSince } from "./trending";
10
+ import { parseQuery, applyFlagFilters, rankRepos, createGitHubSearch, SearchModule, TrendingAdapter } from "./search";
11
+ import { restoreSession, saveSession } from "./session";
12
+ import { appendHistory } from "./history";
13
+ import { toggleBookmark, isBookmarked } from "./bookmarks";
14
+ import { saveSearch, getSavedSearches, touchSavedSearch } from "./saved-searches";
15
+ import { fetchDeepDive, buildDeepDiveText } from "./deepdive";
16
+ import { fetchTopics } from "./explore";
17
+ import { loadConfig } from "./config";
18
+
19
+ export type Overlay =
20
+ | "none" | "history" | "bookmarks" | "saved" | "help"
21
+ | "topics" | "export" | "compare" | "notifications"
22
+ | "share" | "leader" | "readme";
23
+
24
+ export interface SearchState {
25
+ // Core search state
26
+ currentRepos: Repo[];
27
+ currentSort: SortStrategy;
28
+ currentLimit: number;
29
+ currentQueryInput: string;
30
+ isLoading: boolean;
31
+ currentMode: "search" | "trending";
32
+ currentPage: number;
33
+ totalCount: number;
34
+
35
+ // Trending
36
+ trendingTab: TabName;
37
+ trendingPeriod: string;
38
+
39
+ // UI overlays
40
+ currentOverlay: Overlay;
41
+ deepDiveActive: boolean;
42
+ compareList: Repo[];
43
+ graphFullscreen: boolean;
44
+ chartCommitData: number[];
45
+ quitArmed: boolean;
46
+ }
47
+
48
+ export class SearchStateManager {
49
+ private config = loadConfig();
50
+ private provider = createGitHubSearch(undefined, this.config.githubTokens);
51
+ private trendingSearch = new SearchModule(new TrendingAdapter());
52
+
53
+ state: SearchState = {
54
+ currentRepos: [],
55
+ currentSort: this.config.defaultSort,
56
+ currentLimit: this.config.defaultLimit,
57
+ currentQueryInput: "",
58
+ isLoading: false,
59
+ currentMode: "search",
60
+ currentPage: 1,
61
+ totalCount: 0,
62
+ trendingTab: "This Week",
63
+ trendingPeriod: "this week",
64
+ currentOverlay: "none",
65
+ deepDiveActive: false,
66
+ compareList: [],
67
+ graphFullscreen: false,
68
+ chartCommitData: [],
69
+ quitArmed: false,
70
+ };
71
+
72
+ constructor() {
73
+ const session = restoreSession();
74
+ if (session) {
75
+ this.state.currentMode = session.mode;
76
+ this.state.currentQueryInput = session.query;
77
+ this.state.currentSort = session.sort;
78
+ this.state.currentLimit = session.limit;
79
+ this.state.trendingTab = (session.trendingTab as TabName) ?? "This Week";
80
+ }
81
+ }
82
+
83
+ // ── Search ──
84
+
85
+ async search(query: string, page: number = 1): Promise<void> {
86
+ this.state.isLoading = true;
87
+ this.state.currentQueryInput = query;
88
+ this.state.currentPage = page;
89
+ try {
90
+ const parsed = applyFlagFilters(parseQuery(query), {});
91
+ const response = await this.provider.search(parsed, {
92
+ limit: this.state.currentLimit,
93
+ sort: this.state.currentSort,
94
+ json: false,
95
+ verbose: false,
96
+ token: this.config.githubToken,
97
+ page,
98
+ });
99
+ this.state.currentRepos = rankRepos(response.repos, this.state.currentSort);
100
+ this.state.totalCount = response.totalCount;
101
+ this.state.currentMode = "search";
102
+ appendHistory({ query, mode: "search", timestamp: Date.now(), resultCount: response.repos.length });
103
+ } catch (err) {
104
+ throw err;
105
+ } finally {
106
+ this.state.isLoading = false;
107
+ }
108
+ }
109
+
110
+ async nextPage(): Promise<void> {
111
+ await this.search(this.state.currentQueryInput, this.state.currentPage + 1);
112
+ }
113
+
114
+ async prevPage(): Promise<void> {
115
+ if (this.state.currentPage > 1) {
116
+ await this.search(this.state.currentQueryInput, this.state.currentPage - 1);
117
+ }
118
+ }
119
+
120
+ // ── Trending ──
121
+
122
+ async loadTrending(tab: TabName): Promise<void> {
123
+ this.state.isLoading = true;
124
+ this.state.trendingTab = tab;
125
+ this.state.currentMode = "trending";
126
+ try {
127
+ const response = await this.trendingSearch.search(
128
+ { keywords: [], qualifiers: [], raw: "trending" },
129
+ { limit: 25, sort: "stars", json: false, verbose: false, trendingSince: tabSince(tab) },
130
+ );
131
+ this.state.currentRepos = response.repos;
132
+ this.state.totalCount = response.totalCount;
133
+ appendHistory({ query: `trending:${tab}`, mode: "trending", tab, timestamp: Date.now(), resultCount: response.repos.length });
134
+ } finally {
135
+ this.state.isLoading = false;
136
+ }
137
+ }
138
+
139
+ // ── Bookmarks ──
140
+
141
+ toggleBookmark(repo: Repo): boolean {
142
+ return toggleBookmark(repo);
143
+ }
144
+
145
+ isBookmarked(fullName: string): boolean {
146
+ return isBookmarked(fullName);
147
+ }
148
+
149
+ // ── Saved searches ──
150
+
151
+ saveCurrentSearch(name: string): void {
152
+ saveSearch(name, this.state.currentQueryInput, this.state.currentMode, this.state.currentSort, this.state.currentLimit);
153
+ }
154
+
155
+ getSavedSearches() {
156
+ return getSavedSearches();
157
+ }
158
+
159
+ touchSavedSearch(name: string): void {
160
+ touchSavedSearch(name);
161
+ }
162
+
163
+ // ── Deep dive ──
164
+
165
+ async deepDive(repo: Repo): Promise<string> {
166
+ this.state.deepDiveActive = true;
167
+ try {
168
+ const data = await fetchDeepDive(repo, this.config.githubToken);
169
+ return buildDeepDiveText(data);
170
+ } finally {
171
+ this.state.deepDiveActive = false;
172
+ }
173
+ }
174
+
175
+ // ── Compare ──
176
+
177
+ addToCompare(repo: Repo): void {
178
+ if (!this.state.compareList.some((r) => r.fullName === repo.fullName)) {
179
+ this.state.compareList.push(repo);
180
+ }
181
+ }
182
+
183
+ removeFromCompare(fullName: string): void {
184
+ this.state.compareList = this.state.compareList.filter((r) => r.fullName !== fullName);
185
+ }
186
+
187
+ clearCompare(): void {
188
+ this.state.compareList = [];
189
+ }
190
+
191
+ // ── Session ──
192
+
193
+ saveSession(): void {
194
+ const s: SessionState = {
195
+ mode: this.state.currentMode,
196
+ query: this.state.currentQueryInput,
197
+ sort: this.state.currentSort,
198
+ limit: this.state.currentLimit,
199
+ trendingTab: this.state.trendingTab,
200
+ };
201
+ saveSession(s);
202
+ }
203
+
204
+ // ── Setters ──
205
+
206
+ setSort(sort: SortStrategy): void {
207
+ this.state.currentSort = sort;
208
+ }
209
+
210
+ setLimit(limit: number): void {
211
+ this.state.currentLimit = limit;
212
+ }
213
+
214
+ setOverlay(overlay: Overlay): void {
215
+ this.state.currentOverlay = overlay;
216
+ }
217
+
218
+ setGraphFullscreen(fullscreen: boolean): void {
219
+ this.state.graphFullscreen = fullscreen;
220
+ }
221
+
222
+ setChartCommitData(data: number[]): void {
223
+ this.state.chartCommitData = data;
224
+ }
225
+
226
+ setQuitArmed(armed: boolean): void {
227
+ this.state.quitArmed = armed;
228
+ }
229
+ }
package/src/search.ts CHANGED
@@ -467,6 +467,96 @@ function appendPage(url: string, page: number, perPage: number): string {
467
467
  return u.toString();
468
468
  }
469
469
 
470
+ // ─── Trending adapter ─────────────────────────────────────────────────
471
+
472
+ const TRENDING_URL = "https://github.com/trending";
473
+
474
+ interface RawTrendingRepo {
475
+ rank: number;
476
+ owner: string;
477
+ name: string;
478
+ stars: number;
479
+ starsToday: number;
480
+ language: string;
481
+ description: string;
482
+ }
483
+
484
+ function parseTrendingHtml(html: string): RawTrendingRepo[] {
485
+ const flat = html.replace(/>\s+</g, "><").replace(/\s+/g, " ");
486
+ const repos: RawTrendingRepo[] = [];
487
+ const articleRe = /<article class="Box-row">(.*?)<\/article>/gi;
488
+ let match: RegExpExecArray | null;
489
+ let rank = 0;
490
+ while ((match = articleRe.exec(flat)) !== null) {
491
+ rank++;
492
+ const block = match[1];
493
+ const repoMatch = block.match(/href="\/([^\/\"]+)\/([^\"?#]+)"[^>]*class="Link"/);
494
+ if (!repoMatch) continue;
495
+ const owner = repoMatch[1];
496
+ const name = repoMatch[2];
497
+ const starsMatch = block.match(/href="\/[^\/\"]+\/[^\/\"]+\/stargazers"[^>]*>.*?<\/svg>\s*(\d[\d,]*)/);
498
+ const stars = starsMatch ? parseInt(starsMatch[1].replace(/,/g, ""), 10) : 0;
499
+ const growthMatch = block.match(/(\d[\d,]*)\s+stars\s+(today|this\s+\w+)/);
500
+ const starsToday = growthMatch ? parseInt(growthMatch[1].replace(/,/g, ""), 10) : 0;
501
+ const langMatch = block.match(/itemprop="programmingLanguage">([^<]+)</);
502
+ const language = langMatch ? langMatch[1].trim() : "";
503
+ const descMatch = block.match(/<p[^>]*class="[^"]*color-fg-muted[^"]*"[^>]*>([^<]+)</);
504
+ const description = descMatch ? descMatch[1].trim() : "";
505
+ repos.push({ rank, owner, name, stars, starsToday, language, description });
506
+ }
507
+ return repos;
508
+ }
509
+
510
+ function trendingRepoToRepo(r: RawTrendingRepo): Repo {
511
+ return {
512
+ id: 0,
513
+ fullName: `${r.owner}/${r.name}`,
514
+ name: r.name,
515
+ owner: r.owner,
516
+ description: r.description,
517
+ url: `https://github.com/${r.owner}/${r.name}`,
518
+ stars: r.stars,
519
+ forks: 0,
520
+ watchers: 0,
521
+ language: r.language,
522
+ topics: [],
523
+ archived: false,
524
+ isFork: false,
525
+ private: false,
526
+ createdAt: "",
527
+ updatedAt: "",
528
+ pushedAt: "",
529
+ score: r.starsToday,
530
+ };
531
+ }
532
+
533
+ /**
534
+ * Trending adapter — scrapes github.com/trending HTML and returns SearchResponse.
535
+ */
536
+ export class TrendingAdapter implements SearchAdapter {
537
+ readonly name = "trending";
538
+ private readonly logger: Logger;
539
+
540
+ constructor(logger: Logger = noopLogger) {
541
+ this.logger = logger;
542
+ }
543
+
544
+ async search(_query: ParsedQuery, options: SearchOptions): Promise<SearchResponse> {
545
+ const since = options.trendingSince ?? "daily";
546
+ const url = since === "daily" ? TRENDING_URL : `${TRENDING_URL}?since=${since}`;
547
+ let res: Response;
548
+ try {
549
+ res = await fetch(url, { headers: { "User-Agent": "ghfind" } });
550
+ } catch {
551
+ throw new NetworkError();
552
+ }
553
+ if (!res.ok) throw new NetworkError();
554
+ const html = await res.text();
555
+ const repos = parseTrendingHtml(html).map(trendingRepoToRepo);
556
+ return { totalCount: repos.length, repos, rateLimited: false };
557
+ }
558
+ }
559
+
470
560
  // ─── In-memory adapter (for tests) ────────────────────────────────────
471
561
 
472
562
  /**
@@ -551,5 +641,12 @@ export function createGitHubSearch(
551
641
  return new SearchModule(new GitHubSearchAdapter(logger, tokens), logger);
552
642
  }
553
643
 
554
- // Re-export types that callers need
555
- export type { GitHubApiItem, GitHubSearchEnvelope };
644
+ /**
645
+ * Create a SearchModule backed by the Trending adapter.
646
+ */
647
+ export function createTrendingSearch(
648
+ logger: Logger = noopLogger,
649
+ ): SearchModule {
650
+ return new SearchModule(new TrendingAdapter(logger), logger);
651
+ }
652
+
package/src/session.ts CHANGED
@@ -1,17 +1,14 @@
1
1
  /**
2
2
  * Session restore — save/restore UI state between restarts.
3
3
  */
4
- import { mkdirSync, readFileSync, writeFileSync } from "fs";
5
- import { join } from "path";
6
- import type { SessionState, SortStrategy } from "./types";
7
- import { stateDir } from "./config";
4
+ import type { SessionState } from "./types";
5
+ import { readJSON, writeJSON } from "./storage";
8
6
 
9
- const SESSION_FILE = join(stateDir(), "session.json");
7
+ const SESSION_FILE = "session.json";
10
8
 
11
9
  export function saveSession(state: SessionState): void {
12
- mkdirSync(stateDir(), { recursive: true });
13
10
  try {
14
- writeFileSync(SESSION_FILE, JSON.stringify(state));
11
+ writeJSON(SESSION_FILE, state);
15
12
  } catch (err) {
16
13
  if (process.env.DEBUG)
17
14
  console.error(`[ghfind] Failed to save session: ${err instanceof Error ? err.message : String(err)}`);
@@ -19,18 +16,9 @@ export function saveSession(state: SessionState): void {
19
16
  }
20
17
 
21
18
  export function restoreSession(): SessionState | null {
22
- try {
23
- const raw = readFileSync(SESSION_FILE, "utf-8");
24
- return JSON.parse(raw) as SessionState;
25
- } catch {
26
- return null;
27
- }
19
+ return readJSON<SessionState | null>(SESSION_FILE, null);
28
20
  }
29
21
 
30
22
  export function clearSession(): void {
31
- try {
32
- writeFileSync(SESSION_FILE, "");
33
- } catch {
34
- // non-critical
35
- }
23
+ writeJSON(SESSION_FILE, null);
36
24
  }
package/src/storage.ts ADDED
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Storage adapter — single point of file I/O for state files.
3
+ *
4
+ * All domain modules (history, bookmarks, saved-searches, notifications,
5
+ * session, config) delegate file I/O here. Swap for an in-memory
6
+ * implementation in tests.
7
+ */
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, unlinkSync } from "fs";
9
+ import { join } from "path";
10
+ import { stateDir } from "./config";
11
+
12
+ /** Ensure the state directory exists. */
13
+ function ensureDir(): void {
14
+ mkdirSync(stateDir(), { recursive: true });
15
+ }
16
+
17
+ /** Read and parse a JSON file. Returns `fallback` on any error. */
18
+ export function readJSON<T>(filename: string, fallback: T): T {
19
+ ensureDir();
20
+ try {
21
+ return JSON.parse(readFileSync(join(stateDir(), filename), "utf-8")) as T;
22
+ } catch {
23
+ return fallback;
24
+ }
25
+ }
26
+
27
+ /** Write data as JSON to a file. */
28
+ export function writeJSON(filename: string, data: unknown): void {
29
+ ensureDir();
30
+ writeFileSync(join(stateDir(), filename), JSON.stringify(data, null, 2));
31
+ }
32
+
33
+ /** Append one JSON object as a line to a JSONL file. */
34
+ export function appendJSONL(filename: string, obj: unknown): void {
35
+ ensureDir();
36
+ try {
37
+ appendFileSync(join(stateDir(), filename), JSON.stringify(obj) + "\n", "utf-8");
38
+ } catch {
39
+ // non-critical
40
+ }
41
+ }
42
+
43
+ /** Read all lines from a JSONL file, parsing each as JSON. */
44
+ export function readJSONL<T>(filename: string): T[] {
45
+ ensureDir();
46
+ try {
47
+ const raw = readFileSync(join(stateDir(), filename), "utf-8").trim();
48
+ if (!raw) return [];
49
+ return raw.split("\n").filter(Boolean).map((l) => JSON.parse(l) as T);
50
+ } catch {
51
+ return [];
52
+ }
53
+ }
54
+
55
+ /** Write raw text to a file (bypasses JSON encoding). */
56
+ export function writeRaw(filename: string, data: string): void {
57
+ ensureDir();
58
+ writeFileSync(join(stateDir(), filename), data);
59
+ }
60
+
61
+ /** Delete a file if it exists. */
62
+ export function deleteFile(filename: string): void {
63
+ try {
64
+ unlinkSync(join(stateDir(), filename));
65
+ } catch {
66
+ // non-critical
67
+ }
68
+ }
69
+
70
+ /** Append a timestamped debug line to ghfind-debug.log (only when DEBUG is set). */
71
+ export function debugLog(msg: string): void {
72
+ if (!process.env.DEBUG) return;
73
+ try {
74
+ appendFileSync(join(stateDir(), "ghfind-debug.log"), `[${new Date().toISOString()}] ${msg}\n`, "utf-8");
75
+ } catch { /* ignore */ }
76
+ }
77
+
78
+ /** Check if a file exists in the state dir. */
79
+ export function fileExists(filename: string): boolean {
80
+ return existsSync(join(stateDir(), filename));
81
+ }
82
+
83
+ // ─── In-memory implementation for tests ────────────────────────────────
84
+
85
+ const memStore = new Map<string, string>();
86
+
87
+ export const memoryStorage = {
88
+ readJSON<T>(filename: string, fallback: T): T {
89
+ const raw = memStore.get(filename);
90
+ if (!raw) return fallback;
91
+ try { return JSON.parse(raw) as T; } catch { return fallback; }
92
+ },
93
+ writeJSON(filename: string, data: unknown): void {
94
+ memStore.set(filename, JSON.stringify(data, null, 2));
95
+ },
96
+ appendJSONL(filename: string, obj: unknown): void {
97
+ const existing = memStore.get(filename) ?? "";
98
+ memStore.set(filename, existing + JSON.stringify(obj) + "\n");
99
+ },
100
+ readJSONL<T>(filename: string): T[] {
101
+ const raw = memStore.get(filename);
102
+ if (!raw) return [];
103
+ return raw.trim().split("\n").filter(Boolean).map((l) => JSON.parse(l) as T);
104
+ },
105
+ clear(): void { memStore.clear(); },
106
+ };
package/src/themes.ts CHANGED
@@ -1,12 +1,20 @@
1
1
  /**
2
- * Theme system — 3 built-in palettes. Apply theme to TUI colors.
2
+ * Theme system — Premium palettes with nuanced color layers.
3
+ * Each theme provides a rich color hierarchy for premium visual depth.
3
4
  */
4
5
  export interface Theme {
5
6
  name: string;
7
+ /** Primary background — deepest layer */
6
8
  bg: string;
9
+ /** Secondary surface — cards, panels, elevated areas */
7
10
  surface: string;
11
+ /** Tertiary surface — subtle hover/focus states */
12
+ surfaceAlt: string;
13
+ /** Primary text — highest contrast */
8
14
  text: string;
15
+ /** Secondary text — labels, metadata */
9
16
  muted: string;
17
+ /** Accent colors for semantic meaning */
10
18
  blue: string;
11
19
  green: string;
12
20
  yellow: string;
@@ -14,32 +22,64 @@ export interface Theme {
14
22
  teal: string;
15
23
  purple: string;
16
24
  orange: string;
25
+ /** Border and divider color */
17
26
  border: string;
27
+ /** Subtle border for inner dividers */
28
+ borderAlt: string;
29
+ /** Darkest thin colorblock - thicker than border, acts as visual separator */
30
+ separator: string;
31
+ /** Selection/hover background */
18
32
  selectionBg: string;
33
+ /** Selection/hover text */
19
34
  selectionText: string;
35
+ /** Success state color */
36
+ success: string;
37
+ /** Warning state color */
38
+ warning: string;
39
+ /** Error state color */
40
+ error: string;
20
41
  }
21
42
 
22
43
  const THEMES: Record<string, Theme> = {
44
+ "premium-dark": {
45
+ name: "premium-dark",
46
+ bg: "#3D3B3B", surface: "#4a4848", surfaceAlt: "#525050",
47
+ text: "#e0e4f0", muted: "#a8a6a6",
48
+ blue: "#89b4fa", green: "#a6e3a1", yellow: "#f9e2af", red: "#f38ba8",
49
+ teal: "#94e2d5", purple: "#cba6f7", orange: "#fab387",
50
+ border: "#1C1C1C", borderAlt: "#2a2a2a", separator: "#1C1C1C",
51
+ selectionBg: "#2A2A9C", selectionText: "#ffffff",
52
+ success: "#a6e3a1", warning: "#f9e2af", error: "#f38ba8",
53
+ },
23
54
  "tokyo-night": {
24
55
  name: "tokyo-night",
25
- bg: "#1e1e2e", surface: "#181825", text: "#cdd6f4", muted: "#6c7086",
56
+ bg: "#3D3B3B", surface: "#4a4848", surfaceAlt: "#525050",
57
+ text: "#e0e4f0", muted: "#a8a6a6",
26
58
  blue: "#89b4fa", green: "#a6e3a1", yellow: "#f9e2af", red: "#f38ba8",
27
59
  teal: "#94e2d5", purple: "#cba6f7", orange: "#fab387",
28
- border: "#45475a", selectionBg: "#2d5bcf", selectionText: "#ffffff",
60
+ border: "#1C1C1C", borderAlt: "#2a2a2a", separator: "#1C1C1C",
61
+ selectionBg: "#2A2A9C", selectionText: "#ffffff",
62
+ success: "#a6e3a1", warning: "#f9e2af", error: "#f38ba8",
29
63
  },
30
64
  dracula: {
31
65
  name: "dracula",
32
- bg: "#282a36", surface: "#21222c", text: "#f8f8f2", muted: "#6272a4",
66
+ bg: "#3D3B3B", surface: "#4a4848", surfaceAlt: "#525050",
67
+ text: "#f8f8f2", muted: "#a8a6a6",
33
68
  blue: "#8be9fd", green: "#50fa7b", yellow: "#f1fa8c", red: "#ff5555",
34
69
  teal: "#50fa7b", purple: "#bd93f9", orange: "#ffb86c",
35
- border: "#44475a", selectionBg: "#bd93f9", selectionText: "#282a36",
70
+ border: "#1C1C1C", borderAlt: "#2a2a2a", separator: "#1C1C1C",
71
+ selectionBg: "#2A2A9C", selectionText: "#ffffff",
72
+ success: "#50fa7b", warning: "#f1fa8c", error: "#ff5555",
36
73
  },
37
74
  monokai: {
38
75
  name: "monokai",
39
- bg: "#272822", surface: "#1e1f1c", text: "#f8f8f2", muted: "#75715e",
76
+ bg: "#3D3B3B", surface: "#4a4848", surfaceAlt: "#525050",
77
+ text: "#f8f8f2", muted: "#a8a6a6",
40
78
  blue: "#66d9ef", green: "#a6e22e", yellow: "#e6db74", red: "#f92672",
41
79
  teal: "#a6e22e", purple: "#ae81ff", orange: "#fd971f",
42
- border: "#49483e", selectionBg: "#e6db74", selectionText: "#272822",
80
+ border: "#1C1C1C", borderAlt: "#2a2a2a", separator: "#1C1C1C",
81
+ selectionBg: "#2A2A9C", selectionText: "#f8f8f2",
82
+ success: "#a6e22e", warning: "#e6db74", error: "#f92672",
43
83
  },
44
84
  };
45
85