@carlesandres/house 0.4.0 → 0.4.2
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 +75 -137
- package/README.md +37 -20
- package/package.json +14 -12
- package/src/Browser.tsx +331 -226
- package/src/CommandPalette.tsx +67 -46
- package/src/Footer.tsx +71 -27
- package/src/Header.tsx +68 -0
- package/src/HelpOverlay.tsx +59 -41
- package/src/PromptRow.tsx +49 -0
- package/src/brand.ts +7 -0
- package/src/cli/argv.ts +34 -3
- package/src/commands/buildCommands.ts +1 -0
- package/src/config/load.ts +84 -19
- package/src/discovery/walk.ts +8 -3
- package/src/index.tsx +64 -11
- package/src/io/editor.ts +162 -0
- package/src/keymap/browser.ts +79 -22
- package/src/keymap/keymap.ts +6 -0
- package/src/layout/resolve.ts +9 -9
- package/src/layout/sidebarRow.ts +85 -0
- package/src/serve/server.ts +4 -1
- package/src/theme/colors.ts +51 -15
- package/src/theme/types.ts +7 -0
- package/src/update/cache.ts +77 -0
- package/src/update/check.ts +165 -0
- package/src/update/compare.ts +41 -0
- package/src/update/notice.ts +29 -0
- package/src/update/runtime.ts +48 -0
- package/src/update/useUpdateNotice.ts +24 -0
package/src/keymap/browser.ts
CHANGED
|
@@ -10,11 +10,19 @@ export type BrowserFocus = "sidebar" | "reader"
|
|
|
10
10
|
|
|
11
11
|
export interface BrowserCtx {
|
|
12
12
|
readonly files: readonly FileEntry[]
|
|
13
|
+
/** True iff `files[selectedIndex]` resolves to an entry. The honest
|
|
14
|
+
* predicate for File-group actions (`o`, `e`, `[`, `]`): with debounced
|
|
15
|
+
* filter and sticky auto-select, `files.length > 0` can be true while
|
|
16
|
+
* `selectedIndex` is invalid for the displayed list. See #115. */
|
|
17
|
+
readonly hasSelected: boolean
|
|
13
18
|
readonly focus: BrowserFocus
|
|
14
19
|
/** User's sticky sidebar preference. Visibility is `shown || focus==="sidebar"`. */
|
|
15
20
|
readonly sidebarShown: boolean
|
|
16
21
|
readonly helpVisible: boolean
|
|
17
22
|
readonly filterOpen: boolean
|
|
23
|
+
/** Current applied/edited filter query. Used by `filter.clearOrOpen`'s
|
|
24
|
+
* hint gate so the hint only appears when there is something to clear. */
|
|
25
|
+
readonly filterQuery: string
|
|
18
26
|
readonly paletteOpen: boolean
|
|
19
27
|
readonly setFocus: (next: BrowserFocus | ((prev: BrowserFocus) => BrowserFocus)) => void
|
|
20
28
|
readonly setSelectedIndex: (updater: (prev: number) => number) => void
|
|
@@ -22,12 +30,19 @@ export interface BrowserCtx {
|
|
|
22
30
|
readonly toggleShown: () => void
|
|
23
31
|
readonly setHelpVisible: (updater: (prev: boolean) => boolean) => void
|
|
24
32
|
readonly openFilter: () => void
|
|
33
|
+
/** Clear the current filter query and open the filter modal in a single
|
|
34
|
+
* action. Bound to `\` so users can reset a stranded zero-match filter
|
|
35
|
+
* without first reopening with `/` and backspacing. */
|
|
36
|
+
readonly clearAndOpenFilter: () => void
|
|
25
37
|
readonly openPalette: () => void
|
|
26
38
|
readonly cycleTheme: (delta: 1 | -1) => void
|
|
27
39
|
readonly toggleTone: () => void
|
|
28
40
|
readonly quit: () => void
|
|
29
41
|
/** Start (or retarget) the HTML preview server on the focused file. */
|
|
30
42
|
readonly serveCurrent: () => void
|
|
43
|
+
/** Suspend the TUI, hand the TTY to `$EDITOR`, resume and re-read on
|
|
44
|
+
* exit. No-op when nothing is selected; gating is the binding's job. */
|
|
45
|
+
readonly editCurrent: () => void
|
|
31
46
|
}
|
|
32
47
|
|
|
33
48
|
/** Step size for shift+j/k and the space/b/page keys. Constant for v1; could
|
|
@@ -37,6 +52,7 @@ const JUMP = 8
|
|
|
37
52
|
const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n))
|
|
38
53
|
const lastIndex = (c: BrowserCtx) => Math.max(0, c.files.length - 1)
|
|
39
54
|
const haveFiles = (c: BrowserCtx) => c.files.length > 0
|
|
55
|
+
const hasSelected = (c: BrowserCtx) => c.hasSelected
|
|
40
56
|
const stepBy = (c: BrowserCtx, delta: number) =>
|
|
41
57
|
c.setSelectedIndex((i) => clamp(i + delta, 0, lastIndex(c)))
|
|
42
58
|
|
|
@@ -45,7 +61,10 @@ const filterClosed = (c: BrowserCtx) => !c.filterOpen
|
|
|
45
61
|
const paletteClosed = (c: BrowserCtx) => !c.paletteOpen
|
|
46
62
|
const inReader = (c: BrowserCtx) => c.focus === "reader"
|
|
47
63
|
const inSidebarWithFiles = (c: BrowserCtx) => inSidebar(c) && haveFiles(c)
|
|
48
|
-
|
|
64
|
+
/** Reader-only sibling-step gate: needs a current selection plus a sibling
|
|
65
|
+
* to step to. `hasSelected` implies `files.length >= 1`, so `>= 2` is the
|
|
66
|
+
* meaningful extra condition. */
|
|
67
|
+
const inReaderWithSibling = (c: BrowserCtx) => inReader(c) && hasSelected(c) && c.files.length >= 2
|
|
49
68
|
|
|
50
69
|
export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
|
|
51
70
|
// Global
|
|
@@ -93,6 +112,28 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
|
|
|
93
112
|
when: filterClosed,
|
|
94
113
|
run: (c) => c.openFilter(),
|
|
95
114
|
},
|
|
115
|
+
{
|
|
116
|
+
id: "filter.clearOrOpen",
|
|
117
|
+
group: "Sidebar",
|
|
118
|
+
description: "Clear filter",
|
|
119
|
+
hint: "clear",
|
|
120
|
+
keys: ["ctrl+\\"],
|
|
121
|
+
// Fires from anywhere outside the filter modal via the keymap.
|
|
122
|
+
// Inside the filter modal it's intercepted directly in Browser.tsx
|
|
123
|
+
// (the filter mode owns key handling), but the action is the same —
|
|
124
|
+
// clear input, keep modal open. Palette/help branches short-circuit
|
|
125
|
+
// dispatch in Browser.tsx, so we don't need to gate on them for
|
|
126
|
+
// behavior; the `hintWhen` gate keeps the footer chip from showing
|
|
127
|
+
// when there's nothing to clear or when a modal owns the input.
|
|
128
|
+
// Chord chosen over single `\` so the binding works inside the
|
|
129
|
+
// filter input without colliding with the typed character; ctrl+u
|
|
130
|
+
// is deliberately left to its reader/sidebar half-page-up role to
|
|
131
|
+
// avoid overload.
|
|
132
|
+
when: filterClosed,
|
|
133
|
+
hintWhen: (c) =>
|
|
134
|
+
filterClosed(c) && !c.paletteOpen && !c.helpVisible && c.filterQuery.length > 0,
|
|
135
|
+
run: (c) => c.clearAndOpenFilter(),
|
|
136
|
+
},
|
|
96
137
|
{
|
|
97
138
|
id: "palette.open",
|
|
98
139
|
group: "Global",
|
|
@@ -106,15 +147,6 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
|
|
|
106
147
|
when: paletteClosed,
|
|
107
148
|
run: (c) => c.openPalette(),
|
|
108
149
|
},
|
|
109
|
-
{
|
|
110
|
-
id: "serve.current",
|
|
111
|
-
group: "Global",
|
|
112
|
-
description: "Open current file in browser as HTML",
|
|
113
|
-
hint: "html",
|
|
114
|
-
keys: ["o"],
|
|
115
|
-
when: haveFiles,
|
|
116
|
-
run: (c) => c.serveCurrent(),
|
|
117
|
-
},
|
|
118
150
|
{
|
|
119
151
|
id: "theme.next",
|
|
120
152
|
group: "Global",
|
|
@@ -213,32 +245,57 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
|
|
|
213
245
|
run: (c) => c.setFocus("reader"),
|
|
214
246
|
},
|
|
215
247
|
|
|
216
|
-
//
|
|
248
|
+
// File — actions on the currently-selected file. Gated on `hasSelected`
|
|
249
|
+
// (per #115) so they're available exactly when the reader has something
|
|
250
|
+
// to act on, regardless of focus or filter state.
|
|
217
251
|
{
|
|
218
|
-
id: "
|
|
219
|
-
group: "
|
|
220
|
-
description: "
|
|
221
|
-
hint: "
|
|
222
|
-
keys: ["
|
|
223
|
-
when:
|
|
224
|
-
run: (c) => c.
|
|
252
|
+
id: "serve.current",
|
|
253
|
+
group: "File",
|
|
254
|
+
description: "Open current file in browser as HTML",
|
|
255
|
+
hint: "html",
|
|
256
|
+
keys: ["o"],
|
|
257
|
+
when: hasSelected,
|
|
258
|
+
run: (c) => c.serveCurrent(),
|
|
225
259
|
},
|
|
226
260
|
{
|
|
261
|
+
id: "file.edit",
|
|
262
|
+
group: "File",
|
|
263
|
+
description: "Open current file in $EDITOR",
|
|
264
|
+
hint: "edit",
|
|
265
|
+
keys: ["e"],
|
|
266
|
+
when: hasSelected,
|
|
267
|
+
run: (c) => c.editCurrent(),
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
// `[`/`]` keep the `inReader` clause so they're only typed from the
|
|
271
|
+
// reader (sidebar uses j/k for stepping). The File-group predicate is
|
|
272
|
+
// additive: needs a selection *and* a sibling to step to.
|
|
227
273
|
id: "reader.prevFile",
|
|
228
|
-
group: "
|
|
274
|
+
group: "File",
|
|
229
275
|
description: "Prev file",
|
|
230
276
|
hint: "prev",
|
|
231
277
|
keys: ["["],
|
|
232
|
-
when:
|
|
278
|
+
when: inReaderWithSibling,
|
|
233
279
|
run: (c) => stepBy(c, -1),
|
|
234
280
|
},
|
|
235
281
|
{
|
|
236
282
|
id: "reader.nextFile",
|
|
237
|
-
group: "
|
|
283
|
+
group: "File",
|
|
238
284
|
description: "Next file",
|
|
239
285
|
hint: "next",
|
|
240
286
|
keys: ["]"],
|
|
241
|
-
when:
|
|
287
|
+
when: inReaderWithSibling,
|
|
242
288
|
run: (c) => stepBy(c, 1),
|
|
243
289
|
},
|
|
290
|
+
|
|
291
|
+
// Reader
|
|
292
|
+
{
|
|
293
|
+
id: "reader.back",
|
|
294
|
+
group: "Reader",
|
|
295
|
+
description: "Back to sidebar",
|
|
296
|
+
hint: "back",
|
|
297
|
+
keys: ["escape", "left", "h"],
|
|
298
|
+
when: inReader,
|
|
299
|
+
run: (c) => c.setFocus("sidebar"),
|
|
300
|
+
},
|
|
244
301
|
]
|
package/src/keymap/keymap.ts
CHANGED
|
@@ -34,6 +34,12 @@ export interface KeyBinding<C> {
|
|
|
34
34
|
readonly hint?: string
|
|
35
35
|
/** If present, the binding only fires when this returns true. */
|
|
36
36
|
readonly when?: (ctx: C) => boolean
|
|
37
|
+
/** If present, the footer hint is shown only when this returns true. When
|
|
38
|
+
* absent, hint visibility falls back to `when`. Use when a binding's
|
|
39
|
+
* dispatch gate is broader than the situations where the hint is useful
|
|
40
|
+
* (e.g. "clear filter" fires from anywhere but only deserves a hint when
|
|
41
|
+
* there is actually a filter to clear). */
|
|
42
|
+
readonly hintWhen?: (ctx: C) => boolean
|
|
37
43
|
readonly run: (ctx: C) => void
|
|
38
44
|
}
|
|
39
45
|
|
package/src/layout/resolve.ts
CHANGED
|
@@ -18,8 +18,6 @@ export const SIDEBAR_MAX_WIDTH = 60
|
|
|
18
18
|
export const READER_MIN_WIDTH = 40
|
|
19
19
|
/** Column gap painted between the two panes when both are inline. */
|
|
20
20
|
export const DIVIDER_WIDTH = 1
|
|
21
|
-
/** Launch-bucket threshold for `--sidebar=auto`. < this → start hidden. */
|
|
22
|
-
export const TIGHT_VIEWPORT_THRESHOLD = 80
|
|
23
21
|
|
|
24
22
|
/**
|
|
25
23
|
* Continuous clamp. `preferred` is the user's desired width (until #13 lands,
|
|
@@ -28,7 +26,7 @@ export const TIGHT_VIEWPORT_THRESHOLD = 80
|
|
|
28
26
|
*
|
|
29
27
|
* Result is not clamped *up* to SIDEBAR_MIN when the viewport itself is too
|
|
30
28
|
* narrow to hold both panes — the caller decides whether to render at all
|
|
31
|
-
* (e.g.
|
|
29
|
+
* (e.g. single-pane stack instead of inline). See `canFitInline`.
|
|
32
30
|
*/
|
|
33
31
|
export const resolveSidebarWidth = (viewport: number, preferred: number): number => {
|
|
34
32
|
const ceiling = viewport - DIVIDER_WIDTH - READER_MIN_WIDTH
|
|
@@ -46,15 +44,17 @@ export const defaultPreferredWidth = (viewport: number): number =>
|
|
|
46
44
|
|
|
47
45
|
/**
|
|
48
46
|
* True when an inline (side-by-side) layout still gives the reader at least
|
|
49
|
-
* READER_MIN_WIDTH. When false, the
|
|
50
|
-
*
|
|
47
|
+
* READER_MIN_WIDTH. When false, the viewport is "narrow" and the UI runs in
|
|
48
|
+
* single-pane stack mode — sidebar OR reader fills the pane area, never both.
|
|
49
|
+
* See DESIGN.md §7.1.
|
|
51
50
|
*/
|
|
52
51
|
export const canFitInline = (viewport: number): boolean =>
|
|
53
52
|
viewport >= SIDEBAR_MIN_WIDTH + DIVIDER_WIDTH + READER_MIN_WIDTH
|
|
54
53
|
|
|
55
54
|
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
55
|
+
* Initial sidebar visibility for `--sidebar=auto`. Always true — every
|
|
56
|
+
* viewport now boots on the sidebar (narrow: as the single visible screen;
|
|
57
|
+
* wide: as the focused inline pane). `--sidebar=off` is the only way to
|
|
58
|
+
* boot directly into the reader.
|
|
58
59
|
*/
|
|
59
|
-
export const initialShownForAuto = (
|
|
60
|
-
viewport >= TIGHT_VIEWPORT_THRESHOLD
|
|
60
|
+
export const initialShownForAuto = (_viewport: number): boolean => true
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidebar row layout — basename-first with a dim parent suffix sized to the
|
|
3
|
+
* available width.
|
|
4
|
+
*
|
|
5
|
+
* Pure per-row formatting: a row's rendered shape depends only on its own
|
|
6
|
+
* path and the column budget — never on neighboring rows. That keeps the
|
|
7
|
+
* sidebar stable as filters change and the file set grows, and keeps the
|
|
8
|
+
* function trivially predictable. Disambiguation against same-basename
|
|
9
|
+
* siblings is the header's job (it shows the full relative path of the
|
|
10
|
+
* selected row); a future auto-scroll on the selected sidebar row can carry
|
|
11
|
+
* the same information without altering layout for the rest.
|
|
12
|
+
*
|
|
13
|
+
* Truncation policy (head-elide, segment-aware):
|
|
14
|
+
* - Full parent fits → render whole.
|
|
15
|
+
* - Else drop leading segments one at a time, prefixed with `…/`, until
|
|
16
|
+
* the remainder fits — never chops a segment mid-character.
|
|
17
|
+
* - When even the tail segment with `…/` overflows, drop the marker.
|
|
18
|
+
* - When the tail segment alone overflows, hard-truncate it from its head
|
|
19
|
+
* (leading `…`) as a last resort.
|
|
20
|
+
*
|
|
21
|
+
* Why head-elide: the immediate parent is the segment closest to the file
|
|
22
|
+
* and the most universally meaningful one when context shrinks.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export const SIDEBAR_ROW_SEPARATOR = " · "
|
|
26
|
+
const ELISION_PREFIX = "…/"
|
|
27
|
+
const MIN_PARENT_BUDGET = 3
|
|
28
|
+
|
|
29
|
+
export interface SidebarRowParts {
|
|
30
|
+
readonly basename: string
|
|
31
|
+
readonly separator: string
|
|
32
|
+
readonly parent: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const formatSidebarRow = (relativePath: string, totalWidth: number): SidebarRowParts => {
|
|
36
|
+
const slash = relativePath.lastIndexOf("/")
|
|
37
|
+
if (slash < 0) {
|
|
38
|
+
return { basename: fitTail(relativePath, totalWidth), separator: "", parent: "" }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const basename = relativePath.slice(slash + 1)
|
|
42
|
+
const parentFull = relativePath.slice(0, slash)
|
|
43
|
+
|
|
44
|
+
if (basename.length >= totalWidth) {
|
|
45
|
+
return { basename: fitTail(basename, totalWidth), separator: "", parent: "" }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const remaining = totalWidth - basename.length - SIDEBAR_ROW_SEPARATOR.length
|
|
49
|
+
if (remaining < MIN_PARENT_BUDGET || parentFull.length === 0) {
|
|
50
|
+
return { basename, separator: "", parent: "" }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (parentFull.length <= remaining) {
|
|
54
|
+
return row(basename, parentFull)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const segments = parentFull.split("/")
|
|
58
|
+
for (let k = segments.length - 1; k >= 1; k--) {
|
|
59
|
+
const candidate = ELISION_PREFIX + segments.slice(segments.length - k).join("/")
|
|
60
|
+
if (candidate.length <= remaining) return row(basename, candidate)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Even one segment with the `…/` marker doesn't fit. Try without the marker.
|
|
64
|
+
const tail = segments[segments.length - 1]!
|
|
65
|
+
if (tail.length <= remaining) return row(basename, tail)
|
|
66
|
+
|
|
67
|
+
// Hard-chop the tail segment from its head as a last resort.
|
|
68
|
+
return row(basename, "…" + tail.slice(tail.length - remaining + 1))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const row = (basename: string, parent: string): SidebarRowParts => ({
|
|
72
|
+
basename,
|
|
73
|
+
separator: SIDEBAR_ROW_SEPARATOR,
|
|
74
|
+
parent,
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const fitTail = (s: string, width: number): string => {
|
|
78
|
+
if (s.length <= width) return s
|
|
79
|
+
// At width ≤ 1 there's no room for both a character and the ellipsis; emit
|
|
80
|
+
// a single char so the result respects the budget. Callers currently clamp
|
|
81
|
+
// width to ≥ 4, but the helper carries its own floor so a tighter future
|
|
82
|
+
// caller can't silently overflow the column.
|
|
83
|
+
if (width <= 1) return s.slice(0, 1)
|
|
84
|
+
return s.slice(0, width - 1) + "…"
|
|
85
|
+
}
|
package/src/serve/server.ts
CHANGED
|
@@ -86,9 +86,12 @@ export const startServer = ({ path, port = 0 }: StartOptions): ServerHandle => {
|
|
|
86
86
|
// user's local files, so leaking them to the network would be a
|
|
87
87
|
// surprise. URL strings are localhost-only by construction below.
|
|
88
88
|
hostname: "127.0.0.1",
|
|
89
|
-
async fetch(req) {
|
|
89
|
+
async fetch(req, server) {
|
|
90
90
|
const url = new URL(req.url)
|
|
91
91
|
if (url.pathname === "/__reload") {
|
|
92
|
+
// SSE stream is silent between file changes; without this Bun
|
|
93
|
+
// closes the request at the default 10s idleTimeout and warns.
|
|
94
|
+
server.timeout(req, 0)
|
|
92
95
|
// `cancel` receives a reason, not the controller — capture
|
|
93
96
|
// the controller in `start` so we can remove it from the set
|
|
94
97
|
// on disconnect. Without this, dead clients accumulate.
|
package/src/theme/colors.ts
CHANGED
|
@@ -9,24 +9,60 @@ import type { ColorPalette, ResolvedTheme, ThemeDefinition, Tone } from "./types
|
|
|
9
9
|
* - UI tokens map name-for-name where they overlap.
|
|
10
10
|
* - `surface` ← `backgroundPanel`, `selectedBg` ← `backgroundElement`,
|
|
11
11
|
* `selectedBgInactive` ← `borderSubtle`.
|
|
12
|
-
* - `textStrong`
|
|
13
|
-
* text
|
|
12
|
+
* - `textStrong` ← `primary` (opencode's convention for emphasized brand
|
|
13
|
+
* text in UI chrome). Markdown rendering still reads `markdownStrong`
|
|
14
|
+
* directly via the syntax map.
|
|
14
15
|
* - `syntax` is a fully populated opentui tree-sitter scope map built from
|
|
15
16
|
* `markdown*` and `syntax*` tokens.
|
|
16
17
|
*/
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
18
|
+
/** Relative luminance of a `#rrggbb` color (0..1). Sufficient for ordering
|
|
19
|
+
* two near-neutral chrome colors — not a full WCAG contrast calculation.
|
|
20
|
+
* Non-hex inputs fall back to 0.5 so the caller's ordering is a no-op. */
|
|
21
|
+
const luminance = (hex: string): number => {
|
|
22
|
+
const m = /^#([0-9a-fA-F]{6})$/.exec(hex)
|
|
23
|
+
if (!m) return 0.5
|
|
24
|
+
const h = m[1]!
|
|
25
|
+
const r = parseInt(h.slice(0, 2), 16)
|
|
26
|
+
const g = parseInt(h.slice(2, 4), 16)
|
|
27
|
+
const b = parseInt(h.slice(4, 6), 16)
|
|
28
|
+
// Rec. 601 weighting — perceptual ordering, not the linearized WCAG variant.
|
|
29
|
+
return (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Pane chrome assumes the active/raised pane sits on the darker of the two
|
|
33
|
+
* background tokens and the dim chrome on the lighter one. Most themes
|
|
34
|
+
* define `background` darker than `backgroundPanel` and this is a no-op;
|
|
35
|
+
* some (e.g. cursor) flip the polarity, in which case we swap so the
|
|
36
|
+
* active-pane convention stays consistent across themes. */
|
|
37
|
+
const orientChrome = (r: ResolvedTheme): { raised: string; dim: string } => {
|
|
38
|
+
const bg = r.background
|
|
39
|
+
const panel = r.backgroundPanel
|
|
40
|
+
return luminance(bg) <= luminance(panel) ? { raised: bg, dim: panel } : { raised: panel, dim: bg }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const buildPalette = (r: ResolvedTheme): ColorPalette => {
|
|
44
|
+
const { raised, dim } = orientChrome(r)
|
|
45
|
+
return {
|
|
46
|
+
background: raised,
|
|
47
|
+
surface: dim,
|
|
48
|
+
text: r.text,
|
|
49
|
+
textStrong: r.primary,
|
|
50
|
+
textMuted: r.textMuted,
|
|
51
|
+
border: r.border,
|
|
52
|
+
borderActive: r.borderActive,
|
|
53
|
+
selectedBg: r.backgroundElement,
|
|
54
|
+
selectedBgInactive: r.borderSubtle,
|
|
55
|
+
selectedListItemText: r.selectedListItemText,
|
|
56
|
+
primary: r.primary,
|
|
57
|
+
secondary: r.secondary,
|
|
58
|
+
accent: r.accent,
|
|
59
|
+
error: r.error,
|
|
60
|
+
warning: r.warning,
|
|
61
|
+
success: r.success,
|
|
62
|
+
info: r.info,
|
|
63
|
+
syntax: buildSyntaxMap(r),
|
|
64
|
+
}
|
|
65
|
+
}
|
|
30
66
|
|
|
31
67
|
const buildSyntaxMap = (r: ResolvedTheme): Record<string, StyleDefinitionInput> => {
|
|
32
68
|
const codeBg = r.backgroundPanel
|
package/src/theme/types.ts
CHANGED
|
@@ -97,7 +97,14 @@ export interface ColorPalette {
|
|
|
97
97
|
readonly borderActive: string
|
|
98
98
|
readonly selectedBg: string
|
|
99
99
|
readonly selectedBgInactive: string
|
|
100
|
+
readonly selectedListItemText: string
|
|
101
|
+
readonly primary: string
|
|
102
|
+
readonly secondary: string
|
|
103
|
+
readonly accent: string
|
|
100
104
|
readonly error: string
|
|
105
|
+
readonly warning: string
|
|
106
|
+
readonly success: string
|
|
107
|
+
readonly info: string
|
|
101
108
|
readonly syntax: Record<string, StyleDefinitionInput>
|
|
102
109
|
}
|
|
103
110
|
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Update-check cache. Stores the last npm-registry probe result on disk so
|
|
3
|
+
* we hit the registry at most once per TTL window.
|
|
4
|
+
*
|
|
5
|
+
* Layout: `$XDG_CACHE_HOME/house/update-check.json` (fallback
|
|
6
|
+
* `~/.cache/house/update-check.json`). All IO failures are non-fatal — a
|
|
7
|
+
* missing or unparseable cache simply forces a fresh probe.
|
|
8
|
+
*
|
|
9
|
+
* Schema is intentionally minimal. `tarballOk` distinguishes "we saw the
|
|
10
|
+
* version and confirmed the tarball is downloadable" from "we saw the
|
|
11
|
+
* version but the CDN HEAD failed" — only the former gates the notice. A
|
|
12
|
+
* `false` entry forces a retry on the next launch rather than waiting out
|
|
13
|
+
* the TTL.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { dirname, join } from "node:path"
|
|
17
|
+
import { homedir } from "node:os"
|
|
18
|
+
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises"
|
|
19
|
+
|
|
20
|
+
export interface UpdateCacheRecord {
|
|
21
|
+
readonly checkedAt: number
|
|
22
|
+
readonly latestVersion: string
|
|
23
|
+
readonly tarballOk: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const cacheDir = (): string => {
|
|
27
|
+
const xdg = process.env.XDG_CACHE_HOME
|
|
28
|
+
if (xdg && xdg.length > 0) return join(xdg, "house")
|
|
29
|
+
return join(homedir(), ".cache", "house")
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const cachePath = (): string => join(cacheDir(), "update-check.json")
|
|
33
|
+
|
|
34
|
+
export const readCache = async (path = cachePath()): Promise<UpdateCacheRecord | null> => {
|
|
35
|
+
try {
|
|
36
|
+
const raw = await readFile(path, "utf8")
|
|
37
|
+
const parsed = JSON.parse(raw) as unknown
|
|
38
|
+
if (typeof parsed !== "object" || parsed === null) return null
|
|
39
|
+
const r = parsed as Record<string, unknown>
|
|
40
|
+
if (
|
|
41
|
+
typeof r.checkedAt !== "number" ||
|
|
42
|
+
typeof r.latestVersion !== "string" ||
|
|
43
|
+
typeof r.tarballOk !== "boolean"
|
|
44
|
+
) {
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
checkedAt: r.checkedAt,
|
|
49
|
+
latestVersion: r.latestVersion,
|
|
50
|
+
tarballOk: r.tarballOk,
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const writeCache = async (record: UpdateCacheRecord, path = cachePath()): Promise<void> => {
|
|
58
|
+
// Atomic write: writeFile to a sibling tmp path, then rename. Rename is
|
|
59
|
+
// atomic on POSIX, so a process.exit() racing the writer either leaves
|
|
60
|
+
// the prior file intact or installs the new one fully — never a partial
|
|
61
|
+
// JSON blob that readCache would have to ignore.
|
|
62
|
+
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`
|
|
63
|
+
try {
|
|
64
|
+
await mkdir(dirname(path), { recursive: true })
|
|
65
|
+
await writeFile(tmp, JSON.stringify(record), "utf8")
|
|
66
|
+
await rename(tmp, path)
|
|
67
|
+
} catch {
|
|
68
|
+
// Cache writes are best-effort. A read-only HOME or full disk should
|
|
69
|
+
// not break the app; we'll just re-probe on the next launch. Clean up
|
|
70
|
+
// the tmp file if writeFile partially succeeded but rename did not.
|
|
71
|
+
try {
|
|
72
|
+
await unlink(tmp)
|
|
73
|
+
} catch {
|
|
74
|
+
// best-effort
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Update probe. Resolves to an upgrade record iff a strictly-newer version
|
|
3
|
+
* is published on npm AND its tarball is actually downloadable.
|
|
4
|
+
*
|
|
5
|
+
* The HEAD on `dist.tarball` is the load-bearing step: the npm registry can
|
|
6
|
+
* publish version metadata moments before the CDN serves the tarball, and
|
|
7
|
+
* we promised not to nag the user toward a version they cannot install
|
|
8
|
+
* yet. A non-200 HEAD invalidates the cache entry (tarballOk=false) so the
|
|
9
|
+
* next launch retries instead of waiting out the TTL.
|
|
10
|
+
*
|
|
11
|
+
* All failures are silent. The notice surface is opportunistic — the user
|
|
12
|
+
* should never see an error from a feature whose job is to whisper.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { isNewer } from "./compare.ts"
|
|
16
|
+
import { readCache, writeCache, type UpdateCacheRecord } from "./cache.ts"
|
|
17
|
+
|
|
18
|
+
export const TTL_MS = 24 * 60 * 60 * 1000
|
|
19
|
+
const FETCH_TIMEOUT_MS = 3000
|
|
20
|
+
const REGISTRY_URL = (pkgName: string) => `https://registry.npmjs.org/${pkgName}/latest`
|
|
21
|
+
|
|
22
|
+
export interface UpdateInfo {
|
|
23
|
+
readonly pkgName: string
|
|
24
|
+
readonly currentVersion: string
|
|
25
|
+
readonly latestVersion: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface CheckOptions {
|
|
29
|
+
readonly pkgName: string
|
|
30
|
+
readonly currentVersion: string
|
|
31
|
+
/** Override for tests. Defaults to the npm registry + global fetch. */
|
|
32
|
+
readonly now?: () => number
|
|
33
|
+
readonly env?: Record<string, string | undefined>
|
|
34
|
+
readonly fetchImpl?: typeof fetch
|
|
35
|
+
readonly cacheRead?: () => Promise<UpdateCacheRecord | null>
|
|
36
|
+
readonly cacheWrite?: (r: UpdateCacheRecord) => Promise<void>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Treat any non-empty, non-`0`, non-`false` value as opt-out. Mirrors the
|
|
40
|
+
* permissiveness of the de-facto npm-ecosystem convention; a user copy-
|
|
41
|
+
* pasting `NO_UPDATE_NOTIFIER=true` from another tool's docs should work. */
|
|
42
|
+
const isTruthyEnv = (value: string | undefined): boolean => {
|
|
43
|
+
if (!value) return false
|
|
44
|
+
const v = value.toLowerCase()
|
|
45
|
+
return v !== "0" && v !== "false" && v !== "no"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Most major CIs (GitHub Actions, GitLab, Travis, CircleCI, Jenkins) set
|
|
49
|
+
* `CI=true`. A few set it to other truthy values; treat any non-empty
|
|
50
|
+
* value as "we're in CI, skip the nag." */
|
|
51
|
+
const isCi = (env: Record<string, string | undefined>): boolean =>
|
|
52
|
+
typeof env.CI === "string" &&
|
|
53
|
+
env.CI.length > 0 &&
|
|
54
|
+
env.CI !== "0" &&
|
|
55
|
+
env.CI.toLowerCase() !== "false"
|
|
56
|
+
|
|
57
|
+
/** Run a fetch with a single timeout that covers BOTH the response headers
|
|
58
|
+
* and the caller's body read. Returning the bare Response and then reading
|
|
59
|
+
* `res.json()` outside the timer leaves the body stream unbounded — a
|
|
60
|
+
* stalled connection after headers would hang forever. The consumer
|
|
61
|
+
* callback runs while the AbortController is still live, so an abort
|
|
62
|
+
* cancels an in-flight body read too. */
|
|
63
|
+
const fetchWithTimeout = async <T>(
|
|
64
|
+
url: string,
|
|
65
|
+
init: RequestInit,
|
|
66
|
+
consume: (res: Response) => Promise<T>,
|
|
67
|
+
fetchImpl: typeof fetch = fetch,
|
|
68
|
+
): Promise<T> => {
|
|
69
|
+
const ctrl = new AbortController()
|
|
70
|
+
const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS)
|
|
71
|
+
try {
|
|
72
|
+
const res = await fetchImpl(url, { ...init, signal: ctrl.signal })
|
|
73
|
+
return await consume(res)
|
|
74
|
+
} finally {
|
|
75
|
+
clearTimeout(timer)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Run the probe and return upgrade info if one applies. `null` covers
|
|
81
|
+
* every "no nag" case: opt-out, recent cache hit on the same version, no
|
|
82
|
+
* newer version, registry/CDN failure, malformed response.
|
|
83
|
+
*/
|
|
84
|
+
export const checkForUpdate = async (opts: CheckOptions): Promise<UpdateInfo | null> => {
|
|
85
|
+
const env = opts.env ?? process.env
|
|
86
|
+
if (isTruthyEnv(env.NO_UPDATE_NOTIFIER) || isCi(env)) {
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const now = opts.now ?? Date.now
|
|
91
|
+
const cacheRead = opts.cacheRead ?? (() => readCache())
|
|
92
|
+
const cacheWrite = opts.cacheWrite ?? ((r: UpdateCacheRecord) => writeCache(r))
|
|
93
|
+
|
|
94
|
+
const cached = await cacheRead()
|
|
95
|
+
const fresh = cached !== null && cached.tarballOk && now() - cached.checkedAt < TTL_MS
|
|
96
|
+
if (fresh) {
|
|
97
|
+
return isNewer(cached.latestVersion, opts.currentVersion)
|
|
98
|
+
? {
|
|
99
|
+
pkgName: opts.pkgName,
|
|
100
|
+
currentVersion: opts.currentVersion,
|
|
101
|
+
latestVersion: cached.latestVersion,
|
|
102
|
+
}
|
|
103
|
+
: null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Probe the registry.
|
|
107
|
+
let latestVersion: string
|
|
108
|
+
let tarballUrl: string
|
|
109
|
+
try {
|
|
110
|
+
const parsed = await fetchWithTimeout(
|
|
111
|
+
REGISTRY_URL(opts.pkgName),
|
|
112
|
+
{ headers: { accept: "application/json" } },
|
|
113
|
+
async (res) => {
|
|
114
|
+
if (!res.ok) return null
|
|
115
|
+
const body = (await res.json()) as unknown
|
|
116
|
+
if (typeof body !== "object" || body === null) return null
|
|
117
|
+
const obj = body as Record<string, unknown>
|
|
118
|
+
const version = obj.version
|
|
119
|
+
const dist = obj.dist as Record<string, unknown> | undefined
|
|
120
|
+
const tarball = dist?.tarball
|
|
121
|
+
if (typeof version !== "string" || typeof tarball !== "string") return null
|
|
122
|
+
// The registry returns a CDN URL we're about to HEAD without
|
|
123
|
+
// further validation. Pin to https so a compromised or proxied
|
|
124
|
+
// registry can't redirect us to file://, http://, or another
|
|
125
|
+
// scheme we'd issue a request against.
|
|
126
|
+
try {
|
|
127
|
+
if (new URL(tarball).protocol !== "https:") return null
|
|
128
|
+
} catch {
|
|
129
|
+
return null
|
|
130
|
+
}
|
|
131
|
+
return { version, tarball }
|
|
132
|
+
},
|
|
133
|
+
opts.fetchImpl,
|
|
134
|
+
)
|
|
135
|
+
if (!parsed) return null
|
|
136
|
+
latestVersion = parsed.version
|
|
137
|
+
tarballUrl = parsed.tarball
|
|
138
|
+
} catch {
|
|
139
|
+
return null
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Verify the artifact is actually downloadable. This is the step that
|
|
143
|
+
// makes "only announced when the artifact is available" hold.
|
|
144
|
+
let tarballOk = false
|
|
145
|
+
try {
|
|
146
|
+
tarballOk = await fetchWithTimeout(
|
|
147
|
+
tarballUrl,
|
|
148
|
+
{ method: "HEAD" },
|
|
149
|
+
async (res) => res.ok,
|
|
150
|
+
opts.fetchImpl,
|
|
151
|
+
)
|
|
152
|
+
} catch {
|
|
153
|
+
tarballOk = false
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
await cacheWrite({ checkedAt: now(), latestVersion, tarballOk })
|
|
157
|
+
|
|
158
|
+
if (!tarballOk) return null
|
|
159
|
+
if (!isNewer(latestVersion, opts.currentVersion)) return null
|
|
160
|
+
return {
|
|
161
|
+
pkgName: opts.pkgName,
|
|
162
|
+
currentVersion: opts.currentVersion,
|
|
163
|
+
latestVersion,
|
|
164
|
+
}
|
|
165
|
+
}
|