github-search-cli 9.2.1 → 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 +976 -162
- 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/compare.ts +4 -3
- 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/markdown-render.ts +216 -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 +43 -13
- package/src/types.ts +3 -1
- package/src/update-check.ts +53 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal markdown-to-terminal-text renderer.
|
|
3
|
+
*
|
|
4
|
+
* Renders headings, code blocks, lists, tables, blockquotes, horizontal rules,
|
|
5
|
+
* bold/italic, and links into a plain-text representation suitable for a
|
|
6
|
+
* TextRenderable in the TUI.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
interface RenderOptions {
|
|
10
|
+
/** Terminal width for wrapping and table layout. Default: 80. */
|
|
11
|
+
width?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Render markdown text to terminal-friendly plain text. */
|
|
15
|
+
export function renderMarkdown(md: string, opts: RenderOptions = {}): string {
|
|
16
|
+
const width = opts.width ?? 80;
|
|
17
|
+
const lines = md.split("\n");
|
|
18
|
+
const out: string[] = [];
|
|
19
|
+
let inCodeBlock = false;
|
|
20
|
+
let codeLang = "";
|
|
21
|
+
let codeLines: string[] = [];
|
|
22
|
+
let inTable = false;
|
|
23
|
+
let tableRows: string[][] = [];
|
|
24
|
+
|
|
25
|
+
function flushCode() {
|
|
26
|
+
if (codeLines.length === 0) return;
|
|
27
|
+
const maxLen = Math.max(...codeLines.map((l) => l.length), codeLang.length);
|
|
28
|
+
const barW = Math.min(maxLen + 4, width - 2);
|
|
29
|
+
out.push("┌" + "─".repeat(barW) + "┐");
|
|
30
|
+
if (codeLang) out.push("│ " + codeLang.padEnd(barW - 1) + "│");
|
|
31
|
+
for (const cl of codeLines) {
|
|
32
|
+
out.push("│ " + cl.padEnd(barW - 1) + "│");
|
|
33
|
+
}
|
|
34
|
+
out.push("└" + "─".repeat(barW) + "┘");
|
|
35
|
+
out.push("");
|
|
36
|
+
codeLines = [];
|
|
37
|
+
codeLang = "";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function flushTable() {
|
|
41
|
+
if (tableRows.length === 0) return;
|
|
42
|
+
const dataRows = tableRows.filter(
|
|
43
|
+
(r) => !r.every((c) => /^[\s\-:|]+$/.test(c)),
|
|
44
|
+
);
|
|
45
|
+
if (dataRows.length === 0) {
|
|
46
|
+
tableRows = [];
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const colCount = Math.max(...dataRows.map((r) => r.length));
|
|
50
|
+
const colW: number[] = [];
|
|
51
|
+
for (let ci = 0; ci < colCount; ci++) {
|
|
52
|
+
colW.push(
|
|
53
|
+
Math.max(
|
|
54
|
+
3,
|
|
55
|
+
...dataRows.map((r) => (r[ci] ?? "").length),
|
|
56
|
+
),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
const totalW = colW.reduce((a, b) => a + b, 0) + colCount * 3 - 1;
|
|
60
|
+
if (totalW > width) {
|
|
61
|
+
for (const row of dataRows) {
|
|
62
|
+
out.push(" " + row.map((c) => c.trim()).join(" │ "));
|
|
63
|
+
}
|
|
64
|
+
} else {
|
|
65
|
+
const sep = "┌" + colW.map((w) => "─".repeat(w + 2)).join("┬") + "┐";
|
|
66
|
+
const mid = "├" + colW.map((w) => "─".repeat(w + 2)).join("┼") + "┤";
|
|
67
|
+
const end = "└" + colW.map((w) => "─".repeat(w + 2)).join("┴") + "┘";
|
|
68
|
+
out.push(sep);
|
|
69
|
+
dataRows.forEach((row, i) => {
|
|
70
|
+
const cells = colW.map((w, ci) => (row[ci] ?? "").trim().padEnd(w));
|
|
71
|
+
out.push("│ " + cells.map((c) => ` ${c} `).join("│") + "│");
|
|
72
|
+
if (i === 0) out.push(mid);
|
|
73
|
+
});
|
|
74
|
+
out.push(end);
|
|
75
|
+
}
|
|
76
|
+
out.push("");
|
|
77
|
+
tableRows = [];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function renderInline(line: string): string {
|
|
81
|
+
// Bold
|
|
82
|
+
line = line.replace(/\*\*(.+?)\*\*/g, "$1");
|
|
83
|
+
// Italic (no asterisks)
|
|
84
|
+
line = line.replace(/\*([^*]+?)\*/g, "$1");
|
|
85
|
+
// Inline code
|
|
86
|
+
line = line.replace(/`([^`]+)`/g, "⟨$1⟩");
|
|
87
|
+
// Links [text](url)
|
|
88
|
+
line = line.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)");
|
|
89
|
+
// Images
|
|
90
|
+
line = line.replace(/!\[([^\]]*)\]\([^)]+\)/g, "🖼 $1");
|
|
91
|
+
return line;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function wrapLine(line: string, maxW: number): string[] {
|
|
95
|
+
if (line.length <= maxW) return [line];
|
|
96
|
+
const parts: string[] = [];
|
|
97
|
+
let remaining = line;
|
|
98
|
+
while (remaining.length > maxW) {
|
|
99
|
+
let breakAt = maxW;
|
|
100
|
+
const spaceIdx = remaining.lastIndexOf(" ", maxW);
|
|
101
|
+
if (spaceIdx > maxW / 2) breakAt = spaceIdx;
|
|
102
|
+
parts.push(remaining.slice(0, breakAt));
|
|
103
|
+
remaining = remaining.slice(breakAt).replace(/^ /, "");
|
|
104
|
+
}
|
|
105
|
+
if (remaining) parts.push(remaining);
|
|
106
|
+
return parts;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (let i = 0; i < lines.length; i++) {
|
|
110
|
+
let line = lines[i];
|
|
111
|
+
|
|
112
|
+
// Code block boundaries
|
|
113
|
+
if (/^```/.test(line)) {
|
|
114
|
+
if (inCodeBlock) {
|
|
115
|
+
flushCode();
|
|
116
|
+
inCodeBlock = false;
|
|
117
|
+
} else {
|
|
118
|
+
inCodeBlock = true;
|
|
119
|
+
codeLang = line.slice(3).trim();
|
|
120
|
+
}
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (inCodeBlock) {
|
|
124
|
+
codeLines.push(line);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Table row
|
|
129
|
+
if (line.includes("|")) {
|
|
130
|
+
const cells = line
|
|
131
|
+
.split("|")
|
|
132
|
+
.map((c) => c.trim())
|
|
133
|
+
.filter((_, idx, arr) => idx > 0 && idx < arr.length - 1);
|
|
134
|
+
if (cells.length >= 2) {
|
|
135
|
+
inTable = true;
|
|
136
|
+
tableRows.push(cells);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
} else if (inTable) {
|
|
140
|
+
flushTable();
|
|
141
|
+
inTable = false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Horizontal rule
|
|
145
|
+
if (/^(\s*[-*_]){3,}\s*$/.test(line)) {
|
|
146
|
+
out.push("─".repeat(Math.min(width, 40)));
|
|
147
|
+
out.push("");
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Headings
|
|
152
|
+
const headingMatch = line.match(/^(#{1,6})\s+(.+)/);
|
|
153
|
+
if (headingMatch) {
|
|
154
|
+
const level = headingMatch[1].length;
|
|
155
|
+
const text = renderInline(headingMatch[2]);
|
|
156
|
+
const prefix = "█ ".repeat(level);
|
|
157
|
+
out.push("");
|
|
158
|
+
out.push(prefix + text);
|
|
159
|
+
out.push("");
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Blockquote
|
|
164
|
+
const bqMatch = line.match(/^>\s?(.*)/);
|
|
165
|
+
if (bqMatch) {
|
|
166
|
+
const inner = renderInline(bqMatch[1]);
|
|
167
|
+
for (const wl of wrapLine(inner, width - 4)) {
|
|
168
|
+
out.push("│ " + wl);
|
|
169
|
+
}
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Unordered list
|
|
174
|
+
const ulMatch = line.match(/^(\s*)[-*+]\s+(.*)/);
|
|
175
|
+
if (ulMatch) {
|
|
176
|
+
const indent = ulMatch[1].length;
|
|
177
|
+
const depth = Math.floor(indent / 2);
|
|
178
|
+
const prefix = " ".repeat(depth) + "• ";
|
|
179
|
+
const inner = renderInline(ulMatch[2]);
|
|
180
|
+
for (const wl of wrapLine(inner, width - prefix.length)) {
|
|
181
|
+
out.push(prefix + wl);
|
|
182
|
+
}
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Ordered list
|
|
187
|
+
const olMatch = line.match(/^(\s*)\d+\.\s+(.*)/);
|
|
188
|
+
if (olMatch) {
|
|
189
|
+
const indent = olMatch[1].length;
|
|
190
|
+
const depth = Math.floor(indent / 2);
|
|
191
|
+
const prefix = " ".repeat(depth) + "◦ ";
|
|
192
|
+
const inner = renderInline(olMatch[2]);
|
|
193
|
+
for (const wl of wrapLine(inner, width - prefix.length)) {
|
|
194
|
+
out.push(prefix + wl);
|
|
195
|
+
}
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Empty line
|
|
200
|
+
if (line.trim() === "") {
|
|
201
|
+
out.push("");
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Paragraph
|
|
206
|
+
const rendered = renderInline(line);
|
|
207
|
+
for (const wl of wrapLine(rendered, width)) {
|
|
208
|
+
out.push(wl);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (inCodeBlock) flushCode();
|
|
213
|
+
if (inTable) flushTable();
|
|
214
|
+
|
|
215
|
+
return out.join("\n");
|
|
216
|
+
}
|
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
|
|
package/src/releases.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
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
|
+
import { readJSON, writeJSON } from "./storage";
|
|
15
|
+
import { getBookmarks, getLastSeen, markSeen } from "./bookmarks";
|
|
16
|
+
import { addNotification } from "./notifications";
|
|
17
|
+
import { loadConfig } from "./config";
|
|
18
|
+
|
|
19
|
+
const RELEASES_FILE = "release-cache.json";
|
|
20
|
+
const PER_REPO = 5;
|
|
21
|
+
|
|
22
|
+
export interface TrackedRelease {
|
|
23
|
+
id: number;
|
|
24
|
+
tagName: string;
|
|
25
|
+
name: string;
|
|
26
|
+
url: string;
|
|
27
|
+
publishedAt: string;
|
|
28
|
+
prerelease: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Persisted cache: etag + last-known releases per repo. Only what the feed needs. */
|
|
32
|
+
interface RepoReleaseState {
|
|
33
|
+
etag?: string;
|
|
34
|
+
checkedAt: number;
|
|
35
|
+
releases: TrackedRelease[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type ReleaseCache = Record<string, RepoReleaseState>;
|
|
39
|
+
|
|
40
|
+
function loadCache(): ReleaseCache {
|
|
41
|
+
return readJSON<ReleaseCache>(RELEASES_FILE, {});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function saveCache(cache: ReleaseCache): void {
|
|
45
|
+
writeJSON(RELEASES_FILE, cache);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function toTracked(raw: Array<Record<string, unknown>>): TrackedRelease[] {
|
|
49
|
+
return raw
|
|
50
|
+
.filter((r) => r.draft !== true) // drafts: never shown
|
|
51
|
+
.map((r) => ({
|
|
52
|
+
id: r.id as number,
|
|
53
|
+
tagName: r.tag_name as string,
|
|
54
|
+
name: (r.name as string) || (r.tag_name as string),
|
|
55
|
+
url: r.html_url as string,
|
|
56
|
+
publishedAt: (r.published_at as string) || "",
|
|
57
|
+
prerelease: r.prerelease === true,
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Fetch /releases for one repo using its cached etag. */
|
|
62
|
+
async function fetchReleases(
|
|
63
|
+
fullName: string,
|
|
64
|
+
etag: string | undefined,
|
|
65
|
+
token: string | undefined,
|
|
66
|
+
): Promise<{ status: number; etag?: string; releases?: TrackedRelease[] }> {
|
|
67
|
+
const headers: Record<string, string> = { Accept: "application/vnd.github+json" };
|
|
68
|
+
if (etag) headers["If-None-Match"] = etag;
|
|
69
|
+
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
70
|
+
const res = await fetch(`https://api.github.com/repos/${fullName}/releases?per_page=${PER_REPO}`, { headers });
|
|
71
|
+
if (res.status === 304 || !res.ok) return { status: res.status };
|
|
72
|
+
return {
|
|
73
|
+
status: 200,
|
|
74
|
+
etag: res.headers.get("etag") ?? undefined,
|
|
75
|
+
releases: toTracked((await res.json()) as Array<Record<string, unknown>>),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Check all bookmarks for releases newer than their lastSeenAt.
|
|
81
|
+
* Returns one result per bookmark so callers can render feeds or counts.
|
|
82
|
+
*/
|
|
83
|
+
export async function checkReleases(
|
|
84
|
+
opts: { token?: string } = {},
|
|
85
|
+
): Promise<Array<{ fullName: string; newReleases: TrackedRelease[]; error?: string }>> {
|
|
86
|
+
const bookmarks = getBookmarks();
|
|
87
|
+
const cache = loadCache();
|
|
88
|
+
const config = loadConfig();
|
|
89
|
+
const token = opts.token ?? config.githubToken ?? process.env.GITHUB_TOKEN;
|
|
90
|
+
const results: Array<{ fullName: string; newReleases: TrackedRelease[]; error?: string }> = [];
|
|
91
|
+
|
|
92
|
+
for (const bm of bookmarks) {
|
|
93
|
+
try {
|
|
94
|
+
const prev = cache[bm.repo.fullName];
|
|
95
|
+
const lastSeen = getLastSeen(bm.repo.fullName);
|
|
96
|
+
const isNew = (r: TrackedRelease) =>
|
|
97
|
+
!r.publishedAt || new Date(r.publishedAt).getTime() > lastSeen;
|
|
98
|
+
|
|
99
|
+
const { status, etag, releases } = await fetchReleases(bm.repo.fullName, prev?.etag, token);
|
|
100
|
+
|
|
101
|
+
if (status === 304 && prev) {
|
|
102
|
+
// Unchanged upstream; recompute against lastSeenAt (it may have moved).
|
|
103
|
+
results.push({ fullName: bm.repo.fullName, newReleases: prev.releases.filter(isNew) });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (status !== 200 || !releases) {
|
|
107
|
+
results.push({ fullName: bm.repo.fullName, newReleases: [], error: `HTTP ${status}` });
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
cache[bm.repo.fullName] = { checkedAt: Date.now(), etag, releases };
|
|
111
|
+
results.push({ fullName: bm.repo.fullName, newReleases: releases.filter(isNew) });
|
|
112
|
+
} catch (err) {
|
|
113
|
+
results.push({ fullName: bm.repo.fullName, newReleases: [], error: (err as Error).message });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
saveCache(cache);
|
|
118
|
+
return results;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Check + push unseen releases into the notification center, then mark them seen. Returns per-repo results. */
|
|
122
|
+
export async function checkAndNotify(opts: { token?: string } = {}): Promise<Array<{ fullName: string; newReleases: TrackedRelease[]; error?: string }>> {
|
|
123
|
+
const results = await checkReleases(opts);
|
|
124
|
+
for (const r of results) {
|
|
125
|
+
for (const rel of r.newReleases) {
|
|
126
|
+
addNotification(
|
|
127
|
+
"release",
|
|
128
|
+
`${r.fullName} released ${rel.tagName}${rel.prerelease ? " [pre]" : ""}`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
if (r.newReleases.length > 0) markSeen(r.fullName);
|
|
132
|
+
}
|
|
133
|
+
return results;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** All cached releases across bookmarks, newest first (for the Releases panel / CLI). */
|
|
137
|
+
export function allCachedReleases(): Array<TrackedRelease & { fullName: string }> {
|
|
138
|
+
const cache = loadCache();
|
|
139
|
+
const bookmarks = getBookmarks();
|
|
140
|
+
return Object.entries(cache)
|
|
141
|
+
.filter(([fullName]) => bookmarks.some((b) => b.repo.fullName === fullName))
|
|
142
|
+
.flatMap(([fullName, s]) => s.releases.map((r) => ({ ...r, fullName })))
|
|
143
|
+
.sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime());
|
|
144
|
+
}
|
package/src/tips.ts
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Startup tips — rotate through helpful hints.
|
|
3
3
|
*/
|
|
4
|
+
|
|
5
|
+
/** Rotates tips without repeating the same one twice in a row. */
|
|
6
|
+
export class TipRotator {
|
|
7
|
+
private tips: string[];
|
|
8
|
+
private lastIndex = -1;
|
|
9
|
+
|
|
10
|
+
constructor(tips?: string[]) {
|
|
11
|
+
this.tips = tips ?? TIPS;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
next(): string {
|
|
15
|
+
let idx: number;
|
|
16
|
+
do {
|
|
17
|
+
idx = Math.floor(Math.random() * this.tips.length);
|
|
18
|
+
} while (idx === this.lastIndex && this.tips.length > 1);
|
|
19
|
+
this.lastIndex = idx;
|
|
20
|
+
return this.tips[idx];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
4
24
|
const TIPS = [
|
|
5
25
|
"💡 Tip: Press ? to see all keyboard shortcuts",
|
|
6
26
|
"💡 Tip: Use Ctrl+R to recall past searches",
|
|
@@ -14,14 +34,9 @@ const TIPS = [
|
|
|
14
34
|
"💡 Tip: Press Ctrl+P to copy a repo link to clipboard",
|
|
15
35
|
];
|
|
16
36
|
|
|
17
|
-
|
|
37
|
+
export const defaultRotator = new TipRotator();
|
|
18
38
|
|
|
19
39
|
/** Get the next tip (never repeats the same tip twice in a row). */
|
|
20
40
|
export function nextTip(): string {
|
|
21
|
-
|
|
22
|
-
do {
|
|
23
|
-
idx = Math.floor(Math.random() * TIPS.length);
|
|
24
|
-
} while (idx === lastIndex && TIPS.length > 1);
|
|
25
|
-
lastIndex = idx;
|
|
26
|
-
return TIPS[idx];
|
|
41
|
+
return defaultRotator.next();
|
|
27
42
|
}
|
package/src/toast.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** Toast popup — absolute-positioned card pinned top-right, auto-hides. */
|
|
2
|
+
import { BoxRenderable, TextRenderable } from "@opentui/core";
|
|
3
|
+
|
|
4
|
+
/** Only the color keys the toast reads (lazily, so runtime theme swaps apply). */
|
|
5
|
+
export interface ToastColors {
|
|
6
|
+
green: string;
|
|
7
|
+
yellow: string;
|
|
8
|
+
text: string;
|
|
9
|
+
surfaceDim: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createToast(
|
|
13
|
+
renderer: Parameters<BoxRenderable["constructor"]>[0],
|
|
14
|
+
root: { add(child: unknown): unknown },
|
|
15
|
+
colors: ToastColors,
|
|
16
|
+
) {
|
|
17
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
18
|
+
const box = new BoxRenderable(renderer, {
|
|
19
|
+
visible: false,
|
|
20
|
+
position: "absolute",
|
|
21
|
+
top: 1,
|
|
22
|
+
right: 2,
|
|
23
|
+
height: 3,
|
|
24
|
+
border: true,
|
|
25
|
+
borderStyle: "rounded",
|
|
26
|
+
borderColor: colors.green,
|
|
27
|
+
backgroundColor: colors.surfaceDim,
|
|
28
|
+
zIndex: 100,
|
|
29
|
+
});
|
|
30
|
+
const text = new TextRenderable(renderer, {
|
|
31
|
+
content: "",
|
|
32
|
+
color: colors.text,
|
|
33
|
+
backgroundColor: colors.surfaceDim,
|
|
34
|
+
height: 1,
|
|
35
|
+
});
|
|
36
|
+
box.add(text);
|
|
37
|
+
root.add(box);
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
/** Show a message; accent picks the border color. Re-showing resets the timer. */
|
|
41
|
+
show(msg: string, ms = 2000, accent?: string): void {
|
|
42
|
+
text.content = msg;
|
|
43
|
+
box.borderColor = accent ?? colors.green;
|
|
44
|
+
// Message + paddingX(2) + side borders(2)
|
|
45
|
+
box.width = msg.length + 4;
|
|
46
|
+
box.visible = true;
|
|
47
|
+
renderer.requestRender();
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
timer = setTimeout(() => {
|
|
50
|
+
box.visible = false;
|
|
51
|
+
renderer.requestRender();
|
|
52
|
+
}, ms);
|
|
53
|
+
},
|
|
54
|
+
hide(): void {
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
if (!box.visible) return;
|
|
57
|
+
box.visible = false;
|
|
58
|
+
renderer.requestRender();
|
|
59
|
+
},
|
|
60
|
+
get visible(): boolean {
|
|
61
|
+
return box.visible;
|
|
62
|
+
},
|
|
63
|
+
get borderColor(): string {
|
|
64
|
+
return box.borderColor as string;
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type Toast = ReturnType<typeof createToast>;
|
package/src/tui.ts
CHANGED
|
@@ -79,6 +79,7 @@ import {
|
|
|
79
79
|
import { saveSession, restoreSession } from "./session";
|
|
80
80
|
import { fetchDeepDive, buildDeepDiveText } from "./deepdive";
|
|
81
81
|
import { buildComparisonTable } from "./compare";
|
|
82
|
+
import { renderMarkdown } from "./markdown-render";
|
|
82
83
|
import { fetchTopics, type TopicItem } from "./explore";
|
|
83
84
|
import { exportToFile, type ExportFormat } from "./output";
|
|
84
85
|
import { listThemes, loadTheme } from "./themes";
|
|
@@ -112,6 +113,9 @@ import {
|
|
|
112
113
|
snoozeUpdateNotices,
|
|
113
114
|
suppressUpdateNotices,
|
|
114
115
|
performUpdate,
|
|
116
|
+
fetchReleaseNotes,
|
|
117
|
+
readUpdateState,
|
|
118
|
+
recordPreUpdateState,
|
|
115
119
|
} from "./update-check";
|
|
116
120
|
import { debugLog } from "./storage";
|
|
117
121
|
const colors = {
|
|
@@ -1526,6 +1530,33 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
1526
1530
|
showOverlay("leader");
|
|
1527
1531
|
}
|
|
1528
1532
|
|
|
1533
|
+
// ponytail: post-upgrade panel — fires when lastInstalledVersion != current version
|
|
1534
|
+
// (no network, just reads bundled release notes for the new version)
|
|
1535
|
+
function checkPostUpgradePanel() {
|
|
1536
|
+
const state = readUpdateState();
|
|
1537
|
+
if (state.suppressed) return;
|
|
1538
|
+
if (!cachedVersion) {
|
|
1539
|
+
try {
|
|
1540
|
+
const pkg = JSON.parse(readFileSync(join(PKG_DIR, "package.json"), "utf-8"));
|
|
1541
|
+
cachedVersion = pkg.version;
|
|
1542
|
+
} catch {
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
const currentVersion = cachedVersion;
|
|
1547
|
+
if (!state.lastInstalledVersion) return;
|
|
1548
|
+
if (state.lastInstalledVersion === currentVersion) return;
|
|
1549
|
+
// We just upgraded — show notes for the new version
|
|
1550
|
+
const notes = fetchReleaseNotes(currentVersion);
|
|
1551
|
+
const notesBlock = notes
|
|
1552
|
+
? renderMarkdown(notes, { width: 60 })
|
|
1553
|
+
: " No release notes found for this version.";
|
|
1554
|
+
updateSelectedOption = 0;
|
|
1555
|
+
updateText.content = ` Updated ghfind ${state.lastInstalledVersion} → ${currentVersion}\n\n${notesBlock}`;
|
|
1556
|
+
renderUpdateOptions();
|
|
1557
|
+
showOverlay("update");
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1529
1560
|
async function checkForUpdateAndShow(force: boolean = false) {
|
|
1530
1561
|
if (!force && !shouldCheckUpdate()) return;
|
|
1531
1562
|
if (cachedVersion === null) {
|
|
@@ -1543,7 +1574,11 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
1543
1574
|
markUpdateChecked(latest ?? undefined);
|
|
1544
1575
|
if (latest) {
|
|
1545
1576
|
updateSelectedOption = 0;
|
|
1546
|
-
|
|
1577
|
+
const notes = fetchReleaseNotes(latest);
|
|
1578
|
+
const notesBlock = notes
|
|
1579
|
+
? renderMarkdown(notes, { width: 60 })
|
|
1580
|
+
: ` A new version is available\n\n Current: ${currentVersion}\n Latest: ${latest}\n\n Run "npm install -g ghfind"`;
|
|
1581
|
+
updateText.content = ` A new version is available\n\n Current: ${currentVersion}\n Latest: ${latest}\n\n${notesBlock}`;
|
|
1547
1582
|
renderUpdateOptions();
|
|
1548
1583
|
showOverlay("update");
|
|
1549
1584
|
} else {
|
|
@@ -1709,17 +1744,8 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
1709
1744
|
if (!text) {
|
|
1710
1745
|
readmeText.content = ` (no README found for ${repo.fullName})`;
|
|
1711
1746
|
} else {
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
.replace(/```[\s\S]*?```/g, "[code block]")
|
|
1715
|
-
.replace(/#{1,6}\s/g, "")
|
|
1716
|
-
.replace(/\*\*(.+?)\*\*/g, "$1")
|
|
1717
|
-
.replace(/\[(.+?)\]\(.+?\)/g, "$1")
|
|
1718
|
-
.replace(/`([^`]+)`/g, "$1")
|
|
1719
|
-
.split("\n")
|
|
1720
|
-
.map((l) => l.trimEnd())
|
|
1721
|
-
.join("\n");
|
|
1722
|
-
readmeText.content = stripped;
|
|
1747
|
+
const termW = Math.max(40, (process.stdout.columns || 80) - 4);
|
|
1748
|
+
readmeText.content = renderMarkdown(text, { width: termW });
|
|
1723
1749
|
}
|
|
1724
1750
|
} catch {
|
|
1725
1751
|
readmeText.content = " Failed to load README";
|
|
@@ -2585,6 +2611,8 @@ ${pack.description ?? ""}`;
|
|
|
2585
2611
|
const action = ["now", "later", "never"][updateSelectedOption] as "now" | "later" | "never" | undefined;
|
|
2586
2612
|
if (action === "now") {
|
|
2587
2613
|
showOverlay("none");
|
|
2614
|
+
// ponytail: record the running version so the next launch detects the upgrade
|
|
2615
|
+
recordPreUpdateState(cachedVersion ?? "");
|
|
2588
2616
|
performUpdate().then((ok) => {
|
|
2589
2617
|
if (ok) {
|
|
2590
2618
|
setStatus("✓ Update complete — restart ghfind");
|
|
@@ -2646,6 +2674,8 @@ ${pack.description ?? ""}`;
|
|
|
2646
2674
|
showLeaderMenu();
|
|
2647
2675
|
}
|
|
2648
2676
|
return;
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2649
2679
|
// 't' toggles theme and persists to config
|
|
2650
2680
|
if (key.name === "t" && !searchInput.focused) {
|
|
2651
2681
|
const current = config.theme;
|
|
@@ -2659,7 +2689,6 @@ ${pack.description ?? ""}`;
|
|
|
2659
2689
|
renderer.requestRender();
|
|
2660
2690
|
return;
|
|
2661
2691
|
}
|
|
2662
|
-
}
|
|
2663
2692
|
|
|
2664
2693
|
// 'c' toggles compare on selected repo (when not typing in search)
|
|
2665
2694
|
if (key.name === "c" && !searchInput.focused) {
|
|
@@ -2779,6 +2808,7 @@ ${pack.description ?? ""}`;
|
|
|
2779
2808
|
showLanding(true);
|
|
2780
2809
|
}
|
|
2781
2810
|
checkForUpdateAndShow();
|
|
2811
|
+
checkPostUpgradePanel();
|
|
2782
2812
|
renderer.start();
|
|
2783
2813
|
if (session && session.mode) {
|
|
2784
2814
|
if (currentMode === "trending") {
|
package/src/types.ts
CHANGED
|
@@ -104,6 +104,8 @@ export interface Bookmark {
|
|
|
104
104
|
savedAt: number;
|
|
105
105
|
tags: string[];
|
|
106
106
|
notes?: string;
|
|
107
|
+
/** Releases published after this are "unseen". Set when the Releases panel is viewed. */
|
|
108
|
+
lastSeenAt?: number;
|
|
107
109
|
}
|
|
108
110
|
|
|
109
111
|
/** A named, saved search. */
|
|
@@ -120,7 +122,7 @@ export interface SavedSearch {
|
|
|
120
122
|
|
|
121
123
|
/** Session state restored on startup. */
|
|
122
124
|
export interface SessionState {
|
|
123
|
-
mode: "search" | "trending" | "packages";
|
|
125
|
+
mode: "search" | "trending" | "packages" | "bookmarks";
|
|
124
126
|
query: string;
|
|
125
127
|
sort: SortStrategy;
|
|
126
128
|
limit: number;
|