@ahmd-sh/hntui 0.3.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/src/spinner.ts ADDED
@@ -0,0 +1,243 @@
1
+ // Adapted from opentui-spinner (MIT) — examples/knight-rider/utils.ts by Matt Simpson
2
+ import type { ColorInput } from "@opentui/core"
3
+ import { RGBA } from "@opentui/core"
4
+ import type { ColorGenerator } from "opentui-spinner"
5
+
6
+ interface AdvancedGradientOptions {
7
+ colors: ColorInput[]
8
+ trailLength: number
9
+ defaultColor?: ColorInput
10
+ direction?: "forward" | "backward" | "bidirectional"
11
+ holdFrames?: { start?: number; end?: number }
12
+ }
13
+
14
+ interface ScannerState {
15
+ activePosition: number
16
+ isHolding: boolean
17
+ holdProgress: number
18
+ holdTotal: number
19
+ movementProgress: number
20
+ movementTotal: number
21
+ isMovingForward: boolean
22
+ }
23
+
24
+ function getScannerState(
25
+ frameIndex: number,
26
+ totalChars: number,
27
+ options: Pick<AdvancedGradientOptions, "direction" | "holdFrames">,
28
+ ): ScannerState {
29
+ const { direction = "forward", holdFrames = {} } = options
30
+
31
+ if (direction === "bidirectional") {
32
+ const forwardFrames = totalChars
33
+ const holdEndFrames = holdFrames.end ?? 0
34
+ const backwardFrames = totalChars - 1
35
+
36
+ if (frameIndex < forwardFrames) {
37
+ return {
38
+ activePosition: frameIndex,
39
+ isHolding: false,
40
+ holdProgress: 0,
41
+ holdTotal: 0,
42
+ movementProgress: frameIndex,
43
+ movementTotal: forwardFrames,
44
+ isMovingForward: true,
45
+ }
46
+ } else if (frameIndex < forwardFrames + holdEndFrames) {
47
+ return {
48
+ activePosition: totalChars - 1,
49
+ isHolding: true,
50
+ holdProgress: frameIndex - forwardFrames,
51
+ holdTotal: holdEndFrames,
52
+ movementProgress: 0,
53
+ movementTotal: 0,
54
+ isMovingForward: true,
55
+ }
56
+ } else if (frameIndex < forwardFrames + holdEndFrames + backwardFrames) {
57
+ const backwardIndex = frameIndex - forwardFrames - holdEndFrames
58
+ return {
59
+ activePosition: totalChars - 2 - backwardIndex,
60
+ isHolding: false,
61
+ holdProgress: 0,
62
+ holdTotal: 0,
63
+ movementProgress: backwardIndex,
64
+ movementTotal: backwardFrames,
65
+ isMovingForward: false,
66
+ }
67
+ } else {
68
+ return {
69
+ activePosition: 0,
70
+ isHolding: true,
71
+ holdProgress: frameIndex - forwardFrames - holdEndFrames - backwardFrames,
72
+ holdTotal: holdFrames.start ?? 0,
73
+ movementProgress: 0,
74
+ movementTotal: 0,
75
+ isMovingForward: false,
76
+ }
77
+ }
78
+ } else if (direction === "backward") {
79
+ return {
80
+ activePosition: totalChars - 1 - (frameIndex % totalChars),
81
+ isHolding: false,
82
+ holdProgress: 0,
83
+ holdTotal: 0,
84
+ movementProgress: frameIndex % totalChars,
85
+ movementTotal: totalChars,
86
+ isMovingForward: false,
87
+ }
88
+ } else {
89
+ return {
90
+ activePosition: frameIndex % totalChars,
91
+ isHolding: false,
92
+ holdProgress: 0,
93
+ holdTotal: 0,
94
+ movementProgress: frameIndex % totalChars,
95
+ movementTotal: totalChars,
96
+ isMovingForward: true,
97
+ }
98
+ }
99
+ }
100
+
101
+ function calculateColorIndex(
102
+ frameIndex: number,
103
+ charIndex: number,
104
+ totalChars: number,
105
+ options: Pick<AdvancedGradientOptions, "direction" | "holdFrames" | "trailLength">,
106
+ ): number {
107
+ const { trailLength } = options
108
+ const { activePosition, isHolding, holdProgress, isMovingForward } = getScannerState(
109
+ frameIndex,
110
+ totalChars,
111
+ options,
112
+ )
113
+
114
+ const directionalDistance = isMovingForward ? activePosition - charIndex : charIndex - activePosition
115
+
116
+ if (isHolding) {
117
+ return directionalDistance + holdProgress
118
+ }
119
+
120
+ if (directionalDistance > 0 && directionalDistance < trailLength) {
121
+ return directionalDistance
122
+ }
123
+
124
+ if (directionalDistance === 0) {
125
+ return 0
126
+ }
127
+
128
+ return -1
129
+ }
130
+
131
+ function createKnightRiderTrail(options: AdvancedGradientOptions): ColorGenerator {
132
+ const { colors, defaultColor } = options
133
+
134
+ const defaultRgba =
135
+ defaultColor instanceof RGBA ? defaultColor : RGBA.fromHex((defaultColor as string) || "#000000")
136
+
137
+ return (frameIndex: number, charIndex: number, _totalFrames: number, totalChars: number) => {
138
+ const index = calculateColorIndex(frameIndex, charIndex, totalChars, options)
139
+
140
+ const { isHolding, holdProgress, holdTotal, movementProgress, movementTotal } = getScannerState(
141
+ frameIndex,
142
+ totalChars,
143
+ options,
144
+ )
145
+
146
+ let alpha = 1.0
147
+ if (isHolding && holdTotal > 0) {
148
+ const progress = Math.min(holdProgress / holdTotal, 1)
149
+ alpha = Math.max(0, 1 - progress)
150
+ } else if (!isHolding && movementTotal > 0) {
151
+ const progress = Math.min(movementProgress / Math.max(1, movementTotal - 1), 1)
152
+ alpha = progress
153
+ }
154
+
155
+ defaultRgba.a = alpha
156
+
157
+ if (index === -1) {
158
+ return defaultRgba
159
+ }
160
+
161
+ return colors[index] ?? defaultRgba
162
+ }
163
+ }
164
+
165
+ export type KnightRiderStyle = "blocks" | "diamonds"
166
+
167
+ export interface KnightRiderOptions {
168
+ width?: number
169
+ style?: KnightRiderStyle
170
+ holdStart?: number
171
+ holdEnd?: number
172
+ colors?: ColorInput[]
173
+ defaultColor?: ColorInput
174
+ }
175
+
176
+ export function createFrames(options: KnightRiderOptions = {}): string[] {
177
+ const width = options.width ?? 8
178
+ const style = options.style ?? "diamonds"
179
+ const holdStart = options.holdStart ?? 30
180
+ const holdEnd = options.holdEnd ?? 9
181
+
182
+ const colors = options.colors ?? [
183
+ RGBA.fromHex("#ff0000"),
184
+ RGBA.fromHex("#ff5555"),
185
+ RGBA.fromHex("#dd0000"),
186
+ RGBA.fromHex("#aa0000"),
187
+ RGBA.fromHex("#770000"),
188
+ RGBA.fromHex("#440000"),
189
+ ]
190
+
191
+ const trailOptions = {
192
+ colors,
193
+ trailLength: colors.length,
194
+ defaultColor: options.defaultColor ?? RGBA.fromHex("#330000"),
195
+ direction: "bidirectional" as const,
196
+ holdFrames: { start: holdStart, end: holdEnd },
197
+ }
198
+
199
+ const totalFrames = width + holdEnd + (width - 1) + holdStart
200
+
201
+ const frames = Array.from({ length: totalFrames }, (_, frameIndex) => {
202
+ return Array.from({ length: width }, (_, charIndex) => {
203
+ const index = calculateColorIndex(frameIndex, charIndex, width, trailOptions)
204
+
205
+ if (style === "diamonds") {
206
+ const shapes = ["⬥", "◆", "⬩", "⬪"]
207
+ if (index >= 0 && index < trailOptions.colors.length) {
208
+ return shapes[Math.min(index, shapes.length - 1)]
209
+ }
210
+ return "·"
211
+ }
212
+
213
+ const isActive = index >= 0 && index < trailOptions.colors.length
214
+ return isActive ? "■" : "⬝"
215
+ }).join("")
216
+ })
217
+
218
+ return frames
219
+ }
220
+
221
+ export function createColors(options: KnightRiderOptions = {}): ColorGenerator {
222
+ const holdStart = options.holdStart ?? 30
223
+ const holdEnd = options.holdEnd ?? 9
224
+
225
+ const colors = options.colors ?? [
226
+ RGBA.fromHex("#ff0000"),
227
+ RGBA.fromHex("#ff5555"),
228
+ RGBA.fromHex("#dd0000"),
229
+ RGBA.fromHex("#aa0000"),
230
+ RGBA.fromHex("#770000"),
231
+ RGBA.fromHex("#440000"),
232
+ ]
233
+
234
+ const trailOptions = {
235
+ colors,
236
+ trailLength: colors.length,
237
+ defaultColor: options.defaultColor ?? RGBA.fromHex("#330000"),
238
+ direction: "bidirectional" as const,
239
+ holdFrames: { start: holdStart, end: holdEnd },
240
+ }
241
+
242
+ return createKnightRiderTrail(trailOptions)
243
+ }
package/src/theme.ts ADDED
@@ -0,0 +1,131 @@
1
+ import { createContext, useContext } from "react"
2
+ import type { ColorInput } from "@opentui/core"
3
+
4
+ export interface Theme {
5
+ name: "dark" | "light"
6
+
7
+ body: ColorInput
8
+ strip: ColorInput
9
+ border: ColorInput
10
+
11
+ brandTileBg: ColorInput
12
+ brandTileFg: ColorInput
13
+ brandText: ColorInput
14
+ brandSubtle: ColorInput
15
+
16
+ tabActiveBg: ColorInput
17
+ tabActiveFg: ColorInput
18
+ tabInactiveFg: ColorInput
19
+
20
+ statusHint: ColorInput
21
+
22
+ text: ColorInput
23
+ textBody: ColorInput
24
+ textMuted: ColorInput
25
+ textDim: ColorInput
26
+ textVisited: ColorInput
27
+
28
+ accent: ColorInput
29
+ stripAccent: ColorInput
30
+ link: ColorInput
31
+
32
+ rowHighlight: ColorInput
33
+ menuBg: ColorInput
34
+
35
+ selectionBg: ColorInput
36
+ selectionFg: ColorInput
37
+
38
+ scrollTrack: ColorInput
39
+ scrollThumb: ColorInput
40
+
41
+ commentDepth: readonly ColorInput[]
42
+ }
43
+
44
+ export const darkTheme: Theme = {
45
+ name: "dark",
46
+ body: "#0a0a0acc",
47
+ strip: "#0e0e0e",
48
+ border: "#2a2a2a",
49
+
50
+ brandTileBg: "#ff6600",
51
+ brandTileFg: "#000000",
52
+ brandText: "#ff6600",
53
+ brandSubtle: "#888888",
54
+
55
+ tabActiveBg: "#ff6600",
56
+ tabActiveFg: "#000000",
57
+ tabInactiveFg: "#cccccc",
58
+
59
+ statusHint: "#888888",
60
+
61
+ text: "#ffffff",
62
+ textBody: "#dddddd",
63
+ textMuted: "#aaaaaa",
64
+ textDim: "#666666",
65
+ textVisited: "#666666",
66
+
67
+ accent: "#ff6600",
68
+ stripAccent: "#ff6600",
69
+ link: "#4488ff",
70
+
71
+ rowHighlight: "#ffffff1a",
72
+ menuBg: "#1a1a1a",
73
+
74
+ selectionBg: "#ffffff",
75
+ selectionFg: "#000000",
76
+
77
+ scrollTrack: "#1a1a1a",
78
+ scrollThumb: "#555555",
79
+
80
+ commentDepth: ["#ff6600", "#ffaa44", "#ffd58a", "#88ccff", "#aa88ff", "#ff88cc"],
81
+ }
82
+
83
+ export const lightTheme: Theme = {
84
+ name: "light",
85
+ body: "#f6f6ef",
86
+ strip: "#ff6600",
87
+ border: "#f6f6ef",
88
+
89
+ brandTileBg: "#ffffff",
90
+ brandTileFg: "#000000",
91
+ brandText: "#000000",
92
+ brandSubtle: "#5a2200",
93
+
94
+ tabActiveBg: "#ffffff",
95
+ tabActiveFg: "#000000",
96
+ tabInactiveFg: "#000000",
97
+
98
+ statusHint: "#000000",
99
+
100
+ text: "#000000",
101
+ textBody: "#000000",
102
+ textMuted: "#000000",
103
+ textDim: "#000000",
104
+ textVisited: "#828282",
105
+
106
+ accent: "#ff6600",
107
+ stripAccent: "#000000",
108
+ link: "#4488ff",
109
+
110
+ rowHighlight: "#ffffff",
111
+ menuBg: "#f6f6ef",
112
+
113
+ selectionBg: "#000000",
114
+ selectionFg: "#ffffff",
115
+
116
+ scrollTrack: "#ffffff",
117
+ scrollThumb: "#828282",
118
+
119
+ commentDepth: ["#ff6600", "#cc5200", "#993d00", "#0066cc", "#6633aa", "#aa3366"],
120
+ }
121
+
122
+ export const ThemeContext = createContext<Theme>(darkTheme)
123
+
124
+ export function useTheme(): Theme {
125
+ return useContext(ThemeContext)
126
+ }
127
+
128
+ /** Spread onto <text> so mouse selection inverts properly in both themes. */
129
+ export function selectionColors(t: Theme): { selectionBg: ColorInput; selectionFg: ColorInput } {
130
+ return { selectionBg: t.selectionBg, selectionFg: t.selectionFg }
131
+ }
@@ -0,0 +1,22 @@
1
+ import { homedir } from "os"
2
+ import { join } from "path"
3
+ import { existsSync, renameSync } from "fs"
4
+
5
+ const OLD_DIR = join(homedir(), ".config", "hackernuis")
6
+ const NEW_DIR = join(homedir(), ".config", "hntui")
7
+
8
+ let checked = false
9
+
10
+ // The app was renamed from hackernuis to hntui: move existing config
11
+ // (saved posts, view history) to the new location. Once, best-effort.
12
+ export function configDir(): string {
13
+ if (!checked) {
14
+ checked = true
15
+ try {
16
+ if (!existsSync(NEW_DIR) && existsSync(OLD_DIR)) renameSync(OLD_DIR, NEW_DIR)
17
+ } catch {
18
+ // fall through — the stores create the dir on write anyway
19
+ }
20
+ }
21
+ return NEW_DIR
22
+ }
@@ -0,0 +1,21 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { HnDecodeError, HnRequestError, HnStatusError, HnTimeoutError } from "../api/hn"
3
+ import { hnErrorMessage } from "./errors"
4
+
5
+ describe("hnErrorMessage", () => {
6
+ test("network failure suggests checking the connection", () => {
7
+ expect(hnErrorMessage(new HnRequestError({ path: "x", cause: 1 }))).toMatch(/connection/)
8
+ })
9
+
10
+ test("timeout says so", () => {
11
+ expect(hnErrorMessage(new HnTimeoutError({ path: "x" }))).toMatch(/too long/)
12
+ })
13
+
14
+ test("status errors surface the HTTP code", () => {
15
+ expect(hnErrorMessage(new HnStatusError({ path: "x", status: 503 }))).toContain("503")
16
+ })
17
+
18
+ test("decode errors are covered", () => {
19
+ expect(hnErrorMessage(new HnDecodeError({ path: "x", issue: "bad" }))).toMatch(/understand/)
20
+ })
21
+ })
@@ -0,0 +1,13 @@
1
+ import { Match } from "effect"
2
+ import type { HnError } from "../api/hn"
3
+
4
+ // The last mile of typed errors: every HnError member must map to copy a
5
+ // human can act on. Match.exhaustive makes this a compile-time guarantee —
6
+ // adding a new error to HnError breaks this build until it gets a message.
7
+ export const hnErrorMessage: (e: HnError) => string = Match.type<HnError>().pipe(
8
+ Match.tag("HnRequestError", () => "Couldn't reach Hacker News — check your connection."),
9
+ Match.tag("HnTimeoutError", () => "Hacker News took too long to respond."),
10
+ Match.tag("HnStatusError", (e) => `Hacker News returned HTTP ${e.status}.`),
11
+ Match.tag("HnDecodeError", () => "Hacker News sent a response the app couldn't understand."),
12
+ Match.exhaustive,
13
+ )
@@ -0,0 +1,56 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { parseHnItemLink } from "./format"
3
+
4
+ describe("parseHnItemLink", () => {
5
+ test("story link", () => {
6
+ expect(parseHnItemLink("https://news.ycombinator.com/item?id=8863")).toEqual({
7
+ id: 8863,
8
+ anchorId: undefined,
9
+ })
10
+ })
11
+
12
+ test("story link with comment anchor", () => {
13
+ expect(parseHnItemLink("https://news.ycombinator.com/item?id=8863#8917")).toEqual({
14
+ id: 8863,
15
+ anchorId: 8917,
16
+ })
17
+ })
18
+
19
+ test("www and http variants", () => {
20
+ expect(parseHnItemLink("http://www.news.ycombinator.com/item?id=1")).toEqual({
21
+ id: 1,
22
+ anchorId: undefined,
23
+ })
24
+ })
25
+
26
+ test("extra query params are tolerated", () => {
27
+ expect(parseHnItemLink("https://news.ycombinator.com/item?id=42&p=2")?.id).toBe(42)
28
+ })
29
+
30
+ test("non-item HN pages are external", () => {
31
+ expect(parseHnItemLink("https://news.ycombinator.com/user?id=pg")).toBeNull()
32
+ expect(parseHnItemLink("https://news.ycombinator.com/newest")).toBeNull()
33
+ })
34
+
35
+ test("other hosts are external", () => {
36
+ expect(parseHnItemLink("https://example.com/item?id=1")).toBeNull()
37
+ expect(parseHnItemLink("https://hn.algolia.com/item?id=1")).toBeNull()
38
+ })
39
+
40
+ test("missing or malformed ids are external", () => {
41
+ expect(parseHnItemLink("https://news.ycombinator.com/item")).toBeNull()
42
+ expect(parseHnItemLink("https://news.ycombinator.com/item?id=abc")).toBeNull()
43
+ expect(parseHnItemLink("https://news.ycombinator.com/item?id=-5")).toBeNull()
44
+ })
45
+
46
+ test("non-numeric anchor is ignored, link still internal", () => {
47
+ expect(parseHnItemLink("https://news.ycombinator.com/item?id=7#up_8")).toEqual({
48
+ id: 7,
49
+ anchorId: undefined,
50
+ })
51
+ })
52
+
53
+ test("garbage is external", () => {
54
+ expect(parseHnItemLink("not a url")).toBeNull()
55
+ })
56
+ })
@@ -0,0 +1,97 @@
1
+ export function relativeTime(unixSec?: number): string {
2
+ if (!unixSec) return ""
3
+ const diff = Math.floor(Date.now() / 1000 - unixSec)
4
+ if (diff < 60) return `${diff}s ago`
5
+ if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
6
+ if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
7
+ if (diff < 86400 * 30) return `${Math.floor(diff / 86400)}d ago`
8
+ if (diff < 86400 * 365) return `${Math.floor(diff / 2592000)}mo ago`
9
+ return `${Math.floor(diff / 31536000)}y ago`
10
+ }
11
+
12
+ export function hostname(url?: string): string {
13
+ if (!url) return ""
14
+ try {
15
+ return new URL(url).hostname.replace(/^www\./, "")
16
+ } catch {
17
+ return ""
18
+ }
19
+ }
20
+
21
+ const ENTITIES: Record<string, string> = {
22
+ "&amp;": "&",
23
+ "&lt;": "<",
24
+ "&gt;": ">",
25
+ "&quot;": '"',
26
+ "&#x27;": "'",
27
+ "&#39;": "'",
28
+ "&#x2F;": "/",
29
+ "&#47;": "/",
30
+ "&nbsp;": " ",
31
+ }
32
+
33
+ export function decodeEntities(s: string): string {
34
+ return s
35
+ .replace(/&(amp|lt|gt|quot|nbsp|#x27|#39|#x2F|#47);/g, (m) => ENTITIES[m] ?? m)
36
+ .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10)))
37
+ .replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCharCode(parseInt(n, 16)))
38
+ }
39
+
40
+ export interface Link {
41
+ text: string
42
+ url: string
43
+ }
44
+
45
+ export function extractLinks(html?: string): Link[] {
46
+ if (!html) return []
47
+ const re = /<a\s+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi
48
+ const out: Link[] = []
49
+ const seen = new Set<string>()
50
+ let m: RegExpExecArray | null
51
+ while ((m = re.exec(html))) {
52
+ const url = decodeEntities(m[1]!)
53
+ if (seen.has(url)) continue
54
+ seen.add(url)
55
+ const text = decodeEntities(m[2]!.replace(/<[^>]+>/g, "")).trim()
56
+ out.push({ text: text || url, url })
57
+ }
58
+ return out
59
+ }
60
+
61
+ // A link into HN itself: /item?id=POSTID or /item?id=POSTID#COMMENTID.
62
+ // `id` may be a story OR a comment id
63
+ export interface HnItemRef {
64
+ id: number
65
+ anchorId?: number
66
+ }
67
+
68
+ export function parseHnItemLink(url: string): HnItemRef | null {
69
+ try {
70
+ const u = new URL(url)
71
+ const host = u.hostname.replace(/^www\./, "")
72
+ if (host !== "news.ycombinator.com" || u.pathname !== "/item") return null
73
+ const id = Number(u.searchParams.get("id"))
74
+ if (!Number.isInteger(id) || id <= 0) return null
75
+ const anchor = Number(u.hash.slice(1))
76
+ const anchorId =
77
+ u.hash.length > 1 && Number.isInteger(anchor) && anchor > 0 ? anchor : undefined
78
+ return { id, anchorId }
79
+ } catch {
80
+ return null
81
+ }
82
+ }
83
+
84
+ export function htmlToText(html?: string): string {
85
+ if (!html) return ""
86
+ return decodeEntities(
87
+ html
88
+ .replace(/<p>/gi, "\n\n")
89
+ .replace(/<\/p>/gi, "")
90
+ .replace(/<br\s*\/?>/gi, "\n")
91
+ .replace(/<i>(.*?)<\/i>/gi, "$1")
92
+ .replace(/<a\s+href="([^"]+)"[^>]*>(.*?)<\/a>/gi, "$2 ($1)")
93
+ .replace(/<pre><code>([\s\S]*?)<\/code><\/pre>/gi, "\n$1\n")
94
+ .replace(/<code>(.*?)<\/code>/gi, "`$1`")
95
+ .replace(/<[^>]+>/g, "")
96
+ ).trim()
97
+ }
@@ -0,0 +1,38 @@
1
+ import { join } from "path"
2
+ import { mkdirSync } from "fs"
3
+ import { configDir } from "./configDir"
4
+
5
+ export interface HistoryEntry {
6
+ id: number
7
+ viewedAt: number
8
+ }
9
+
10
+ export const HISTORY_CAP = 1000
11
+
12
+ const HISTORY_PATH = join(configDir(), "history.json")
13
+
14
+ export async function loadHistory(): Promise<HistoryEntry[]> {
15
+ try {
16
+ const file = Bun.file(HISTORY_PATH)
17
+ if (!(await file.exists())) return []
18
+ const data = await file.json()
19
+ if (!Array.isArray(data)) return []
20
+ return data
21
+ .filter(
22
+ (e): e is HistoryEntry =>
23
+ e && typeof e.id === "number" && typeof e.viewedAt === "number",
24
+ )
25
+ .slice(0, HISTORY_CAP)
26
+ } catch {
27
+ return []
28
+ }
29
+ }
30
+
31
+ export async function persistHistory(entries: HistoryEntry[]): Promise<void> {
32
+ try {
33
+ mkdirSync(configDir(), { recursive: true })
34
+ await Bun.write(HISTORY_PATH, JSON.stringify(entries, null, 2))
35
+ } catch {
36
+ // fail silently — view history is best-effort
37
+ }
38
+ }
@@ -0,0 +1,10 @@
1
+ export function openUrl(url: string): void {
2
+ if (!url) return
3
+ const cmd =
4
+ process.platform === "darwin"
5
+ ? ["open", url]
6
+ : process.platform === "win32"
7
+ ? ["cmd", "/c", "start", "", url]
8
+ : ["xdg-open", url]
9
+ Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore" })
10
+ }
@@ -0,0 +1,34 @@
1
+ import { join } from "path"
2
+ import { mkdirSync } from "fs"
3
+ import { configDir } from "./configDir"
4
+
5
+ export interface SavedEntry {
6
+ id: number
7
+ savedAt: number
8
+ }
9
+
10
+ const SAVED_PATH = join(configDir(), "saved.json")
11
+
12
+ export async function loadSaved(): Promise<SavedEntry[]> {
13
+ try {
14
+ const file = Bun.file(SAVED_PATH)
15
+ if (!(await file.exists())) return []
16
+ const data = await file.json()
17
+ if (!Array.isArray(data)) return []
18
+ return data.filter(
19
+ (e): e is SavedEntry =>
20
+ e && typeof e.id === "number" && typeof e.savedAt === "number",
21
+ )
22
+ } catch {
23
+ return []
24
+ }
25
+ }
26
+
27
+ export async function persistSaved(entries: SavedEntry[]): Promise<void> {
28
+ try {
29
+ mkdirSync(configDir(), { recursive: true })
30
+ await Bun.write(SAVED_PATH, JSON.stringify(entries, null, 2))
31
+ } catch {
32
+ // fail silently — saved state is best-effort
33
+ }
34
+ }