github-search-cli 9.6.0 → 9.7.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/CHANGELOG.md +71 -0
- package/README.md +32 -26
- package/bin/ghfind.js +5 -5
- package/completions/ghfind.bash +5 -1
- package/completions/ghfind.fish +2 -1
- package/completions/ghfind.zsh +3 -0
- package/dist/cli.js +617 -258
- package/package.json +3 -2
- package/release-notes/9.7.0.md +53 -0
- package/release-notes/9.7.1.md +62 -0
- package/scripts/build.ts +1 -1
- package/scripts/postinstall.js +8 -8
- package/src/agent-skill.ts +7 -6
- package/src/cli.ts +139 -16
- package/src/compare.ts +81 -6
- package/src/config.ts +3 -3
- package/src/deepdive.ts +195 -24
- package/src/doctor.ts +144 -8
- package/src/error-report.ts +14 -8
- package/src/export.ts +1 -1
- package/src/history.ts +5 -5
- package/src/init.ts +2 -2
- package/src/login.ts +4 -4
- package/src/markdown-render.ts +13 -13
- package/src/mcp-server.ts +30 -58
- package/src/open-url.ts +16 -5
- package/src/output.ts +1 -1
- package/src/package-browser.ts +1 -1
- package/src/releases.ts +1 -14
- package/src/search-state.ts +1 -4
- package/src/search.ts +22 -4
- package/src/share.ts +2 -2
- package/src/storage.ts +4 -4
- package/src/themes.ts +1 -0
- package/src/trending-parser.ts +3 -4
- package/src/trending.ts +7 -7
- package/src/tui.ts +38 -42
- package/src/update-check.ts +96 -34
- package/src/version.ts +3 -3
- package/src/watch.ts +1 -1
package/src/mcp-server.ts
CHANGED
|
@@ -27,8 +27,7 @@ import {
|
|
|
27
27
|
rankRepos,
|
|
28
28
|
createGitHubSearch,
|
|
29
29
|
createTrendingSearch,
|
|
30
|
-
|
|
31
|
-
trendingLanguageSlug,
|
|
30
|
+
resolveTrendingLanguage,
|
|
32
31
|
} from "./search";
|
|
33
32
|
import {
|
|
34
33
|
createPackageSearch,
|
|
@@ -37,7 +36,11 @@ import {
|
|
|
37
36
|
} from "./package";
|
|
38
37
|
import { fetchOrgProfile, formatOrgText } from "./org";
|
|
39
38
|
import { fetchUserProfile, formatUserJson } from "./user";
|
|
40
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
fetchDeepDive,
|
|
41
|
+
buildDeepDiveText,
|
|
42
|
+
resolveRepoFromRef,
|
|
43
|
+
} from "./deepdive";
|
|
41
44
|
import { buildComparisonTable } from "./compare";
|
|
42
45
|
import { allCachedReleases } from "./releases";
|
|
43
46
|
import { getBookmarks } from "./bookmarks";
|
|
@@ -199,45 +202,9 @@ function formatPackageLines(
|
|
|
199
202
|
.join("\n");
|
|
200
203
|
}
|
|
201
204
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const fullName = input
|
|
206
|
-
.trim()
|
|
207
|
-
.replace(/\.git$/, "")
|
|
208
|
-
.replace(/^@/, "");
|
|
209
|
-
if (!REPO_NAME_RE.test(fullName)) {
|
|
210
|
-
throw new McpToolError(
|
|
211
|
-
`"${input}" is not a repo name — use owner/name, e.g. facebook/react.`,
|
|
212
|
-
);
|
|
213
|
-
}
|
|
214
|
-
const [owner, name] = fullName.split("/");
|
|
215
|
-
return { owner, name };
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/** Minimal Repo for deep-dive (fetchDeepDive only reads owner/name). */
|
|
219
|
-
function stubRepo(fullName: string): Repo {
|
|
220
|
-
const { owner, name } = parseRepoName(fullName);
|
|
221
|
-
return {
|
|
222
|
-
id: 0,
|
|
223
|
-
fullName: `${owner}/${name}`,
|
|
224
|
-
name,
|
|
225
|
-
owner,
|
|
226
|
-
description: null,
|
|
227
|
-
url: `https://github.com/${owner}/${name}`,
|
|
228
|
-
stars: 0,
|
|
229
|
-
forks: 0,
|
|
230
|
-
watchers: 0,
|
|
231
|
-
language: null,
|
|
232
|
-
topics: [],
|
|
233
|
-
archived: false,
|
|
234
|
-
isFork: false,
|
|
235
|
-
private: false,
|
|
236
|
-
createdAt: "",
|
|
237
|
-
updatedAt: "",
|
|
238
|
-
pushedAt: "",
|
|
239
|
-
score: 0,
|
|
240
|
-
};
|
|
205
|
+
/** Wrap a domain error as McpToolError so its message surfaces verbatim. */
|
|
206
|
+
function asMcpToolError(err: unknown): McpToolError {
|
|
207
|
+
return new McpToolError(err instanceof Error ? err.message : String(err));
|
|
241
208
|
}
|
|
242
209
|
|
|
243
210
|
const TRENDING_QUERY = { keywords: [], qualifiers: [], raw: "trending" };
|
|
@@ -366,16 +333,12 @@ const TOOLS: ToolDefinition[] = [
|
|
|
366
333
|
const token = resolveToken(state, args.token);
|
|
367
334
|
|
|
368
335
|
let language: string | undefined;
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
`"${langArg}" is not a trending language filter. Try one of: rust, python, typescript, javascript, go, zig.`,
|
|
376
|
-
);
|
|
377
|
-
}
|
|
378
|
-
language = slug;
|
|
336
|
+
try {
|
|
337
|
+
language = resolveTrendingLanguage(
|
|
338
|
+
typeof args.language === "string" ? args.language : undefined,
|
|
339
|
+
);
|
|
340
|
+
} catch (err) {
|
|
341
|
+
throw asMcpToolError(err);
|
|
379
342
|
}
|
|
380
343
|
|
|
381
344
|
const trending = createTrendingSearch();
|
|
@@ -582,7 +545,12 @@ const TOOLS: ToolDefinition[] = [
|
|
|
582
545
|
async run(args, state) {
|
|
583
546
|
const repo = String(args.repo ?? "").trim();
|
|
584
547
|
if (!repo) throw new McpToolError("repo must be a non-empty string.");
|
|
585
|
-
|
|
548
|
+
let stub: Repo;
|
|
549
|
+
try {
|
|
550
|
+
stub = await resolveRepoFromRef(repo, resolveToken(state, args.token));
|
|
551
|
+
} catch (err) {
|
|
552
|
+
throw asMcpToolError(err);
|
|
553
|
+
}
|
|
586
554
|
const data = await fetchDeepDive(stub, resolveToken(state, args.token));
|
|
587
555
|
return { text: buildDeepDiveText(data), data: { ...data } };
|
|
588
556
|
},
|
|
@@ -720,8 +688,11 @@ export async function processServerLine(
|
|
|
720
688
|
return;
|
|
721
689
|
}
|
|
722
690
|
|
|
723
|
-
|
|
724
|
-
|
|
691
|
+
// Extract the id into a const so TS narrows `undefined` out of it below
|
|
692
|
+
// (aliased conditional narrowing doesn't apply through the `let msg` bind).
|
|
693
|
+
const rawId = msg.id;
|
|
694
|
+
const isNotification = rawId === undefined || rawId === null;
|
|
695
|
+
const id: JsonRpcId = isNotification ? null : rawId;
|
|
725
696
|
|
|
726
697
|
// Notifications never get responses.
|
|
727
698
|
if (msg.method === "notifications/initialized") {
|
|
@@ -830,7 +801,7 @@ export async function processServerLine(
|
|
|
830
801
|
// ─── stdio transport ───────────────────────────────────────────────────
|
|
831
802
|
|
|
832
803
|
function writeStdout(line: string): void {
|
|
833
|
-
process.stdout.write(line
|
|
804
|
+
process.stdout.write(`${line}\n`);
|
|
834
805
|
}
|
|
835
806
|
|
|
836
807
|
/**
|
|
@@ -860,8 +831,9 @@ export async function runMcpServer(
|
|
|
860
831
|
let buffer = "";
|
|
861
832
|
process.stdin.on("data", (chunk: string) => {
|
|
862
833
|
buffer += chunk;
|
|
863
|
-
|
|
864
|
-
|
|
834
|
+
for (;;) {
|
|
835
|
+
const idx = buffer.indexOf("\n");
|
|
836
|
+
if (idx < 0) break;
|
|
865
837
|
const line = buffer.slice(0, idx).trim();
|
|
866
838
|
buffer = buffer.slice(idx + 1);
|
|
867
839
|
if (!line) continue;
|
package/src/open-url.ts
CHANGED
|
@@ -1,15 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Open a URL in the user's default browser — platform-aware.
|
|
2
|
+
* Open a URL in the user's default browser — platform- and runtime-aware.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Windows note: `explorer.exe` is used instead of the classic
|
|
5
|
+
* `cmd /c start "" <url>` idiom. cmd splits the URL at the first unquoted
|
|
6
|
+
* `&` (a query-string separator) and executes the rest as a command, which
|
|
7
|
+
* truncated every URL with query params. explorer.exe receives the URL as a
|
|
8
|
+
* single CreateProcess argument, so `&` is never interpreted.
|
|
5
9
|
*/
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
6
11
|
|
|
7
12
|
export function openUrl(url: string): void {
|
|
8
13
|
try {
|
|
9
14
|
const p = process.platform;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
15
|
+
const cmd =
|
|
16
|
+
p === "win32" ? "explorer" : p === "darwin" ? "open" : "xdg-open";
|
|
17
|
+
if (typeof Bun !== "undefined") {
|
|
18
|
+
Bun.spawn([cmd, url]);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
// Node fallback — detached so the child survives our exit.
|
|
22
|
+
const child = spawn(cmd, [url], { detached: true, stdio: "ignore" });
|
|
23
|
+
child.unref();
|
|
13
24
|
} catch {
|
|
14
25
|
// non-critical — user can copy the URL manually
|
|
15
26
|
}
|
package/src/output.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* clone-commands, ids) register in a single registry. One `format()` function
|
|
6
6
|
* dispatches. `exportToFile` and `pipeExec` are methods on the same module.
|
|
7
7
|
*/
|
|
8
|
-
import { writeFileSync } from "fs";
|
|
8
|
+
import { writeFileSync } from "node:fs";
|
|
9
9
|
import type { Repo } from "./types";
|
|
10
10
|
import { openUrl } from "./open-url";
|
|
11
11
|
|
package/src/package-browser.ts
CHANGED
|
@@ -49,7 +49,7 @@ function formatPackageLine(pack: Package): StyledText {
|
|
|
49
49
|
const scoreStr = `score ${pack.score.toFixed(2)}`.padEnd(12).slice(0, 12);
|
|
50
50
|
const desc =
|
|
51
51
|
(pack.description ?? "").length > 60
|
|
52
|
-
? (pack.description ?? "").slice(0, 57)
|
|
52
|
+
? `${(pack.description ?? "").slice(0, 57)}...`
|
|
53
53
|
: (pack.description ?? "");
|
|
54
54
|
const line1 = t`${bold(fg(C.nameText)(nameStr))}${fg(C.cyan)(dlStr)}${fg(C.gold)(scoreStr)}`;
|
|
55
55
|
const line2 = t`${dim(fg(C.descText)(` ${desc}`))}`;
|
package/src/releases.ts
CHANGED
|
@@ -1,16 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Release tracker — checks bookmarked repos for new GitHub releases.
|
|
3
|
-
*
|
|
4
|
-
* Decisions (map 003):
|
|
5
|
-
* - Signal: new GitHub releases only (`GET /repos/{o}/{r}/releases?per_page=5`).
|
|
6
|
-
* - Seen state: `lastSeenAt` per bookmark; "new" = published after it.
|
|
7
|
-
* - Rate budget: etag conditional requests + token when present.
|
|
8
|
-
* - Prereleases shown (tagged `[pre]`), drafts never.
|
|
9
|
-
*
|
|
10
|
-
* ponytail: sequential fetches — fine for ≤100 bookmarks at launch;
|
|
11
|
-
* parallelize with p-limit if users report slow startup.
|
|
12
|
-
*/
|
|
13
|
-
import type { Bookmark } from "./types";
|
|
14
1
|
import { readJSON, writeJSON } from "./storage";
|
|
15
2
|
import { getBookmarks, getLastSeen, markSeen } from "./bookmarks";
|
|
16
3
|
import { addNotification } from "./notifications";
|
|
@@ -68,7 +55,7 @@ async function fetchReleases(
|
|
|
68
55
|
Accept: "application/vnd.github+json",
|
|
69
56
|
};
|
|
70
57
|
if (etag) headers["If-None-Match"] = etag;
|
|
71
|
-
if (token) headers
|
|
58
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
72
59
|
const res = await fetch(
|
|
73
60
|
`https://api.github.com/repos/${fullName}/releases?per_page=${PER_REPO}`,
|
|
74
61
|
{ headers },
|
package/src/search-state.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* The TUI view layer reads from SearchState and dispatches actions.
|
|
5
5
|
* This module is testable without the OpenTUI renderer.
|
|
6
6
|
*/
|
|
7
|
-
import type { Repo,
|
|
7
|
+
import type { Repo, SortStrategy, SessionState } from "./types";
|
|
8
8
|
import type { TabName } from "./trending";
|
|
9
9
|
import { tabSince } from "./trending";
|
|
10
10
|
import {
|
|
@@ -24,7 +24,6 @@ import {
|
|
|
24
24
|
touchSavedSearch,
|
|
25
25
|
} from "./saved-searches";
|
|
26
26
|
import { fetchDeepDive, buildDeepDiveText } from "./deepdive";
|
|
27
|
-
import { fetchTopics } from "./explore";
|
|
28
27
|
import { loadConfig } from "./config";
|
|
29
28
|
|
|
30
29
|
export type Overlay =
|
|
@@ -131,8 +130,6 @@ export class SearchStateManager {
|
|
|
131
130
|
timestamp: Date.now(),
|
|
132
131
|
resultCount: response.repos.length,
|
|
133
132
|
});
|
|
134
|
-
} catch (err) {
|
|
135
|
-
throw err;
|
|
136
133
|
} finally {
|
|
137
134
|
this.state.isLoading = false;
|
|
138
135
|
}
|
package/src/search.ts
CHANGED
|
@@ -181,7 +181,7 @@ export function suggestFor(input: string): string[] {
|
|
|
181
181
|
return QUALIFIER_SUGGESTIONS[trimmed];
|
|
182
182
|
}
|
|
183
183
|
const matches = KNOWN_QUALIFIERS.filter((k) => k.startsWith(trimmed));
|
|
184
|
-
return matches.map((k) => k
|
|
184
|
+
return matches.map((k) => `${k}:`);
|
|
185
185
|
}
|
|
186
186
|
|
|
187
187
|
export function tokenize(input: string): string[] {
|
|
@@ -329,7 +329,6 @@ export function githubSortParam(sort: SortStrategy): {
|
|
|
329
329
|
return { sort: "forks", order: "desc" };
|
|
330
330
|
case "updated":
|
|
331
331
|
return { sort: "updated", order: "desc" };
|
|
332
|
-
case "best-match":
|
|
333
332
|
default:
|
|
334
333
|
return {};
|
|
335
334
|
}
|
|
@@ -359,7 +358,6 @@ export function rankRepos(repos: Repo[], strategy: SortStrategy): Repo[] {
|
|
|
359
358
|
case "updated":
|
|
360
359
|
copy.sort(byKey((r) => Date.parse(r.updatedAt)));
|
|
361
360
|
break;
|
|
362
|
-
case "best-match":
|
|
363
361
|
default:
|
|
364
362
|
break;
|
|
365
363
|
}
|
|
@@ -473,7 +471,7 @@ export class GitHubSearchAdapter implements SearchAdapter {
|
|
|
473
471
|
if (
|
|
474
472
|
!parsed ||
|
|
475
473
|
typeof parsed !== "object" ||
|
|
476
|
-
!Array.isArray((parsed as
|
|
474
|
+
!Array.isArray((parsed as { items?: unknown }).items)
|
|
477
475
|
) {
|
|
478
476
|
this.logger.error(
|
|
479
477
|
`[github] unexpected response shape: ${JSON.stringify(parsed).slice(0, 200)}`,
|
|
@@ -570,6 +568,25 @@ export function trendingLanguageSlug(language: string): string {
|
|
|
570
568
|
return language.trim().toLowerCase().replace(/\s+/g, "-");
|
|
571
569
|
}
|
|
572
570
|
|
|
571
|
+
/**
|
|
572
|
+
* Validate a user-supplied trending language and return its slug, or null if
|
|
573
|
+
* empty. Throws with a user-facing suggestion list for unknown languages.
|
|
574
|
+
* Shared by the MCP trending tool and the CLI trending filter.
|
|
575
|
+
*/
|
|
576
|
+
export function resolveTrendingLanguage(
|
|
577
|
+
language: string | undefined | null,
|
|
578
|
+
): string | undefined {
|
|
579
|
+
const trimmed = typeof language === "string" ? language.trim() : "";
|
|
580
|
+
if (!trimmed) return undefined;
|
|
581
|
+
const slug = trendingLanguageSlug(trimmed);
|
|
582
|
+
if (!TRENDING_LANGUAGES.has(slug)) {
|
|
583
|
+
throw new Error(
|
|
584
|
+
`"${trimmed}" is not a trending language filter. Try one of: rust, python, typescript, javascript, go, zig.`,
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
return slug;
|
|
588
|
+
}
|
|
589
|
+
|
|
573
590
|
/** Build the github.com/trending URL for a period + optional language filter. */
|
|
574
591
|
export function buildTrendingUrl(
|
|
575
592
|
since: "daily" | "weekly" | "monthly",
|
|
@@ -611,6 +628,7 @@ function trendingRepoToRepo(r: RawTrendingRepo): Repo {
|
|
|
611
628
|
*/
|
|
612
629
|
export class TrendingAdapter implements SearchAdapter {
|
|
613
630
|
readonly name = "trending";
|
|
631
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: part of the adapter constructor contract — callers pass a shared logger (search.ts:757) even though this adapter currently logs nothing itself.
|
|
614
632
|
private readonly logger: Logger;
|
|
615
633
|
|
|
616
634
|
constructor(logger: Logger = noopLogger) {
|
package/src/share.ts
CHANGED
|
@@ -19,7 +19,7 @@ export function formatShare(repo: Repo, format: ShareFormat): string {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
/** Copy text to clipboard via platform command. */
|
|
22
|
-
import { spawn } from "child_process";
|
|
22
|
+
import { spawn } from "node:child_process";
|
|
23
23
|
|
|
24
24
|
export async function copyToClipboard(text: string): Promise<boolean> {
|
|
25
25
|
try {
|
|
@@ -46,7 +46,7 @@ export async function copyToClipboard(text: string): Promise<boolean> {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
async function findLinuxClipboard(): Promise<string[] | null> {
|
|
49
|
-
const { execFileSync } = await import("child_process");
|
|
49
|
+
const { execFileSync } = await import("node:child_process");
|
|
50
50
|
try {
|
|
51
51
|
execFileSync("xclip", ["-version"], { stdio: "ignore" });
|
|
52
52
|
return ["xclip", "-selection", "clipboard"];
|
package/src/storage.ts
CHANGED
|
@@ -12,8 +12,8 @@ import {
|
|
|
12
12
|
writeFileSync,
|
|
13
13
|
appendFileSync,
|
|
14
14
|
unlinkSync,
|
|
15
|
-
} from "fs";
|
|
16
|
-
import { join } from "path";
|
|
15
|
+
} from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
17
|
import { stateDir } from "./config";
|
|
18
18
|
|
|
19
19
|
/** Ensure the state directory exists. */
|
|
@@ -43,7 +43,7 @@ export function appendJSONL(filename: string, obj: unknown): void {
|
|
|
43
43
|
try {
|
|
44
44
|
appendFileSync(
|
|
45
45
|
join(stateDir(), filename),
|
|
46
|
-
JSON.stringify(obj)
|
|
46
|
+
`${JSON.stringify(obj)}\n`,
|
|
47
47
|
"utf-8",
|
|
48
48
|
);
|
|
49
49
|
} catch {
|
|
@@ -119,7 +119,7 @@ export const memoryStorage = {
|
|
|
119
119
|
},
|
|
120
120
|
appendJSONL(filename: string, obj: unknown): void {
|
|
121
121
|
const existing = memStore.get(filename) ?? "";
|
|
122
|
-
memStore.set(filename, existing + JSON.stringify(obj)
|
|
122
|
+
memStore.set(filename, `${existing + JSON.stringify(obj)}\n`);
|
|
123
123
|
},
|
|
124
124
|
readJSONL<T>(filename: string): T[] {
|
|
125
125
|
const raw = memStore.get(filename);
|
package/src/themes.ts
CHANGED
|
@@ -242,6 +242,7 @@ export async function detectTerminalBackground(
|
|
|
242
242
|
// Reply: ESC ] 11 ; <spec> (BEL | ESC \)
|
|
243
243
|
const onData = (d: Buffer) => {
|
|
244
244
|
buffer += d.toString("latin1");
|
|
245
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: matches raw ANSI OSC-11 escape bytes, which are control characters by definition
|
|
245
246
|
const m = buffer.match(/\x1b\]11;([^\x07\x1b]*)(?:\x07|\x1b\\)/);
|
|
246
247
|
if (m) finish(parseOscColor(m[1]));
|
|
247
248
|
};
|
package/src/trending-parser.ts
CHANGED
|
@@ -35,7 +35,7 @@ function parseRepoLink(block: string): RepoLink | undefined {
|
|
|
35
35
|
// `/login?...&return_to=...` href fails the bare-path requirement and the
|
|
36
36
|
// stargazers/forks links don't carry the heading's class="Link".
|
|
37
37
|
const anchor = block.match(
|
|
38
|
-
/<a[^>]*?href="\/([A-Za-z0-9._-]+)\/([
|
|
38
|
+
/<a[^>]*?href="\/([A-Za-z0-9._-]+)\/([^"?#]+)"[^>]*class="Link"/,
|
|
39
39
|
);
|
|
40
40
|
if (!anchor) return undefined;
|
|
41
41
|
return { owner: anchor[1], name: anchor[2] };
|
|
@@ -43,7 +43,7 @@ function parseRepoLink(block: string): RepoLink | undefined {
|
|
|
43
43
|
|
|
44
44
|
function parseStars(block: string): number {
|
|
45
45
|
const starsMatch = block.match(
|
|
46
|
-
/href="\/[
|
|
46
|
+
/href="\/[^/"]+\/[^/"]+\/stargazers"[^>]*>.*?<\/svg>\s*(\d[\d,]*)/,
|
|
47
47
|
);
|
|
48
48
|
return starsMatch ? parseInt(starsMatch[1].replace(/,/g, ""), 10) : 0;
|
|
49
49
|
}
|
|
@@ -69,10 +69,9 @@ export function parseTrendingHtml(html: string): RawTrendingRepo[] {
|
|
|
69
69
|
const flat = flattenHtml(html);
|
|
70
70
|
const repos: RawTrendingRepo[] = [];
|
|
71
71
|
const articleRe = /<article class="Box-row">(.*?)<\/article>/gi;
|
|
72
|
-
let match: RegExpExecArray | null;
|
|
73
72
|
let rank = 0;
|
|
74
73
|
|
|
75
|
-
|
|
74
|
+
for (const match of flat.matchAll(articleRe)) {
|
|
76
75
|
rank++;
|
|
77
76
|
const block = match[1];
|
|
78
77
|
const link = parseRepoLink(block);
|
package/src/trending.ts
CHANGED
|
@@ -118,13 +118,13 @@ export function formatRepoLine(repo: Repo, rank: number): StyledText {
|
|
|
118
118
|
const rankStr = String(rank).padStart(2, "0");
|
|
119
119
|
const full = `${repo.owner}/${repo.name}`;
|
|
120
120
|
const lc = langColor(repo.language ?? "");
|
|
121
|
-
const
|
|
121
|
+
const _rc = rankColor(rank);
|
|
122
122
|
const star = "★";
|
|
123
123
|
const arrow = repo.score > 0 ? "▲" : repo.score < 0 ? "▼" : "—";
|
|
124
124
|
const growth = `${arrow} ${fmtSigned(repo.score)} today`;
|
|
125
125
|
const desc =
|
|
126
126
|
(repo.description ?? "").length > 60
|
|
127
|
-
? (repo.description ?? "").slice(0, 57)
|
|
127
|
+
? `${(repo.description ?? "").slice(0, 57)}...`
|
|
128
128
|
: (repo.description ?? "");
|
|
129
129
|
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))}`;
|
|
130
130
|
const line2 = t` ${dim(fg(C.muted)(desc))}`;
|
|
@@ -182,7 +182,7 @@ export async function launchTrending(): Promise<void> {
|
|
|
182
182
|
let currentPeriod = "this week";
|
|
183
183
|
let repos: Repo[] = [];
|
|
184
184
|
let selectedRepoIdx = 0;
|
|
185
|
-
let
|
|
185
|
+
let _isLoading = false;
|
|
186
186
|
// ── Fetch from github.com/trending ──
|
|
187
187
|
async function loadTab(index: number) {
|
|
188
188
|
if (index < 0 || index >= TAB_NAMES.length) return;
|
|
@@ -194,7 +194,7 @@ export async function launchTrending(): Promise<void> {
|
|
|
194
194
|
: since === "weekly"
|
|
195
195
|
? "this week"
|
|
196
196
|
: "this month";
|
|
197
|
-
|
|
197
|
+
_isLoading = true;
|
|
198
198
|
for (const rb of rowBoxes) scrollBox.remove(rb);
|
|
199
199
|
rowBoxes.length = 0;
|
|
200
200
|
rowTexts.length = 0;
|
|
@@ -233,7 +233,7 @@ export async function launchTrending(): Promise<void> {
|
|
|
233
233
|
});
|
|
234
234
|
scrollBox.add(errText);
|
|
235
235
|
}
|
|
236
|
-
|
|
236
|
+
_isLoading = false;
|
|
237
237
|
renderTabBar();
|
|
238
238
|
renderer.requestRender();
|
|
239
239
|
}
|
|
@@ -359,7 +359,7 @@ export async function launchTrending(): Promise<void> {
|
|
|
359
359
|
const lc = langColor(r.language ?? "");
|
|
360
360
|
const desc =
|
|
361
361
|
(r.description ?? "").length > 55
|
|
362
|
-
? (r.description ?? "").slice(0, 52)
|
|
362
|
+
? `${(r.description ?? "").slice(0, 52)}...`
|
|
363
363
|
: (r.description ?? "");
|
|
364
364
|
const arrow = r.score > 0 ? "▲" : r.score < 0 ? "▼" : "—";
|
|
365
365
|
const growthStr = `${arrow} ${fmtStars(Math.abs(r.score))} ${currentPeriod}`;
|
|
@@ -373,7 +373,7 @@ export async function launchTrending(): Promise<void> {
|
|
|
373
373
|
fg: rc,
|
|
374
374
|
height: 1,
|
|
375
375
|
});
|
|
376
|
-
const descStr = desc.length > 60 ? desc.slice(0, 57)
|
|
376
|
+
const descStr = desc.length > 60 ? `${desc.slice(0, 57)}...` : desc;
|
|
377
377
|
const pad = " ";
|
|
378
378
|
const line1 = t`${bold(fg("#c0caf5")(nameStr))}${fg(lc)(langStr)}${fg(C.muted)(starsStr)}${bold(fg(C.green)(growthStr))}`;
|
|
379
379
|
const line2 = t`${fg(C.descText)(`${pad}${descStr}`)}`;
|