github-search-cli 0.7.0 → 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/README.md +66 -104
- package/bin/ghfind.js +14 -0
- package/completions/{search-cli.bash → ghfind.bash} +4 -4
- package/completions/ghfind.fish +19 -0
- package/completions/{search-cli.zsh → ghfind.zsh} +27 -27
- package/package.json +17 -9
- package/src/bookmarks.ts +69 -69
- package/src/cli.ts +239 -252
- package/src/compare.ts +46 -46
- package/src/config.ts +65 -65
- package/src/deepdive.ts +118 -124
- package/src/errors.ts +59 -62
- package/src/explore.ts +29 -30
- package/src/export.ts +63 -64
- package/src/help.ts +22 -30
- package/src/history.ts +91 -91
- package/src/init.ts +55 -55
- package/src/notifications.ts +65 -69
- package/src/open-url.ts +0 -1
- package/src/output.ts +67 -74
- package/src/pipe.ts +54 -60
- package/src/saved-searches.ts +58 -58
- package/src/search.ts +555 -0
- package/src/session.ts +36 -37
- package/src/share.ts +54 -49
- package/src/status.ts +61 -61
- package/src/themes.ts +52 -52
- package/src/tips.ts +27 -27
- package/src/trending.ts +556 -563
- package/src/tui.ts +1665 -1520
- package/src/types.ts +139 -147
- package/src/watch.ts +62 -65
- package/bin/search-cli.js +0 -17
- package/completions/search-cli.fish +0 -19
- package/src/cache.ts +0 -64
- package/src/normalizer.ts +0 -66
- package/src/provider.ts +0 -165
- package/src/query.ts +0 -249
- package/src/ranking.ts +0 -57
package/src/cli.ts
CHANGED
|
@@ -1,252 +1,239 @@
|
|
|
1
|
-
#!/usr/bin/env
|
|
2
|
-
/**
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
case "--
|
|
81
|
-
case "--
|
|
82
|
-
case "
|
|
83
|
-
case "--
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
return;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
//
|
|
155
|
-
if (flags.
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
console.log(
|
|
226
|
-
return;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
//
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// --json, --csv, --markdown
|
|
243
|
-
if (format) {
|
|
244
|
-
console.log(formatRepos(repos, format));
|
|
245
|
-
return;
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
main().catch((err) => {
|
|
250
|
-
console.error(err instanceof Error ? err.message : String(err));
|
|
251
|
-
process.exit(1);
|
|
252
|
-
});
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** CLI entry point — routes between TUI browser and non-interactive modes. */
|
|
3
|
+
import { parseQuery, applyFlagFilters, rankRepos, createGitHubSearch } from "./search";
|
|
4
|
+
import { formatRepos, type ExportFormat } from "./export";
|
|
5
|
+
import { formatLines, pipeExec, type FormatLine } from "./pipe";
|
|
6
|
+
import { runWatch } from "./watch";
|
|
7
|
+
import { runInitWizard } from "./init";
|
|
8
|
+
import { readFileSync } from "fs";
|
|
9
|
+
import { join, dirname } from "path";
|
|
10
|
+
import { fileURLToPath } from "url";
|
|
11
|
+
import { launchBrowser } from "./tui";
|
|
12
|
+
import type { SearchOptions } from "./types";
|
|
13
|
+
|
|
14
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const PKG_DIR = join(__dirname, "..");
|
|
16
|
+
|
|
17
|
+
interface CLIFlags {
|
|
18
|
+
query: string;
|
|
19
|
+
json: boolean;
|
|
20
|
+
csv: boolean;
|
|
21
|
+
markdown: boolean;
|
|
22
|
+
count: boolean;
|
|
23
|
+
limit: number;
|
|
24
|
+
sort: SearchOptions["sort"];
|
|
25
|
+
token?: string;
|
|
26
|
+
trending: boolean;
|
|
27
|
+
since: string;
|
|
28
|
+
pipe?: string;
|
|
29
|
+
format?: string;
|
|
30
|
+
watch: boolean;
|
|
31
|
+
interval: number;
|
|
32
|
+
completion?: string;
|
|
33
|
+
init: boolean;
|
|
34
|
+
version: boolean;
|
|
35
|
+
help: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseArgs(args: string[]): CLIFlags {
|
|
39
|
+
const flags: CLIFlags = {
|
|
40
|
+
query: "",
|
|
41
|
+
json: false,
|
|
42
|
+
csv: false,
|
|
43
|
+
markdown: false,
|
|
44
|
+
count: false,
|
|
45
|
+
limit: 50,
|
|
46
|
+
sort: "best-match",
|
|
47
|
+
token: process.env.GITHUB_TOKEN,
|
|
48
|
+
trending: false,
|
|
49
|
+
since: "daily",
|
|
50
|
+
watch: false,
|
|
51
|
+
interval: 300,
|
|
52
|
+
init: false,
|
|
53
|
+
version: false,
|
|
54
|
+
help: false,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
let queryParts: string[] = [];
|
|
58
|
+
for (let i = 0; i < args.length; i++) {
|
|
59
|
+
const arg = args[i];
|
|
60
|
+
switch (arg) {
|
|
61
|
+
case "--json": flags.json = true; break;
|
|
62
|
+
case "--csv": flags.csv = true; break;
|
|
63
|
+
case "--markdown": flags.markdown = true; break;
|
|
64
|
+
case "--count": flags.count = true; break;
|
|
65
|
+
case "--trending": flags.trending = true; break;
|
|
66
|
+
case "--watch": flags.watch = true; break;
|
|
67
|
+
case "--version": case "-v": flags.version = true; break;
|
|
68
|
+
case "--help": case "-h": flags.help = true; break;
|
|
69
|
+
case "init": flags.init = true; break;
|
|
70
|
+
case "--limit": {
|
|
71
|
+
const parsed = parseInt(args[++i]);
|
|
72
|
+
if (isNaN(parsed) || parsed < 1 || parsed > 100) {
|
|
73
|
+
console.error(`Invalid limit: must be 1-100. Using default 50.`);
|
|
74
|
+
flags.limit = 50;
|
|
75
|
+
} else {
|
|
76
|
+
flags.limit = parsed;
|
|
77
|
+
}
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
case "--sort": flags.sort = args[++i] as SearchOptions["sort"]; break;
|
|
81
|
+
case "--token": flags.token = args[++i]; break;
|
|
82
|
+
case "--since": flags.since = args[++i]; break;
|
|
83
|
+
case "--pipe": flags.pipe = args[++i]; break;
|
|
84
|
+
case "--format": flags.format = args[++i]; break;
|
|
85
|
+
case "--interval": flags.interval = parseInt(args[++i]) || 300; break;
|
|
86
|
+
case "--completion": flags.completion = args[++i]; break;
|
|
87
|
+
default:
|
|
88
|
+
if (!arg.startsWith("-")) queryParts.push(arg);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
flags.query = queryParts.join(" ");
|
|
92
|
+
return flags;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function main() {
|
|
96
|
+
const flags = parseArgs(process.argv.slice(2));
|
|
97
|
+
|
|
98
|
+
// Version
|
|
99
|
+
if (flags.version) {
|
|
100
|
+
const pkg = JSON.parse(readFileSync(join(PKG_DIR, "package.json"), "utf-8"));
|
|
101
|
+
console.log(`ghfind v${pkg.version}`);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Help
|
|
106
|
+
if (flags.help) {
|
|
107
|
+
console.log(`
|
|
108
|
+
ghfind — Interactive GitHub repository browser
|
|
109
|
+
|
|
110
|
+
Usage:
|
|
111
|
+
ghfind Launch interactive TUI
|
|
112
|
+
ghfind <query> --json Search, output JSON
|
|
113
|
+
ghfind <query> --csv Search, output CSV
|
|
114
|
+
ghfind <query> --markdown Search, output Markdown table
|
|
115
|
+
ghfind <query> --count Just the result count
|
|
116
|
+
ghfind <query> --format <fmt> Format lines (urls|names|ssh-urls|clone-commands|ids)
|
|
117
|
+
ghfind <query> --pipe <target> Pipe to clone/open
|
|
118
|
+
ghfind --trending --json Trending repos as JSON
|
|
119
|
+
ghfind --watch <query> Watch mode (poll every Ns)
|
|
120
|
+
ghfind init Run setup wizard
|
|
121
|
+
ghfind --completion <shell> Print completion script (bash|zsh|fish)
|
|
122
|
+
ghfind --version Print version
|
|
123
|
+
ghfind --help Print this help
|
|
124
|
+
|
|
125
|
+
Options:
|
|
126
|
+
--json, --csv, --markdown, --count Output format
|
|
127
|
+
--limit <n> Max results (default: 50)
|
|
128
|
+
--sort <strategy> Sort: best-match|stars|updated|forks
|
|
129
|
+
--token <token> GitHub API token
|
|
130
|
+
--trending Trending mode
|
|
131
|
+
--since <period> Trending period: daily|weekly|monthly
|
|
132
|
+
--pipe <target> Pipe target: clone|open
|
|
133
|
+
--format <fmt> Line format: urls|names|ssh-urls|...
|
|
134
|
+
--watch Watch mode (re-run periodically)
|
|
135
|
+
--interval <s> Watch interval in seconds (default: 300)
|
|
136
|
+
--completion <shell> Generate completions
|
|
137
|
+
`);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Shell completions
|
|
142
|
+
if (flags.completion) {
|
|
143
|
+
const shell = flags.completion;
|
|
144
|
+
try {
|
|
145
|
+
const content = readFileSync(join(PKG_DIR, "completions", `ghfind.${shell}`), "utf-8");
|
|
146
|
+
console.log(content);
|
|
147
|
+
} catch {
|
|
148
|
+
console.error(`Completions not available for shell: ${shell}`);
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Init wizard
|
|
155
|
+
if (flags.init) {
|
|
156
|
+
await runInitWizard();
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Determine output format
|
|
161
|
+
const format: ExportFormat | undefined =
|
|
162
|
+
flags.json ? "json" : flags.csv ? "csv" : flags.markdown ? "markdown" : undefined;
|
|
163
|
+
const isNonInteractive = format || flags.count || flags.format || flags.pipe || flags.watch;
|
|
164
|
+
|
|
165
|
+
// Non-interactive mode
|
|
166
|
+
if (isNonInteractive) {
|
|
167
|
+
await runNonInteractive(flags, format);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Default: launch interactive TUI
|
|
172
|
+
await launchBrowser();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function runNonInteractive(flags: CLIFlags, format?: ExportFormat) {
|
|
176
|
+
const query = flags.query;
|
|
177
|
+
const provider = createGitHubSearch(undefined, flags.token ? [flags.token] : []);
|
|
178
|
+
|
|
179
|
+
// --watch mode
|
|
180
|
+
if (flags.watch) {
|
|
181
|
+
let tick = 0;
|
|
182
|
+
await runWatch(
|
|
183
|
+
{ query, sort: flags.sort, limit: flags.limit, token: flags.token, intervalMs: flags.interval * 1000, trending: flags.trending },
|
|
184
|
+
(repos, delta) => {
|
|
185
|
+
tick++;
|
|
186
|
+
if (format) {
|
|
187
|
+
console.log(`[Watch #${tick}] ${repos.length} results (${delta >= 0 ? "+" : ""}${delta} since last check)`);
|
|
188
|
+
console.log(formatRepos(repos, format));
|
|
189
|
+
} else {
|
|
190
|
+
console.log(`[Watch #${tick}] ${repos.length} results — ${delta >= 0 ? "+" : ""}${delta} since last check`);
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
(err) => console.error(`[Watch error] ${err.message}`),
|
|
194
|
+
);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// --pipe mode
|
|
199
|
+
if (flags.pipe) {
|
|
200
|
+
const parsed = applyFlagFilters(parseQuery(query), {});
|
|
201
|
+
const response = await provider.search(parsed, { limit: flags.limit, sort: flags.sort, json: false, verbose: false, token: flags.token });
|
|
202
|
+
const repos = rankRepos(response.repos, flags.sort);
|
|
203
|
+
await pipeExec(repos, flags.pipe);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// --format mode
|
|
208
|
+
if (flags.format) {
|
|
209
|
+
const parsed = applyFlagFilters(parseQuery(query), {});
|
|
210
|
+
const response = await provider.search(parsed, { limit: flags.limit, sort: flags.sort, json: false, verbose: false, token: flags.token });
|
|
211
|
+
const repos = rankRepos(response.repos, flags.sort);
|
|
212
|
+
console.log(formatLines(repos, flags.format as FormatLine));
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Standard search with format
|
|
217
|
+
const parsed = applyFlagFilters(parseQuery(query), {});
|
|
218
|
+
const response = await provider.search(parsed, {
|
|
219
|
+
limit: flags.limit, sort: flags.sort, json: false, verbose: false, token: flags.token,
|
|
220
|
+
});
|
|
221
|
+
const repos = rankRepos(response.repos, flags.sort);
|
|
222
|
+
|
|
223
|
+
// --count
|
|
224
|
+
if (flags.count) {
|
|
225
|
+
console.log(response.totalCount);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// --json, --csv, --markdown
|
|
230
|
+
if (format) {
|
|
231
|
+
console.log(formatRepos(repos, format));
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
main().catch((err) => {
|
|
237
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
238
|
+
process.exit(1);
|
|
239
|
+
});
|
package/src/compare.ts
CHANGED
|
@@ -1,46 +1,46 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Repo comparison — side-by-side table for 2+ repos.
|
|
3
|
-
*/
|
|
4
|
-
import type { Repo } from "./types
|
|
5
|
-
|
|
6
|
-
interface CompareRow {
|
|
7
|
-
label: string;
|
|
8
|
-
values: string[];
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
/** Build a comparison table as an array of text rows. */
|
|
12
|
-
export function buildComparisonTable(repos: Repo[]): string {
|
|
13
|
-
if (repos.length < 2) return "Select at least 2 repos to compare (press c to add).";
|
|
14
|
-
|
|
15
|
-
const rows: CompareRow[] = [];
|
|
16
|
-
const names = repos.map((r) => r.fullName);
|
|
17
|
-
|
|
18
|
-
rows.push({ label: "Stars", values: repos.map((r) => `★ ${r.stars.toLocaleString()}`) });
|
|
19
|
-
rows.push({ label: "Forks", values: repos.map((r) => r.forks.toLocaleString()) });
|
|
20
|
-
rows.push({ label: "Language", values: repos.map((r) => r.language ?? "—") });
|
|
21
|
-
rows.push({ label: "Created", values: repos.map((r) => r.createdAt?.slice(0, 7) ?? "—") });
|
|
22
|
-
rows.push({ label: "Updated", values: repos.map((r) => r.updatedAt?.slice(0, 10) ?? "—") });
|
|
23
|
-
rows.push({ label: "Topics", values: repos.map((r) => (r.topics?.slice(0, 3).join(", ") ?? "—") || "—") });
|
|
24
|
-
|
|
25
|
-
// Calculate the max width for each column
|
|
26
|
-
const colW = Math.max(15, ...names.map((n) => n.length));
|
|
27
|
-
const valW = Math.max(8, ...rows.flatMap((r) => r.values.map((v) => v.length)));
|
|
28
|
-
|
|
29
|
-
// Header row
|
|
30
|
-
const header = ["".padEnd(14), ...names.map((n) => n.padEnd(colW))];
|
|
31
|
-
const sep = ["".padEnd(14, "─"), ...names.map(() => "".padEnd(colW, "─"))];
|
|
32
|
-
|
|
33
|
-
const lines: string[] = [
|
|
34
|
-
"┌" + sep.map((s) => "─" + s + "─").join("┬") + "┐",
|
|
35
|
-
"│" + header.map((h) => ` ${h} `).join("│") + "│",
|
|
36
|
-
"├" + sep.map((s) => "─" + s + "─").join("┼") + "┤",
|
|
37
|
-
];
|
|
38
|
-
|
|
39
|
-
for (const row of rows) {
|
|
40
|
-
const cells = [row.label.padEnd(14), ...row.values.map((v) => v.padEnd(colW))];
|
|
41
|
-
lines.push("│" + cells.map((c) => ` ${c} `).join("│") + "│");
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
lines.push("└" + sep.map((s) => "─" + s + "─").join("┴") + "┘");
|
|
45
|
-
return lines.join("\n");
|
|
46
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Repo comparison — side-by-side table for 2+ repos.
|
|
3
|
+
*/
|
|
4
|
+
import type { Repo } from "./types";
|
|
5
|
+
|
|
6
|
+
interface CompareRow {
|
|
7
|
+
label: string;
|
|
8
|
+
values: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Build a comparison table as an array of text rows. */
|
|
12
|
+
export function buildComparisonTable(repos: Repo[]): string {
|
|
13
|
+
if (repos.length < 2) return "Select at least 2 repos to compare (press c to add).";
|
|
14
|
+
|
|
15
|
+
const rows: CompareRow[] = [];
|
|
16
|
+
const names = repos.map((r) => r.fullName);
|
|
17
|
+
|
|
18
|
+
rows.push({ label: "Stars", values: repos.map((r) => `★ ${r.stars.toLocaleString()}`) });
|
|
19
|
+
rows.push({ label: "Forks", values: repos.map((r) => r.forks.toLocaleString()) });
|
|
20
|
+
rows.push({ label: "Language", values: repos.map((r) => r.language ?? "—") });
|
|
21
|
+
rows.push({ label: "Created", values: repos.map((r) => r.createdAt?.slice(0, 7) ?? "—") });
|
|
22
|
+
rows.push({ label: "Updated", values: repos.map((r) => r.updatedAt?.slice(0, 10) ?? "—") });
|
|
23
|
+
rows.push({ label: "Topics", values: repos.map((r) => (r.topics?.slice(0, 3).join(", ") ?? "—") || "—") });
|
|
24
|
+
|
|
25
|
+
// Calculate the max width for each column
|
|
26
|
+
const colW = Math.max(15, ...names.map((n) => n.length));
|
|
27
|
+
const valW = Math.max(8, ...rows.flatMap((r) => r.values.map((v) => v.length)));
|
|
28
|
+
|
|
29
|
+
// Header row
|
|
30
|
+
const header = ["".padEnd(14), ...names.map((n) => n.padEnd(colW))];
|
|
31
|
+
const sep = ["".padEnd(14, "─"), ...names.map(() => "".padEnd(colW, "─"))];
|
|
32
|
+
|
|
33
|
+
const lines: string[] = [
|
|
34
|
+
"┌" + sep.map((s) => "─" + s + "─").join("┬") + "┐",
|
|
35
|
+
"│" + header.map((h) => ` ${h} `).join("│") + "│",
|
|
36
|
+
"├" + sep.map((s) => "─" + s + "─").join("┼") + "┤",
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
for (const row of rows) {
|
|
40
|
+
const cells = [row.label.padEnd(14), ...row.values.map((v) => v.padEnd(colW))];
|
|
41
|
+
lines.push("│" + cells.map((c) => ` ${c} `).join("│") + "│");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
lines.push("└" + sep.map((s) => "─" + s + "─").join("┴") + "┘");
|
|
45
|
+
return lines.join("\n");
|
|
46
|
+
}
|