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/README.md +162 -144
- package/bin/ghfind.js +55 -14
- package/completions/ghfind.fish +18 -18
- package/completions/ghfind.zsh +26 -26
- package/demo.svg +65 -65
- package/package.json +5 -2
- package/src/bookmarks.ts +69 -69
- package/src/cli.ts +239 -239
- package/src/compare.ts +46 -46
- package/src/config.ts +65 -65
- package/src/deepdive.ts +118 -118
- package/src/errors.ts +59 -59
- package/src/explore.ts +29 -29
- package/src/export.ts +63 -63
- package/src/help.ts +112 -112
- package/src/history.ts +91 -91
- package/src/init.ts +55 -55
- package/src/notifications.ts +65 -65
- package/src/open-url.ts +16 -16
- package/src/output.ts +67 -67
- package/src/pipe.ts +54 -54
- package/src/saved-searches.ts +58 -58
- package/src/search.ts +555 -555
- package/src/session.ts +36 -36
- package/src/share.ts +54 -54
- package/src/status.ts +61 -61
- package/src/themes.ts +52 -52
- package/src/tips.ts +27 -27
- package/src/trending.ts +558 -556
- package/src/tui.ts +1667 -1665
- package/src/types.ts +139 -139
- package/src/watch.ts +62 -62
- package/vendor/bun.exe +0 -0
package/src/export.ts
CHANGED
|
@@ -1,63 +1,63 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Export — format repos as JSON, CSV, Markdown, or plain text.
|
|
3
|
-
*/
|
|
4
|
-
import { writeFileSync } from "fs";
|
|
5
|
-
import type { Repo } from "./types";
|
|
6
|
-
|
|
7
|
-
export type ExportFormat = "json" | "csv" | "markdown" | "text";
|
|
8
|
-
|
|
9
|
-
export function formatJson(repos: Repo[]): string {
|
|
10
|
-
return JSON.stringify(repos, null, 2);
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function csvEscape(val: unknown): string {
|
|
14
|
-
const str = String(val ?? "");
|
|
15
|
-
if (str.includes('"') || str.includes(",") || str.includes("\n")) {
|
|
16
|
-
return `"${str.replace(/"/g, '""')}"`;
|
|
17
|
-
}
|
|
18
|
-
return str;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/** Format repos as CSV string. */
|
|
22
|
-
export function formatCsv(repos: Repo[]): string {
|
|
23
|
-
const header = "rank,full_name,stars,forks,language,url";
|
|
24
|
-
const rows = repos.map((r, i) =>
|
|
25
|
-
`${i + 1},${csvEscape(r.fullName)},${r.stars},${r.forks},${csvEscape(r.language ?? "")},${csvEscape(r.url)}`,
|
|
26
|
-
);
|
|
27
|
-
return [header, ...rows].join("\n");
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** Format repos as Markdown table. */
|
|
31
|
-
export function formatMarkdown(repos: Repo[]): string {
|
|
32
|
-
const header = "| # | Repo | Stars | Forks | Language |";
|
|
33
|
-
const sep = "|---|------|-------|-------|----------|";
|
|
34
|
-
const rows = repos.map(
|
|
35
|
-
(r, i) =>
|
|
36
|
-
`| ${i + 1} | [${r.fullName}](${r.url}) | ${r.stars.toLocaleString()} | ${r.forks.toLocaleString()} | ${r.language ?? "—"} |`,
|
|
37
|
-
);
|
|
38
|
-
return [header, sep, ...rows].join("\n");
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** Format repos as plain text, one per line. */
|
|
42
|
-
export function formatText(repos: Repo[]): string {
|
|
43
|
-
return repos.map((r, i) => `${i + 1}. ${r.fullName} ★ ${r.stars.toLocaleString()} ${r.url}`).join("\n");
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Format repos in the given format. */
|
|
47
|
-
export function formatRepos(repos: Repo[], format: ExportFormat): string {
|
|
48
|
-
switch (format) {
|
|
49
|
-
case "json": return formatJson(repos);
|
|
50
|
-
case "csv": return formatCsv(repos);
|
|
51
|
-
case "markdown": return formatMarkdown(repos);
|
|
52
|
-
case "text": return formatText(repos);
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** Export repos to a file. Returns the file path. */
|
|
57
|
-
export function exportToFile(repos: Repo[], format: ExportFormat): string {
|
|
58
|
-
const ext = format === "markdown" ? "md" : format;
|
|
59
|
-
const ts = Date.now();
|
|
60
|
-
const path = `./ghfind-export-${ts}.${ext}`;
|
|
61
|
-
writeFileSync(path, formatRepos(repos, format), "utf-8");
|
|
62
|
-
return path;
|
|
63
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Export — format repos as JSON, CSV, Markdown, or plain text.
|
|
3
|
+
*/
|
|
4
|
+
import { writeFileSync } from "fs";
|
|
5
|
+
import type { Repo } from "./types";
|
|
6
|
+
|
|
7
|
+
export type ExportFormat = "json" | "csv" | "markdown" | "text";
|
|
8
|
+
|
|
9
|
+
export function formatJson(repos: Repo[]): string {
|
|
10
|
+
return JSON.stringify(repos, null, 2);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function csvEscape(val: unknown): string {
|
|
14
|
+
const str = String(val ?? "");
|
|
15
|
+
if (str.includes('"') || str.includes(",") || str.includes("\n")) {
|
|
16
|
+
return `"${str.replace(/"/g, '""')}"`;
|
|
17
|
+
}
|
|
18
|
+
return str;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Format repos as CSV string. */
|
|
22
|
+
export function formatCsv(repos: Repo[]): string {
|
|
23
|
+
const header = "rank,full_name,stars,forks,language,url";
|
|
24
|
+
const rows = repos.map((r, i) =>
|
|
25
|
+
`${i + 1},${csvEscape(r.fullName)},${r.stars},${r.forks},${csvEscape(r.language ?? "")},${csvEscape(r.url)}`,
|
|
26
|
+
);
|
|
27
|
+
return [header, ...rows].join("\n");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Format repos as Markdown table. */
|
|
31
|
+
export function formatMarkdown(repos: Repo[]): string {
|
|
32
|
+
const header = "| # | Repo | Stars | Forks | Language |";
|
|
33
|
+
const sep = "|---|------|-------|-------|----------|";
|
|
34
|
+
const rows = repos.map(
|
|
35
|
+
(r, i) =>
|
|
36
|
+
`| ${i + 1} | [${r.fullName}](${r.url}) | ${r.stars.toLocaleString()} | ${r.forks.toLocaleString()} | ${r.language ?? "—"} |`,
|
|
37
|
+
);
|
|
38
|
+
return [header, sep, ...rows].join("\n");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Format repos as plain text, one per line. */
|
|
42
|
+
export function formatText(repos: Repo[]): string {
|
|
43
|
+
return repos.map((r, i) => `${i + 1}. ${r.fullName} ★ ${r.stars.toLocaleString()} ${r.url}`).join("\n");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Format repos in the given format. */
|
|
47
|
+
export function formatRepos(repos: Repo[], format: ExportFormat): string {
|
|
48
|
+
switch (format) {
|
|
49
|
+
case "json": return formatJson(repos);
|
|
50
|
+
case "csv": return formatCsv(repos);
|
|
51
|
+
case "markdown": return formatMarkdown(repos);
|
|
52
|
+
case "text": return formatText(repos);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Export repos to a file. Returns the file path. */
|
|
57
|
+
export function exportToFile(repos: Repo[], format: ExportFormat): string {
|
|
58
|
+
const ext = format === "markdown" ? "md" : format;
|
|
59
|
+
const ts = Date.now();
|
|
60
|
+
const path = `./ghfind-export-${ts}.${ext}`;
|
|
61
|
+
writeFileSync(path, formatRepos(repos, format), "utf-8");
|
|
62
|
+
return path;
|
|
63
|
+
}
|
package/src/help.ts
CHANGED
|
@@ -1,112 +1,112 @@
|
|
|
1
|
-
/** Help screen — structured sections rendered by OpenTUI components. */
|
|
2
|
-
|
|
3
|
-
export interface HelpRow {
|
|
4
|
-
keys: string;
|
|
5
|
-
action: string;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export interface HelpSection {
|
|
9
|
-
title: string;
|
|
10
|
-
/** Theme color key or hex — used for Box title color */
|
|
11
|
-
titleColor: string;
|
|
12
|
-
rows: HelpRow[];
|
|
13
|
-
note?: string;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export function buildHelpSections(): HelpSection[] {
|
|
17
|
-
return [
|
|
18
|
-
{
|
|
19
|
-
title: " Navigation ",
|
|
20
|
-
titleColor: "blue",
|
|
21
|
-
rows: [
|
|
22
|
-
{ keys: "↑ ↓ / j k", action: "Move selection" },
|
|
23
|
-
{ keys: "Enter", action: "Open repo in browser" },
|
|
24
|
-
{ keys: "/", action: "Focus search input" },
|
|
25
|
-
{ keys: "Space", action: "Open command menu" },
|
|
26
|
-
{ keys: "? / Ctrl+H", action: "Toggle this help screen" },
|
|
27
|
-
{ keys: "q", action: "Quit" },
|
|
28
|
-
],
|
|
29
|
-
},
|
|
30
|
-
{
|
|
31
|
-
title: " Main View ",
|
|
32
|
-
titleColor: "green",
|
|
33
|
-
rows: [
|
|
34
|
-
{ keys: "Tab", action: "Auto-complete qualifier" },
|
|
35
|
-
{ keys: "PageDown", action: "Next page of results" },
|
|
36
|
-
{ keys: "PageUp", action: "Scroll to top of results" },
|
|
37
|
-
],
|
|
38
|
-
},
|
|
39
|
-
{
|
|
40
|
-
title: " Trending Mode ",
|
|
41
|
-
titleColor: "yellow",
|
|
42
|
-
rows: [
|
|
43
|
-
{ keys: "1-5", action: "Switch tab: Today → Week → Month → …" },
|
|
44
|
-
{ keys: "← → / h l", action: "Navigate tabs" },
|
|
45
|
-
],
|
|
46
|
-
},
|
|
47
|
-
{
|
|
48
|
-
title: " Command Menu (Space)",
|
|
49
|
-
titleColor: "purple",
|
|
50
|
-
rows: [
|
|
51
|
-
{ keys: "Sort", action: "Cycle sort: best-match → stars → …" },
|
|
52
|
-
{ keys: "Limit", action: "Cycle limit: 10 → 25 → 50 → 100" },
|
|
53
|
-
{ keys: "Refresh", action: "Re-run current search" },
|
|
54
|
-
{ keys: "Graph", action: "Toggle commit activity chart" },
|
|
55
|
-
{ keys: "Readme", action: "Full README viewer" },
|
|
56
|
-
{ keys: "Deep-dive", action: "Languages + contributors + excerpt" },
|
|
57
|
-
{ keys: "Bookmark", action: "Save / unsave selected repo" },
|
|
58
|
-
{ keys: "Compare", action: "Add to / remove from comparison" },
|
|
59
|
-
{ keys: "Trending", action: "Switch to trending browser" },
|
|
60
|
-
],
|
|
61
|
-
note: "Also: Open, History, Export, Share, Topics, Saved, Notifs, Help",
|
|
62
|
-
},
|
|
63
|
-
{
|
|
64
|
-
title: " Overlay Keys ",
|
|
65
|
-
titleColor: "teal",
|
|
66
|
-
rows: [
|
|
67
|
-
{ keys: "Esc / q", action: "Close any overlay" },
|
|
68
|
-
{ keys: "Enter", action: "Confirm selection / run action" },
|
|
69
|
-
{ keys: "d", action: "Delete entry (history, bookmarks, saved)" },
|
|
70
|
-
{ keys: "↑ ↓ / j k", action: "Navigate overlay list" },
|
|
71
|
-
],
|
|
72
|
-
},
|
|
73
|
-
{
|
|
74
|
-
title: " Search Syntax ",
|
|
75
|
-
titleColor: "teal",
|
|
76
|
-
rows: [
|
|
77
|
-
{ keys: "keywords", action: "Free-text search" },
|
|
78
|
-
{ keys: "key:value", action: "Qualifier filter" },
|
|
79
|
-
{ keys: "-key:value", action: "Negated qualifier" },
|
|
80
|
-
{ keys: '"phrase"', action: "Exact phrase match" },
|
|
81
|
-
],
|
|
82
|
-
},
|
|
83
|
-
{
|
|
84
|
-
title: " Qualifiers ",
|
|
85
|
-
titleColor: "muted",
|
|
86
|
-
rows: [
|
|
87
|
-
{ keys: "language:", action: "Filter by language" },
|
|
88
|
-
{ keys: "stars:>N", action: "Minimum stars" },
|
|
89
|
-
{ keys: "fork:true", action: "Include forks" },
|
|
90
|
-
{ keys: "user:", action: "User's repos" },
|
|
91
|
-
{ keys: "org:", action: "Organization repos" },
|
|
92
|
-
{ keys: "repo:", action: "Specific repo" },
|
|
93
|
-
{ keys: "topic:", action: "Filter by topic" },
|
|
94
|
-
{ keys: "license:", action: "License type" },
|
|
95
|
-
{ keys: "pushed:>YYYY-MM-DD", action: "Last push date" },
|
|
96
|
-
{ keys: "archived:false", action: "Exclude archived" },
|
|
97
|
-
],
|
|
98
|
-
},
|
|
99
|
-
{
|
|
100
|
-
title: " Rate Limits ",
|
|
101
|
-
titleColor: "orange",
|
|
102
|
-
rows: [
|
|
103
|
-
{ keys: "No token", action: "10 req / min" },
|
|
104
|
-
{ keys: "GITHUB_TOKEN", action: "30 req / min (or your plan limit)" },
|
|
105
|
-
],
|
|
106
|
-
note: "Set GITHUB_TOKEN in your environment or add to config.",
|
|
107
|
-
},
|
|
108
|
-
];
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** Column at which the action description starts (right-aligns keys). */
|
|
112
|
-
export const HELP_KEYS_COLUMN = 24;
|
|
1
|
+
/** Help screen — structured sections rendered by OpenTUI components. */
|
|
2
|
+
|
|
3
|
+
export interface HelpRow {
|
|
4
|
+
keys: string;
|
|
5
|
+
action: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface HelpSection {
|
|
9
|
+
title: string;
|
|
10
|
+
/** Theme color key or hex — used for Box title color */
|
|
11
|
+
titleColor: string;
|
|
12
|
+
rows: HelpRow[];
|
|
13
|
+
note?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function buildHelpSections(): HelpSection[] {
|
|
17
|
+
return [
|
|
18
|
+
{
|
|
19
|
+
title: " Navigation ",
|
|
20
|
+
titleColor: "blue",
|
|
21
|
+
rows: [
|
|
22
|
+
{ keys: "↑ ↓ / j k", action: "Move selection" },
|
|
23
|
+
{ keys: "Enter", action: "Open repo in browser" },
|
|
24
|
+
{ keys: "/", action: "Focus search input" },
|
|
25
|
+
{ keys: "Space", action: "Open command menu" },
|
|
26
|
+
{ keys: "? / Ctrl+H", action: "Toggle this help screen" },
|
|
27
|
+
{ keys: "q", action: "Quit" },
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
title: " Main View ",
|
|
32
|
+
titleColor: "green",
|
|
33
|
+
rows: [
|
|
34
|
+
{ keys: "Tab", action: "Auto-complete qualifier" },
|
|
35
|
+
{ keys: "PageDown", action: "Next page of results" },
|
|
36
|
+
{ keys: "PageUp", action: "Scroll to top of results" },
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
title: " Trending Mode ",
|
|
41
|
+
titleColor: "yellow",
|
|
42
|
+
rows: [
|
|
43
|
+
{ keys: "1-5", action: "Switch tab: Today → Week → Month → …" },
|
|
44
|
+
{ keys: "← → / h l", action: "Navigate tabs" },
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
title: " Command Menu (Space)",
|
|
49
|
+
titleColor: "purple",
|
|
50
|
+
rows: [
|
|
51
|
+
{ keys: "Sort", action: "Cycle sort: best-match → stars → …" },
|
|
52
|
+
{ keys: "Limit", action: "Cycle limit: 10 → 25 → 50 → 100" },
|
|
53
|
+
{ keys: "Refresh", action: "Re-run current search" },
|
|
54
|
+
{ keys: "Graph", action: "Toggle commit activity chart" },
|
|
55
|
+
{ keys: "Readme", action: "Full README viewer" },
|
|
56
|
+
{ keys: "Deep-dive", action: "Languages + contributors + excerpt" },
|
|
57
|
+
{ keys: "Bookmark", action: "Save / unsave selected repo" },
|
|
58
|
+
{ keys: "Compare", action: "Add to / remove from comparison" },
|
|
59
|
+
{ keys: "Trending", action: "Switch to trending browser" },
|
|
60
|
+
],
|
|
61
|
+
note: "Also: Open, History, Export, Share, Topics, Saved, Notifs, Help",
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
title: " Overlay Keys ",
|
|
65
|
+
titleColor: "teal",
|
|
66
|
+
rows: [
|
|
67
|
+
{ keys: "Esc / q", action: "Close any overlay" },
|
|
68
|
+
{ keys: "Enter", action: "Confirm selection / run action" },
|
|
69
|
+
{ keys: "d", action: "Delete entry (history, bookmarks, saved)" },
|
|
70
|
+
{ keys: "↑ ↓ / j k", action: "Navigate overlay list" },
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
title: " Search Syntax ",
|
|
75
|
+
titleColor: "teal",
|
|
76
|
+
rows: [
|
|
77
|
+
{ keys: "keywords", action: "Free-text search" },
|
|
78
|
+
{ keys: "key:value", action: "Qualifier filter" },
|
|
79
|
+
{ keys: "-key:value", action: "Negated qualifier" },
|
|
80
|
+
{ keys: '"phrase"', action: "Exact phrase match" },
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
title: " Qualifiers ",
|
|
85
|
+
titleColor: "muted",
|
|
86
|
+
rows: [
|
|
87
|
+
{ keys: "language:", action: "Filter by language" },
|
|
88
|
+
{ keys: "stars:>N", action: "Minimum stars" },
|
|
89
|
+
{ keys: "fork:true", action: "Include forks" },
|
|
90
|
+
{ keys: "user:", action: "User's repos" },
|
|
91
|
+
{ keys: "org:", action: "Organization repos" },
|
|
92
|
+
{ keys: "repo:", action: "Specific repo" },
|
|
93
|
+
{ keys: "topic:", action: "Filter by topic" },
|
|
94
|
+
{ keys: "license:", action: "License type" },
|
|
95
|
+
{ keys: "pushed:>YYYY-MM-DD", action: "Last push date" },
|
|
96
|
+
{ keys: "archived:false", action: "Exclude archived" },
|
|
97
|
+
],
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
title: " Rate Limits ",
|
|
101
|
+
titleColor: "orange",
|
|
102
|
+
rows: [
|
|
103
|
+
{ keys: "No token", action: "10 req / min" },
|
|
104
|
+
{ keys: "GITHUB_TOKEN", action: "30 req / min (or your plan limit)" },
|
|
105
|
+
],
|
|
106
|
+
note: "Set GITHUB_TOKEN in your environment or add to config.",
|
|
107
|
+
},
|
|
108
|
+
];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Column at which the action description starts (right-aligns keys). */
|
|
112
|
+
export const HELP_KEYS_COLUMN = 24;
|
package/src/history.ts
CHANGED
|
@@ -1,91 +1,91 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Persistent search history — append-only JSONL with rotation.
|
|
3
|
-
*/
|
|
4
|
-
import { existsSync, mkdirSync, readFileSync, appendFileSync, writeFileSync, unlinkSync } from "fs";
|
|
5
|
-
import { join } from "path";
|
|
6
|
-
import type { HistoryEntry } from "./types";
|
|
7
|
-
import { stateDir } from "./config";
|
|
8
|
-
|
|
9
|
-
const HISTORY_FILE = join(stateDir(), "history.jsonl");
|
|
10
|
-
const MAX_ENTRIES = 500;
|
|
11
|
-
|
|
12
|
-
function ensureDir() {
|
|
13
|
-
mkdirSync(stateDir(), { recursive: true });
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/** Append one entry to the history log. */
|
|
17
|
-
export function appendHistory(entry: HistoryEntry): void {
|
|
18
|
-
ensureDir();
|
|
19
|
-
const line = JSON.stringify(entry) + "\n";
|
|
20
|
-
try {
|
|
21
|
-
appendFileSync(HISTORY_FILE, line, "utf-8");
|
|
22
|
-
} catch {
|
|
23
|
-
// non-critical
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** Read all history entries, newest first, deduplicating consecutive identical queries. */
|
|
28
|
-
export function readHistory(): HistoryEntry[] {
|
|
29
|
-
try {
|
|
30
|
-
const raw = readFileSync(HISTORY_FILE, "utf-8").trim();
|
|
31
|
-
if (!raw) return [];
|
|
32
|
-
const lines = raw.split("\n").filter(Boolean);
|
|
33
|
-
const entries: HistoryEntry[] = lines.map((l) => JSON.parse(l) as HistoryEntry);
|
|
34
|
-
|
|
35
|
-
// Sorted oldest-first in file; reverse to newest-first
|
|
36
|
-
entries.reverse();
|
|
37
|
-
|
|
38
|
-
// Deduplicate consecutive identical queries (keep newest)
|
|
39
|
-
const deduped: HistoryEntry[] = [];
|
|
40
|
-
let lastQuery: string | undefined;
|
|
41
|
-
for (const e of entries) {
|
|
42
|
-
if (e.query !== lastQuery) {
|
|
43
|
-
deduped.push(e);
|
|
44
|
-
lastQuery = e.query;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
return deduped;
|
|
48
|
-
} catch {
|
|
49
|
-
return [];
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Delete a single history entry by index (0 = newest). */
|
|
54
|
-
export function deleteHistoryEntry(index: number): void {
|
|
55
|
-
const entries = readHistory();
|
|
56
|
-
if (index < 0 || index >= entries.length) return;
|
|
57
|
-
const removed = entries[index];
|
|
58
|
-
// Rewrite file without the removed entry
|
|
59
|
-
const remaining = entries.filter((e) => e.timestamp !== removed.timestamp || e.query !== removed.query);
|
|
60
|
-
ensureDir();
|
|
61
|
-
// Reverse back to oldest-first for file order
|
|
62
|
-
writeFileSync(HISTORY_FILE, remaining.reverse().map((e) => JSON.stringify(e)).join("\n") + "\n");
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** Clear all history. */
|
|
66
|
-
export function clearHistory(): void {
|
|
67
|
-
try {
|
|
68
|
-
writeFileSync(HISTORY_FILE, "");
|
|
69
|
-
} catch {
|
|
70
|
-
// non-critical
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** Rotate history to max entries (trim oldest). */
|
|
75
|
-
export function rotateHistory(): void {
|
|
76
|
-
const entries = readHistory();
|
|
77
|
-
if (entries.length <= MAX_ENTRIES) return;
|
|
78
|
-
const trimmed = entries.slice(0, MAX_ENTRIES);
|
|
79
|
-
ensureDir();
|
|
80
|
-
writeFileSync(HISTORY_FILE, trimmed.reverse().map((e) => JSON.stringify(e)).join("\n") + "\n");
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Count entries in history file. */
|
|
84
|
-
export function historyCount(): number {
|
|
85
|
-
try {
|
|
86
|
-
const raw = readFileSync(HISTORY_FILE, "utf-8").trim();
|
|
87
|
-
return raw ? raw.split("\n").length : 0;
|
|
88
|
-
} catch {
|
|
89
|
-
return 0;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Persistent search history — append-only JSONL with rotation.
|
|
3
|
+
*/
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, appendFileSync, writeFileSync, unlinkSync } from "fs";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
import type { HistoryEntry } from "./types";
|
|
7
|
+
import { stateDir } from "./config";
|
|
8
|
+
|
|
9
|
+
const HISTORY_FILE = join(stateDir(), "history.jsonl");
|
|
10
|
+
const MAX_ENTRIES = 500;
|
|
11
|
+
|
|
12
|
+
function ensureDir() {
|
|
13
|
+
mkdirSync(stateDir(), { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Append one entry to the history log. */
|
|
17
|
+
export function appendHistory(entry: HistoryEntry): void {
|
|
18
|
+
ensureDir();
|
|
19
|
+
const line = JSON.stringify(entry) + "\n";
|
|
20
|
+
try {
|
|
21
|
+
appendFileSync(HISTORY_FILE, line, "utf-8");
|
|
22
|
+
} catch {
|
|
23
|
+
// non-critical
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Read all history entries, newest first, deduplicating consecutive identical queries. */
|
|
28
|
+
export function readHistory(): HistoryEntry[] {
|
|
29
|
+
try {
|
|
30
|
+
const raw = readFileSync(HISTORY_FILE, "utf-8").trim();
|
|
31
|
+
if (!raw) return [];
|
|
32
|
+
const lines = raw.split("\n").filter(Boolean);
|
|
33
|
+
const entries: HistoryEntry[] = lines.map((l) => JSON.parse(l) as HistoryEntry);
|
|
34
|
+
|
|
35
|
+
// Sorted oldest-first in file; reverse to newest-first
|
|
36
|
+
entries.reverse();
|
|
37
|
+
|
|
38
|
+
// Deduplicate consecutive identical queries (keep newest)
|
|
39
|
+
const deduped: HistoryEntry[] = [];
|
|
40
|
+
let lastQuery: string | undefined;
|
|
41
|
+
for (const e of entries) {
|
|
42
|
+
if (e.query !== lastQuery) {
|
|
43
|
+
deduped.push(e);
|
|
44
|
+
lastQuery = e.query;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return deduped;
|
|
48
|
+
} catch {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Delete a single history entry by index (0 = newest). */
|
|
54
|
+
export function deleteHistoryEntry(index: number): void {
|
|
55
|
+
const entries = readHistory();
|
|
56
|
+
if (index < 0 || index >= entries.length) return;
|
|
57
|
+
const removed = entries[index];
|
|
58
|
+
// Rewrite file without the removed entry
|
|
59
|
+
const remaining = entries.filter((e) => e.timestamp !== removed.timestamp || e.query !== removed.query);
|
|
60
|
+
ensureDir();
|
|
61
|
+
// Reverse back to oldest-first for file order
|
|
62
|
+
writeFileSync(HISTORY_FILE, remaining.reverse().map((e) => JSON.stringify(e)).join("\n") + "\n");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Clear all history. */
|
|
66
|
+
export function clearHistory(): void {
|
|
67
|
+
try {
|
|
68
|
+
writeFileSync(HISTORY_FILE, "");
|
|
69
|
+
} catch {
|
|
70
|
+
// non-critical
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Rotate history to max entries (trim oldest). */
|
|
75
|
+
export function rotateHistory(): void {
|
|
76
|
+
const entries = readHistory();
|
|
77
|
+
if (entries.length <= MAX_ENTRIES) return;
|
|
78
|
+
const trimmed = entries.slice(0, MAX_ENTRIES);
|
|
79
|
+
ensureDir();
|
|
80
|
+
writeFileSync(HISTORY_FILE, trimmed.reverse().map((e) => JSON.stringify(e)).join("\n") + "\n");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Count entries in history file. */
|
|
84
|
+
export function historyCount(): number {
|
|
85
|
+
try {
|
|
86
|
+
const raw = readFileSync(HISTORY_FILE, "utf-8").trim();
|
|
87
|
+
return raw ? raw.split("\n").length : 0;
|
|
88
|
+
} catch {
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
}
|
package/src/init.ts
CHANGED
|
@@ -1,55 +1,55 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `ghfind init` wizard — interactive one-time config setup.
|
|
3
|
-
*
|
|
4
|
-
* ponytail: simple stdin prompts, no curses/readline UI.
|
|
5
|
-
*/
|
|
6
|
-
import { createInterface } from "readline";
|
|
7
|
-
import { saveConfig } from "./config";
|
|
8
|
-
import { listThemes } from "./themes";
|
|
9
|
-
import type { Config, SortStrategy } from "./types";
|
|
10
|
-
|
|
11
|
-
function ask(query: string): Promise<string> {
|
|
12
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
13
|
-
return new Promise((resolve) => {
|
|
14
|
-
rl.question(query, (answer) => {
|
|
15
|
-
rl.close();
|
|
16
|
-
resolve(answer.trim());
|
|
17
|
-
});
|
|
18
|
-
});
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export async function runInitWizard(): Promise<void> {
|
|
22
|
-
console.log("");
|
|
23
|
-
console.log(" ghfind setup wizard");
|
|
24
|
-
console.log(" ──────────────────────");
|
|
25
|
-
console.log("");
|
|
26
|
-
|
|
27
|
-
const token = await ask(" GitHub token (optional, press Enter to skip): ");
|
|
28
|
-
if (token && !token.startsWith("ghp_") && !token.startsWith("github_pat_") && !token.startsWith("gho_")) {
|
|
29
|
-
console.log(" ⚠ Token doesn't look like a GitHub token (should start with ghp_, gho_, or github_pat_)");
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const sortStr = await ask(" Default sort [best-match/stars/updated/forks] (best-match): ");
|
|
33
|
-
const limitStr = await ask(" Default result limit [10-100] (50): ");
|
|
34
|
-
|
|
35
|
-
const themes = listThemes();
|
|
36
|
-
const themeStr = await ask(` Theme [${themes.join("/")}] (tokyo-night): `);
|
|
37
|
-
|
|
38
|
-
const config: Config = {
|
|
39
|
-
defaultSort: (["best-match", "stars", "updated", "forks"].includes(sortStr)
|
|
40
|
-
? sortStr
|
|
41
|
-
: "best-match") as SortStrategy,
|
|
42
|
-
defaultLimit: Math.min(100, Math.max(10, parseInt(limitStr) || 50)),
|
|
43
|
-
theme: themes.includes(themeStr) ? themeStr : "tokyo-night",
|
|
44
|
-
cacheTtlSeconds: 300,
|
|
45
|
-
defaultTab: "search",
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
if (token) config.githubToken = token;
|
|
49
|
-
|
|
50
|
-
saveConfig(config);
|
|
51
|
-
const { configPath } = await import("./config.ts");
|
|
52
|
-
console.log(` ✓ Config saved to ${configPath()}`);
|
|
53
|
-
console.log(" Run 'ghfind' to start browsing!");
|
|
54
|
-
console.log("");
|
|
55
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* `ghfind init` wizard — interactive one-time config setup.
|
|
3
|
+
*
|
|
4
|
+
* ponytail: simple stdin prompts, no curses/readline UI.
|
|
5
|
+
*/
|
|
6
|
+
import { createInterface } from "readline";
|
|
7
|
+
import { saveConfig } from "./config";
|
|
8
|
+
import { listThemes } from "./themes";
|
|
9
|
+
import type { Config, SortStrategy } from "./types";
|
|
10
|
+
|
|
11
|
+
function ask(query: string): Promise<string> {
|
|
12
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
rl.question(query, (answer) => {
|
|
15
|
+
rl.close();
|
|
16
|
+
resolve(answer.trim());
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function runInitWizard(): Promise<void> {
|
|
22
|
+
console.log("");
|
|
23
|
+
console.log(" ghfind setup wizard");
|
|
24
|
+
console.log(" ──────────────────────");
|
|
25
|
+
console.log("");
|
|
26
|
+
|
|
27
|
+
const token = await ask(" GitHub token (optional, press Enter to skip): ");
|
|
28
|
+
if (token && !token.startsWith("ghp_") && !token.startsWith("github_pat_") && !token.startsWith("gho_")) {
|
|
29
|
+
console.log(" ⚠ Token doesn't look like a GitHub token (should start with ghp_, gho_, or github_pat_)");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const sortStr = await ask(" Default sort [best-match/stars/updated/forks] (best-match): ");
|
|
33
|
+
const limitStr = await ask(" Default result limit [10-100] (50): ");
|
|
34
|
+
|
|
35
|
+
const themes = listThemes();
|
|
36
|
+
const themeStr = await ask(` Theme [${themes.join("/")}] (tokyo-night): `);
|
|
37
|
+
|
|
38
|
+
const config: Config = {
|
|
39
|
+
defaultSort: (["best-match", "stars", "updated", "forks"].includes(sortStr)
|
|
40
|
+
? sortStr
|
|
41
|
+
: "best-match") as SortStrategy,
|
|
42
|
+
defaultLimit: Math.min(100, Math.max(10, parseInt(limitStr) || 50)),
|
|
43
|
+
theme: themes.includes(themeStr) ? themeStr : "tokyo-night",
|
|
44
|
+
cacheTtlSeconds: 300,
|
|
45
|
+
defaultTab: "search",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
if (token) config.githubToken = token;
|
|
49
|
+
|
|
50
|
+
saveConfig(config);
|
|
51
|
+
const { configPath } = await import("./config.ts");
|
|
52
|
+
console.log(` ✓ Config saved to ${configPath()}`);
|
|
53
|
+
console.log(" Run 'ghfind' to start browsing!");
|
|
54
|
+
console.log("");
|
|
55
|
+
}
|