github-search-cli 9.3.0 → 9.4.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/CHANGELOG.md +192 -0
- package/README.md +28 -52
- package/bin/ghfind.js +55 -11
- package/dist/cli.js +787 -145
- package/package.json +3 -1
- package/release-notes/9.3.0.md +29 -0
- package/scripts/postinstall.js +82 -15
- package/src/bookmarks.ts +13 -0
- package/src/cli.ts +240 -51
- package/src/doctor.ts +287 -0
- package/src/error-report.ts +157 -0
- package/src/help.ts +17 -2
- package/src/landing.ts +104 -0
- package/src/notifications.ts +2 -1
- package/src/releases.ts +144 -0
- package/src/tips.ts +22 -7
- package/src/toast.ts +69 -0
- package/src/tui.ts +38 -1
- package/src/types.ts +3 -1
- package/src/update-check.ts +53 -0
package/src/doctor.ts
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ghfind --doctor` — environment diagnostics for troubleshooting
|
|
3
|
+
* installation / launch problems (missing Bun, wrong libc build, bad config).
|
|
4
|
+
*
|
|
5
|
+
* Runs a battery of checks and prints a PASS / WARN / FAIL report, then exits
|
|
6
|
+
* non-zero if any FAIL check was detected.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, readFileSync, mkdirSync } from "fs";
|
|
9
|
+
import { spawnSync } from "child_process";
|
|
10
|
+
import { join, dirname } from "path";
|
|
11
|
+
import { fileURLToPath } from "url";
|
|
12
|
+
import { homedir, platform, arch } from "os";
|
|
13
|
+
import { loadConfig, configPath, stateDir, cacheDir } from "./config";
|
|
14
|
+
|
|
15
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const PKG_DIR = join(__dirname, "..");
|
|
17
|
+
|
|
18
|
+
type CheckStatus = "PASS" | "WARN" | "FAIL";
|
|
19
|
+
interface CheckResult {
|
|
20
|
+
status: CheckStatus;
|
|
21
|
+
label: string;
|
|
22
|
+
detail: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const results: CheckResult[] = [];
|
|
26
|
+
|
|
27
|
+
function add(status: CheckStatus, label: string, detail: string): void {
|
|
28
|
+
results.push({ status, label, detail });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function run(
|
|
32
|
+
cmd: string,
|
|
33
|
+
args: string[],
|
|
34
|
+
): { status: number | null; stdout: string; stderr: string } {
|
|
35
|
+
try {
|
|
36
|
+
const res = spawnSync(cmd, args, { encoding: "utf8", timeout: 15000 });
|
|
37
|
+
return {
|
|
38
|
+
status: res.status,
|
|
39
|
+
stdout: res.stdout ?? "",
|
|
40
|
+
stderr: res.stderr ?? "",
|
|
41
|
+
};
|
|
42
|
+
} catch {
|
|
43
|
+
return { status: null, stdout: "", stderr: "" };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function hasSystemBun(): { ok: boolean; version?: string } {
|
|
48
|
+
const res = run("bun", ["--version"]);
|
|
49
|
+
return res.status === 0
|
|
50
|
+
? { ok: true, version: res.stdout.trim() }
|
|
51
|
+
: { ok: false };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function bundledBunPath(): string {
|
|
55
|
+
return join(PKG_DIR, "vendor", platform() === "win32" ? "bun.exe" : "bun");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Verify a bun binary actually runs (wrong libc flavor → ENOENT / segfault). */
|
|
59
|
+
function bunRuns(bin: string): { ok: boolean; version?: string } {
|
|
60
|
+
const res = run(bin, ["--version"]);
|
|
61
|
+
if (res.status !== 0) return { ok: false };
|
|
62
|
+
return { ok: true, version: res.stdout.trim() };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function detectLibc(): "musl" | "glibc" | "unknown" {
|
|
66
|
+
if (platform() !== "linux") return "unknown";
|
|
67
|
+
try {
|
|
68
|
+
const osRelease = readFileSync("/etc/os-release", "utf8");
|
|
69
|
+
if (/alpine|musl/i.test(osRelease)) return "musl";
|
|
70
|
+
} catch {
|
|
71
|
+
// ignore
|
|
72
|
+
}
|
|
73
|
+
const ldd = run("ldd", ["--version"]);
|
|
74
|
+
const out = `${ldd.stdout}${ldd.stderr}`;
|
|
75
|
+
if (/musl/i.test(out)) return "musl";
|
|
76
|
+
if (/glibc|GNU libc/i.test(out)) return "glibc";
|
|
77
|
+
return "unknown";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function checkRuntime(): Promise<void> {
|
|
81
|
+
const runningOnBun = process.versions.bun !== undefined;
|
|
82
|
+
const runtime = runningOnBun
|
|
83
|
+
? `Bun ${process.versions.bun}`
|
|
84
|
+
: `Node ${process.version}`;
|
|
85
|
+
add(
|
|
86
|
+
runningOnBun ? "PASS" : "WARN",
|
|
87
|
+
"Runtime",
|
|
88
|
+
`Running on ${runtime}. The interactive TUI needs Bun.`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function checkBundledBun(): { ok: boolean } {
|
|
93
|
+
const path = bundledBunPath();
|
|
94
|
+
if (!existsSync(path)) {
|
|
95
|
+
add(
|
|
96
|
+
"WARN",
|
|
97
|
+
"Bundled Bun",
|
|
98
|
+
`Not found (${path}). Run \`npm install\` or install Bun manually.`,
|
|
99
|
+
);
|
|
100
|
+
return { ok: false };
|
|
101
|
+
}
|
|
102
|
+
const check = bunRuns(path);
|
|
103
|
+
if (check.ok) {
|
|
104
|
+
add("PASS", "Bundled Bun", `${path} (v${check.version})`);
|
|
105
|
+
return { ok: true };
|
|
106
|
+
}
|
|
107
|
+
const libc = detectLibc();
|
|
108
|
+
const hint =
|
|
109
|
+
libc === "musl"
|
|
110
|
+
? "Installed Bun is the glibc build, but this system uses musl."
|
|
111
|
+
: libc === "glibc"
|
|
112
|
+
? "Installed Bun is the musl build, but this system uses glibc."
|
|
113
|
+
: "The bundled binary does not launch.";
|
|
114
|
+
add("FAIL", "Bundled Bun", `${path} does not launch. ${hint}`);
|
|
115
|
+
return { ok: false };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function checkSystemBun(bundledOk: boolean): void {
|
|
119
|
+
const sys = hasSystemBun();
|
|
120
|
+
if (sys.ok) {
|
|
121
|
+
add("PASS", "System Bun", `Found \`bun\` on PATH (v${sys.version}).`);
|
|
122
|
+
} else if (bundledOk) {
|
|
123
|
+
add(
|
|
124
|
+
"WARN",
|
|
125
|
+
"System Bun",
|
|
126
|
+
"No \`bun\` on PATH. Bundled Bun is working, so this is only a fallback.",
|
|
127
|
+
);
|
|
128
|
+
} else {
|
|
129
|
+
add(
|
|
130
|
+
"FAIL",
|
|
131
|
+
"System Bun",
|
|
132
|
+
"No \`bun\` on PATH. Install it: curl -fsSL https://bun.sh/install | bash",
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function checkNodeVersion(): void {
|
|
138
|
+
const major = parseInt(process.version.replace(/^v/, "").split(".")[0], 10);
|
|
139
|
+
if (Number.isNaN(major) || major >= 20) {
|
|
140
|
+
add("PASS", "Node version", `Node ${process.version} (>= 20 required)`);
|
|
141
|
+
} else {
|
|
142
|
+
add(
|
|
143
|
+
"FAIL",
|
|
144
|
+
"Node version",
|
|
145
|
+
`Node ${process.version} is too old (>= 20 required)`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function checkBuildOutput(): void {
|
|
151
|
+
const dist = join(PKG_DIR, "dist", "cli.js");
|
|
152
|
+
if (existsSync(dist)) {
|
|
153
|
+
add("PASS", "Build output", `${dist}`);
|
|
154
|
+
} else {
|
|
155
|
+
add(
|
|
156
|
+
"WARN",
|
|
157
|
+
"Build output",
|
|
158
|
+
"dist/cli.js not found. Run `npm run build` (Node-only fallback needs it).",
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function checkSource(): void {
|
|
164
|
+
const src = join(PKG_DIR, "src", "cli.ts");
|
|
165
|
+
if (existsSync(src)) {
|
|
166
|
+
add("PASS", "Source", `${src}`);
|
|
167
|
+
} else {
|
|
168
|
+
add("FAIL", "Source", "src/cli.ts not found. Reinstall the package.");
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function checkTuiBackend(): void {
|
|
173
|
+
const isTTY = process.stdout.isTTY && process.stdin.isTTY;
|
|
174
|
+
add(
|
|
175
|
+
isTTY ? "PASS" : "WARN",
|
|
176
|
+
"Terminal (TTY)",
|
|
177
|
+
isTTY
|
|
178
|
+
? "stdin/stdout are TTYs — TUI can render."
|
|
179
|
+
: "stdin/stdout are not TTYs — the TUI cannot render (piped/non-interactive).",
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function checkConfig(): void {
|
|
184
|
+
const path = configPath();
|
|
185
|
+
if (!existsSync(path)) {
|
|
186
|
+
add(
|
|
187
|
+
"WARN",
|
|
188
|
+
"Config",
|
|
189
|
+
`Not found (${path}). Run \`ghfind init\` or it will use defaults.`,
|
|
190
|
+
);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
const raw = readFileSync(path, "utf-8");
|
|
195
|
+
JSON.parse(raw);
|
|
196
|
+
add("PASS", "Config", `${path} (valid JSON)`);
|
|
197
|
+
} catch (err) {
|
|
198
|
+
add(
|
|
199
|
+
"FAIL",
|
|
200
|
+
"Config",
|
|
201
|
+
`${path} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function checkGithubToken(): void {
|
|
207
|
+
const config = loadConfig();
|
|
208
|
+
const token = config.githubToken || process.env.GITHUB_TOKEN;
|
|
209
|
+
if (token) {
|
|
210
|
+
const preview = token.slice(0, 7);
|
|
211
|
+
add("PASS", "GitHub token", `Configured (${preview}…).`);
|
|
212
|
+
} else {
|
|
213
|
+
add(
|
|
214
|
+
"WARN",
|
|
215
|
+
"GitHub token",
|
|
216
|
+
"No token found. Unauthenticated requests hit lower rate limits.",
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function checkDirs(): void {
|
|
222
|
+
for (const [label, dir] of [
|
|
223
|
+
["State dir", stateDir()],
|
|
224
|
+
["Cache dir", cacheDir()],
|
|
225
|
+
] as const) {
|
|
226
|
+
try {
|
|
227
|
+
mkdirSync(dir, { recursive: true });
|
|
228
|
+
add("PASS", label, dir);
|
|
229
|
+
} catch (err) {
|
|
230
|
+
add(
|
|
231
|
+
"FAIL",
|
|
232
|
+
label,
|
|
233
|
+
`${dir} not writable: ${err instanceof Error ? err.message : String(err)}`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function printReport(): void {
|
|
240
|
+
const colors = (s: string, n: CheckStatus) => {
|
|
241
|
+
const code =
|
|
242
|
+
n === "PASS" ? "\x1b[32m" : n === "WARN" ? "\x1b[33m" : "\x1b[31m";
|
|
243
|
+
return `${code}${s}\x1b[0m`;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
console.log("\n ghfind doctor — environment diagnostics");
|
|
247
|
+
console.log(` ${platform()} ${arch} · ${homedir()}`);
|
|
248
|
+
console.log(" ─────────────────────────────────────────");
|
|
249
|
+
for (const r of results) {
|
|
250
|
+
console.log(
|
|
251
|
+
` ${colors(r.status.padEnd(4), r.status)} ${r.label}: ${r.detail}`,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
console.log(" ─────────────────────────────────────────");
|
|
255
|
+
|
|
256
|
+
const fails = results.filter((r) => r.status === "FAIL").length;
|
|
257
|
+
const warns = results.filter((r) => r.status === "WARN").length;
|
|
258
|
+
if (fails === 0 && warns === 0) {
|
|
259
|
+
console.log(" All checks passed.");
|
|
260
|
+
} else if (fails === 0) {
|
|
261
|
+
console.log(
|
|
262
|
+
` ${warns} warning(s) — ghfind should work, but review the items above.`,
|
|
263
|
+
);
|
|
264
|
+
} else {
|
|
265
|
+
console.log(
|
|
266
|
+
` ${fails} failed check(s). Resolve these before running the TUI.`,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
console.log("");
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export async function runDoctor(): Promise<void> {
|
|
273
|
+
await checkRuntime();
|
|
274
|
+
const bundled = checkBundledBun();
|
|
275
|
+
checkSystemBun(bundled.ok);
|
|
276
|
+
checkNodeVersion();
|
|
277
|
+
checkBuildOutput();
|
|
278
|
+
checkSource();
|
|
279
|
+
checkTuiBackend();
|
|
280
|
+
checkConfig();
|
|
281
|
+
checkGithubToken();
|
|
282
|
+
checkDirs();
|
|
283
|
+
printReport();
|
|
284
|
+
|
|
285
|
+
const hasFail = results.some((r) => r.status === "FAIL");
|
|
286
|
+
process.exitCode = hasFail ? 1 : 0;
|
|
287
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error report helper — build a GitHub-issue pre-fill URL from an uncaught
|
|
3
|
+
* error, print it, and offer to open it in the user's browser.
|
|
4
|
+
*
|
|
5
|
+
* Privacy: never auto-posts. The user sees the URL and decides whether to
|
|
6
|
+
* submit. We redact env vars and only include what the diagnostic needs.
|
|
7
|
+
*/
|
|
8
|
+
import { platform as osPlatform, arch as osArch, version as nodeVersion, versions as nodeVersions } from "process";
|
|
9
|
+
import { spawn } from "child_process";
|
|
10
|
+
import { readFileSync } from "fs";
|
|
11
|
+
import { join, dirname } from "path";
|
|
12
|
+
import { fileURLToPath } from "url";
|
|
13
|
+
|
|
14
|
+
// ponytail: in bun, process.platform is a string property; in node, same.
|
|
15
|
+
// We need it as a string, not a function call.
|
|
16
|
+
const _osPlatform = osPlatform;
|
|
17
|
+
const _osArch = osArch;
|
|
18
|
+
void _osPlatform;
|
|
19
|
+
void _osArch;
|
|
20
|
+
|
|
21
|
+
const REPO = "codersdfs/search-cli";
|
|
22
|
+
const NEW_ISSUE_URL = `https://github.com/${REPO}/issues/new`;
|
|
23
|
+
|
|
24
|
+
function getPackageVersion(): string {
|
|
25
|
+
try {
|
|
26
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const pkgPath = join(__dirname, "..", "package.json");
|
|
28
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { version?: string };
|
|
29
|
+
return pkg.version ?? "unknown";
|
|
30
|
+
} catch {
|
|
31
|
+
return "unknown";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function runtimeName(): string {
|
|
36
|
+
// ponytail: process.versions.bun is the cheapest probe and works in both runtimes
|
|
37
|
+
const bunVer = (nodeVersions as Record<string, string | undefined>).bun;
|
|
38
|
+
return bunVer ? `Bun ${bunVer}` : `Node ${nodeVersion}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Format an error into a GitHub issue body. Strips ANSI from stack traces,
|
|
43
|
+
* truncates long messages, redacts common secret patterns.
|
|
44
|
+
*/
|
|
45
|
+
export function formatErrorBody(
|
|
46
|
+
err: unknown,
|
|
47
|
+
context: { command: string; argv: string[] },
|
|
48
|
+
): string {
|
|
49
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
50
|
+
const stack = (e.stack ?? "(no stack)").replace(/\u001b\[[0-9;]*m/g, "");
|
|
51
|
+
const message = (e.message || "(no message)").slice(0, 500);
|
|
52
|
+
const cmd = `${context.command} ${context.argv.join(" ")}`.trim();
|
|
53
|
+
const ghfindVersion = getPackageVersion();
|
|
54
|
+
|
|
55
|
+
return [
|
|
56
|
+
"## What happened",
|
|
57
|
+
"",
|
|
58
|
+
"```",
|
|
59
|
+
message,
|
|
60
|
+
"```",
|
|
61
|
+
"",
|
|
62
|
+
"## Command",
|
|
63
|
+
"",
|
|
64
|
+
"```",
|
|
65
|
+
cmd,
|
|
66
|
+
"```",
|
|
67
|
+
"",
|
|
68
|
+
"## Stack trace",
|
|
69
|
+
"",
|
|
70
|
+
"```",
|
|
71
|
+
stack,
|
|
72
|
+
"```",
|
|
73
|
+
"",
|
|
74
|
+
"## Environment",
|
|
75
|
+
"",
|
|
76
|
+
`- ghfind: v${ghfindVersion}`,
|
|
77
|
+
`- runtime: ${runtimeName()}`,
|
|
78
|
+
`- platform: ${osPlatform} ${osArch}`,
|
|
79
|
+
"",
|
|
80
|
+
"## Notes",
|
|
81
|
+
"",
|
|
82
|
+
"Add anything else that might help (network state, what you were doing, etc.).",
|
|
83
|
+
].join("\n");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build a pre-filled GitHub "new issue" URL from an error.
|
|
88
|
+
*/
|
|
89
|
+
export function buildIssueUrl(
|
|
90
|
+
err: unknown,
|
|
91
|
+
context: { command: string; argv: string[] },
|
|
92
|
+
): string {
|
|
93
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
94
|
+
const title = e.message.slice(0, 80).replace(/[\r\n]+/g, " ");
|
|
95
|
+
const body = formatErrorBody(err, context);
|
|
96
|
+
const params = new URLSearchParams({
|
|
97
|
+
title: `[bug] ${title || "Unhandled error"}`,
|
|
98
|
+
body,
|
|
99
|
+
labels: "bug",
|
|
100
|
+
});
|
|
101
|
+
return `${NEW_ISSUE_URL}?${params.toString()}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Try to open a URL in the user's default browser. Platform-aware, with a
|
|
106
|
+
* Node fallback so this works even when `Bun.spawn` isn't available.
|
|
107
|
+
*/
|
|
108
|
+
function tryOpenUrl(url: string): boolean {
|
|
109
|
+
try {
|
|
110
|
+
const p = osPlatform;
|
|
111
|
+
if (typeof Bun !== "undefined") {
|
|
112
|
+
if (p === "win32") Bun.spawn(["cmd", "/c", "start", "", url]);
|
|
113
|
+
else if (p === "darwin") Bun.spawn(["open", url]);
|
|
114
|
+
else Bun.spawn(["xdg-open", url]);
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
// Node fallback — use child_process.spawn detached so it survives exit
|
|
118
|
+
const cmd = p === "win32" ? "cmd" : p === "darwin" ? "open" : "xdg-open";
|
|
119
|
+
const args = p === "win32" ? ["/c", "start", "", url] : [url];
|
|
120
|
+
const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
|
|
121
|
+
child.unref();
|
|
122
|
+
return true;
|
|
123
|
+
} catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Print the report URL and optionally try to open it. Always returns the URL
|
|
130
|
+
* so the caller can log it regardless of whether the browser opened.
|
|
131
|
+
*/
|
|
132
|
+
export function reportError(
|
|
133
|
+
err: unknown,
|
|
134
|
+
context: { command: string; argv: string[] } = {
|
|
135
|
+
command: "ghfind",
|
|
136
|
+
argv: process.argv.slice(2),
|
|
137
|
+
},
|
|
138
|
+
): string {
|
|
139
|
+
const url = buildIssueUrl(err, context);
|
|
140
|
+
console.error("");
|
|
141
|
+
console.error(" ⚠ ghfind encountered an unexpected error.");
|
|
142
|
+
console.error("");
|
|
143
|
+
console.error(" Help us fix it by opening a bug report with the");
|
|
144
|
+
console.error(" details pre-filled. Your browser will open the new-issue");
|
|
145
|
+
console.error(" page; review and submit (or close the tab to skip).");
|
|
146
|
+
console.error("");
|
|
147
|
+
const opened = tryOpenUrl(url);
|
|
148
|
+
if (opened) {
|
|
149
|
+
console.error(" → Opened: " + url);
|
|
150
|
+
} else {
|
|
151
|
+
console.error(" Copy this URL to file a bug report:");
|
|
152
|
+
console.error("");
|
|
153
|
+
console.error(" " + url);
|
|
154
|
+
}
|
|
155
|
+
console.error("");
|
|
156
|
+
return url;
|
|
157
|
+
}
|
package/src/help.ts
CHANGED
|
@@ -17,7 +17,8 @@ export function buildHelpSections(): HelpSection[] {
|
|
|
17
17
|
titleColor: "blue",
|
|
18
18
|
rows: [
|
|
19
19
|
{ keys: "↑ ↓ / j k", action: "Navigate menu cards" },
|
|
20
|
-
{ keys: "Enter", action: "Select mode" },
|
|
20
|
+
{ keys: "Enter", action: "Select mode / open Bookmarks" },
|
|
21
|
+
{ keys: "b", action: "Jump straight to Bookmarks" },
|
|
21
22
|
{ keys: "? / h", action: "Toggle this help screen" },
|
|
22
23
|
{ keys: "q", action: "Quit" },
|
|
23
24
|
],
|
|
@@ -39,6 +40,9 @@ export function buildHelpSections(): HelpSection[] {
|
|
|
39
40
|
titleColor: "green",
|
|
40
41
|
rows: [
|
|
41
42
|
{ keys: "Tab", action: "Auto-complete qualifier" },
|
|
43
|
+
{ keys: "b", action: "Bookmark / unbookmark selected repo" },
|
|
44
|
+
{ keys: "c", action: "Compare selected repo" },
|
|
45
|
+
{ keys: "t", action: "Cycle theme" },
|
|
42
46
|
{ keys: "PageDown", action: "Next page of results" },
|
|
43
47
|
{ keys: "PageUp", action: "Scroll to top of results" },
|
|
44
48
|
],
|
|
@@ -86,13 +90,24 @@ export function buildHelpSections(): HelpSection[] {
|
|
|
86
90
|
],
|
|
87
91
|
note: "Navigate menu with ↑ ↓, select with Enter, back with ← / Esc",
|
|
88
92
|
},
|
|
93
|
+
{
|
|
94
|
+
title: " Bookmarks Page ",
|
|
95
|
+
titleColor: "green",
|
|
96
|
+
rows: [
|
|
97
|
+
{ keys: "↑ ↓ / j k", action: "Move selection" },
|
|
98
|
+
{ keys: "Enter", action: "Open bookmark in browser" },
|
|
99
|
+
{ keys: "d", action: "Remove bookmark" },
|
|
100
|
+
{ keys: "Esc / ←", action: "Back to landing" },
|
|
101
|
+
],
|
|
102
|
+
note: "A full page, not an overlay — Esc returns to the mode menu.",
|
|
103
|
+
},
|
|
89
104
|
{
|
|
90
105
|
title: " Overlay Keys ",
|
|
91
106
|
titleColor: "teal",
|
|
92
107
|
rows: [
|
|
93
108
|
{ keys: "Esc / q", action: "Close any overlay" },
|
|
94
109
|
{ keys: "Enter", action: "Confirm selection / run action" },
|
|
95
|
-
{ keys: "d", action: "Delete entry (history,
|
|
110
|
+
{ keys: "d", action: "Delete entry (history, saved)" },
|
|
96
111
|
{ keys: "↑ ↓ / j k", action: "Navigate overlay list" },
|
|
97
112
|
{ keys: "← → / h l", action: "Navigate tabs / panels" },
|
|
98
113
|
],
|
package/src/landing.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/** Landing screen pieces that are worth testing in isolation. */
|
|
2
|
+
import { BoxRenderable, TextRenderable } from "@opentui/core";
|
|
3
|
+
|
|
4
|
+
export interface LandingButtonColors {
|
|
5
|
+
accent: string;
|
|
6
|
+
border: string;
|
|
7
|
+
text: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Label for the landing-screen bookmarks button. */
|
|
11
|
+
export function bookmarksButtonLabel(count: number): string {
|
|
12
|
+
if (count <= 0) return "\u{1F516} Bookmarks (none yet)";
|
|
13
|
+
if (count === 1) return "\u{1F516} Bookmarks (1 saved)";
|
|
14
|
+
return `\u{1F516} Bookmarks (${count} saved)`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Whether the landing screen should consume navigation keys.
|
|
19
|
+
* False once another mode owns the screen (search, trending, packages,
|
|
20
|
+
* or the bookmarks page) or an overlay (help, ...) is open on top.
|
|
21
|
+
*/
|
|
22
|
+
export function landingKeysActive(mode: string, overlay: string): boolean {
|
|
23
|
+
return mode === "landing" && overlay === "none";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Keys the landing screen consumes; anything else falls through. */
|
|
27
|
+
const LANDING_KEYS = new Set([
|
|
28
|
+
"up",
|
|
29
|
+
"k",
|
|
30
|
+
"down",
|
|
31
|
+
"j",
|
|
32
|
+
"enter",
|
|
33
|
+
"return",
|
|
34
|
+
"b",
|
|
35
|
+
"?",
|
|
36
|
+
"h",
|
|
37
|
+
"q",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Whether the landing screen consumes this key. Callers must stop propagation
|
|
42
|
+
* for these, or the *same* event reaches the global handler next — e.g.
|
|
43
|
+
* Enter opening the bookmarks page and then immediately being re-read as
|
|
44
|
+
* "open the selected bookmark in a browser".
|
|
45
|
+
*/
|
|
46
|
+
export function landingConsumesKey(name: string): boolean {
|
|
47
|
+
return LANDING_KEYS.has(name);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Clamped selection movement for the landing menu (no wrap-around). */
|
|
51
|
+
export function moveSelection(
|
|
52
|
+
current: number,
|
|
53
|
+
delta: number,
|
|
54
|
+
total: number,
|
|
55
|
+
): number {
|
|
56
|
+
if (total <= 0) return 0;
|
|
57
|
+
return Math.min(total - 1, Math.max(0, current + delta));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Compact bordered button rendered under the landing mode cards.
|
|
62
|
+
* Highlights like a card when selected; label carries the bookmark count.
|
|
63
|
+
*/
|
|
64
|
+
export function createBookmarksButton(
|
|
65
|
+
renderer: ConstructorParameters<typeof BoxRenderable>[0],
|
|
66
|
+
colors: LandingButtonColors,
|
|
67
|
+
) {
|
|
68
|
+
const box = new BoxRenderable(renderer, {
|
|
69
|
+
height: 3,
|
|
70
|
+
width: "60%",
|
|
71
|
+
marginTop: 1,
|
|
72
|
+
border: true,
|
|
73
|
+
borderColor: colors.border,
|
|
74
|
+
paddingX: 1,
|
|
75
|
+
alignItems: "center",
|
|
76
|
+
justifyContent: "center",
|
|
77
|
+
focusable: false,
|
|
78
|
+
});
|
|
79
|
+
const label = new TextRenderable(renderer, {
|
|
80
|
+
content: bookmarksButtonLabel(0),
|
|
81
|
+
color: colors.text,
|
|
82
|
+
height: 1,
|
|
83
|
+
});
|
|
84
|
+
box.add(label);
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
box,
|
|
88
|
+
setCount(n: number): void {
|
|
89
|
+
label.content = bookmarksButtonLabel(n);
|
|
90
|
+
},
|
|
91
|
+
setSelected(selected: boolean): void {
|
|
92
|
+
box.borderColor = selected ? colors.accent : colors.border;
|
|
93
|
+
label.color = selected ? colors.accent : colors.text;
|
|
94
|
+
},
|
|
95
|
+
get labelText(): string {
|
|
96
|
+
return label.content as string;
|
|
97
|
+
},
|
|
98
|
+
get borderColor(): unknown {
|
|
99
|
+
return box.borderColor;
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type BookmarksButton = ReturnType<typeof createBookmarksButton>;
|
package/src/notifications.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { readJSON, writeJSON } from "./storage";
|
|
|
3
3
|
|
|
4
4
|
export interface Notification {
|
|
5
5
|
id: number;
|
|
6
|
-
type: "info" | "warning" | "rate-limit" | "watch-change" | "upgrade";
|
|
6
|
+
type: "info" | "warning" | "rate-limit" | "watch-change" | "upgrade" | "release";
|
|
7
7
|
icon: string;
|
|
8
8
|
message: string;
|
|
9
9
|
timestamp: number;
|
|
@@ -39,6 +39,7 @@ function iconFor(type: Notification["type"]): string {
|
|
|
39
39
|
case "rate-limit": return "🚫";
|
|
40
40
|
case "watch-change": return "🔔";
|
|
41
41
|
case "upgrade": return "📦";
|
|
42
|
+
case "release": return "🚀";
|
|
42
43
|
}
|
|
43
44
|
}
|
|
44
45
|
|