@carlesandres/house 0.3.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -128
- package/README.md +40 -12
- package/package.json +3 -2
- package/src/Browser.tsx +482 -109
- package/src/CommandPalette.tsx +150 -0
- package/src/Footer.tsx +122 -55
- package/src/Header.tsx +68 -0
- package/src/HelpOverlay.tsx +59 -41
- package/src/brand.ts +7 -0
- package/src/cli/argv.ts +61 -2
- package/src/commands/buildCommands.ts +102 -0
- package/src/commands/paletteOnlyCommands.ts +16 -0
- package/src/commands/score.ts +78 -0
- package/src/commands/types.ts +26 -0
- package/src/config/load.ts +231 -0
- package/src/discovery/walk.ts +67 -23
- package/src/index.tsx +163 -32
- package/src/keymap/browser.ts +26 -11
- package/src/layout/resolve.ts +60 -0
- 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/index.tsx
CHANGED
|
@@ -10,25 +10,31 @@
|
|
|
10
10
|
|
|
11
11
|
import { stat } from "node:fs/promises"
|
|
12
12
|
import { createCliRenderer, SyntaxStyle } from "@opentui/core"
|
|
13
|
+
import type { BorderSides } from "@opentui/core"
|
|
13
14
|
import { createRoot, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
|
|
14
15
|
import { RegistryProvider, useAtomSet, useAtomValue } from "@effect/atom-react"
|
|
15
|
-
import { Effect } from "effect"
|
|
16
|
-
import { useMemo } from "react"
|
|
16
|
+
import { Cause, Duration, Effect, Fiber, Stream } from "effect"
|
|
17
|
+
import { useEffect, useMemo, useRef, useState } from "react"
|
|
17
18
|
import pkg from "../package.json" with { type: "json" }
|
|
18
19
|
import { Browser } from "./Browser.tsx"
|
|
19
20
|
import { parseArgv, usage } from "./cli/argv.ts"
|
|
20
|
-
import {
|
|
21
|
+
import { defaultConfigPath, formatConfigError, loadConfig } from "./config/load.ts"
|
|
22
|
+
import { walk, type FileEntry, type SortOrder } from "./discovery/walk.ts"
|
|
23
|
+
import { Header } from "./Header.tsx"
|
|
21
24
|
import { readFileText } from "./io/readFile.ts"
|
|
22
25
|
import { openInBrowser } from "./serve/openBrowser.ts"
|
|
23
26
|
import { startServer } from "./serve/server.ts"
|
|
24
27
|
import { colors, setActiveTheme } from "./theme/colors.ts"
|
|
25
28
|
import { themeAtom, type ThemeState } from "./theme/atom.ts"
|
|
26
|
-
import { getThemeDefinition,
|
|
29
|
+
import { getThemeDefinition, themeDefinitions } from "./theme/registry.ts"
|
|
30
|
+
import { formatQuitNotice } from "./update/notice.ts"
|
|
31
|
+
import { currentUpdateInfo, startUpdateProbe } from "./update/runtime.ts"
|
|
32
|
+
import { useUpdateNotice } from "./update/useUpdateNotice.ts"
|
|
27
33
|
|
|
28
34
|
export interface AppProps {
|
|
29
35
|
/** Markdown source to render. */
|
|
30
36
|
readonly content: string
|
|
31
|
-
/** Optional title shown in the
|
|
37
|
+
/** Optional title shown in the header's current-file slot. Defaults to a generic label. */
|
|
32
38
|
readonly title?: string
|
|
33
39
|
/** Cap the rendered markdown's width at N columns (left-aligned). Null = fill the pane. */
|
|
34
40
|
readonly maxWidth?: number | null
|
|
@@ -36,6 +42,80 @@ export interface AppProps {
|
|
|
36
42
|
readonly onQuit?: () => void
|
|
37
43
|
}
|
|
38
44
|
|
|
45
|
+
/**
|
|
46
|
+
* DiscoverShell — owns the streaming walk for directory mode. Mounts Browser
|
|
47
|
+
* immediately with `files=[]` and pushes entries as the stream emits.
|
|
48
|
+
*
|
|
49
|
+
* Batching: `Stream.groupedWithin(64, 60ms)` coalesces bursts so we don't
|
|
50
|
+
* trigger one React render per file. Tuned by feel — small enough that
|
|
51
|
+
* results still feel live on tiny trees, large enough to keep render
|
|
52
|
+
* frequency sane on big ones. Revisit if profiling says otherwise.
|
|
53
|
+
*
|
|
54
|
+
* Cancellation: the walk runs on a forked fiber; unmount interrupts it.
|
|
55
|
+
* `Quit` in Browser tears down the renderer and exits, which propagates
|
|
56
|
+
* naturally — the cleanup effect still fires before process.exit completes.
|
|
57
|
+
*/
|
|
58
|
+
export type SidebarMode = "auto" | "on" | "off"
|
|
59
|
+
|
|
60
|
+
interface DiscoverShellProps {
|
|
61
|
+
readonly target: string
|
|
62
|
+
readonly all: boolean
|
|
63
|
+
readonly sort: SortOrder
|
|
64
|
+
readonly mdx: boolean
|
|
65
|
+
readonly maxWidth: number | null
|
|
66
|
+
readonly sidebarMode: SidebarMode
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const DiscoverShell = ({ target, all, sort, mdx, maxWidth, sidebarMode }: DiscoverShellProps) => {
|
|
70
|
+
const updateNotice = useUpdateNotice()
|
|
71
|
+
const [files, setFiles] = useState<readonly FileEntry[]>([])
|
|
72
|
+
const [scanning, setScanning] = useState<boolean>(true)
|
|
73
|
+
const [scanError, setScanError] = useState<string | null>(null)
|
|
74
|
+
// Files arrive in a ref-tracked count so the status string can show
|
|
75
|
+
// "indexing… N" even when React hasn't yet flushed the latest setFiles.
|
|
76
|
+
const countRef = useRef(0)
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
const program = walk(target, { all, sort, mdx }).pipe(
|
|
80
|
+
Stream.groupedWithin(64, Duration.millis(60)),
|
|
81
|
+
Stream.runForEach((chunk) =>
|
|
82
|
+
Effect.sync(() => {
|
|
83
|
+
const arr = Array.from(chunk)
|
|
84
|
+
if (arr.length === 0) return
|
|
85
|
+
countRef.current += arr.length
|
|
86
|
+
setFiles((prev) => [...prev, ...arr])
|
|
87
|
+
}),
|
|
88
|
+
),
|
|
89
|
+
Effect.matchCauseEffect({
|
|
90
|
+
onSuccess: () => Effect.sync(() => setScanning(false)),
|
|
91
|
+
onFailure: (cause) =>
|
|
92
|
+
Effect.sync(() => {
|
|
93
|
+
// Interruption is the normal teardown path — don't surface it.
|
|
94
|
+
if (Cause.hasInterrupts(cause)) return
|
|
95
|
+
setScanError(`scan failed: ${Cause.pretty(cause)}`)
|
|
96
|
+
setScanning(false)
|
|
97
|
+
}),
|
|
98
|
+
}),
|
|
99
|
+
)
|
|
100
|
+
const fiber = Effect.runFork(program)
|
|
101
|
+
return () => {
|
|
102
|
+
Effect.runFork(Fiber.interrupt(fiber))
|
|
103
|
+
}
|
|
104
|
+
}, [target, all, sort, mdx])
|
|
105
|
+
|
|
106
|
+
const discoveryStatus = scanError ?? (scanning ? `indexing… ${countRef.current}` : null)
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<Browser
|
|
110
|
+
files={files}
|
|
111
|
+
maxWidth={maxWidth}
|
|
112
|
+
discoveryStatus={discoveryStatus}
|
|
113
|
+
sidebarMode={sidebarMode}
|
|
114
|
+
updateNotice={updateNotice}
|
|
115
|
+
/>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
39
119
|
export const App = ({ content, title = "house", maxWidth = null, onQuit }: AppProps) => {
|
|
40
120
|
const renderer = useRenderer()
|
|
41
121
|
const { width, height } = useTerminalDimensions()
|
|
@@ -72,13 +152,14 @@ export const App = ({ content, title = "house", maxWidth = null, onQuit }: AppPr
|
|
|
72
152
|
if (key.name === "l" && key.shift) toggleTone()
|
|
73
153
|
})
|
|
74
154
|
|
|
155
|
+
const paneBorderSides: BorderSides[] = ["top", "bottom"]
|
|
156
|
+
|
|
75
157
|
return (
|
|
76
158
|
<box style={{ width, height, flexDirection: "column", backgroundColor: colors.background }}>
|
|
159
|
+
<Header width={width} currentFile={title} />
|
|
77
160
|
<box
|
|
78
|
-
title={` ${title} `}
|
|
79
|
-
titleAlignment="left"
|
|
80
161
|
style={{
|
|
81
|
-
border:
|
|
162
|
+
border: paneBorderSides,
|
|
82
163
|
borderColor: colors.border,
|
|
83
164
|
padding: 1,
|
|
84
165
|
flexGrow: 1,
|
|
@@ -110,6 +191,8 @@ export const App = ({ content, title = "house", maxWidth = null, onQuit }: AppPr
|
|
|
110
191
|
)
|
|
111
192
|
}
|
|
112
193
|
|
|
194
|
+
let updateExitHookRegistered = false
|
|
195
|
+
|
|
113
196
|
if (import.meta.main) {
|
|
114
197
|
const args = parseArgv(Bun.argv.slice(2))
|
|
115
198
|
|
|
@@ -121,20 +204,29 @@ if (import.meta.main) {
|
|
|
121
204
|
console.log(pkg.version)
|
|
122
205
|
process.exit(0)
|
|
123
206
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const known = themeDefinitions.map((t) => t.id).join(", ")
|
|
128
|
-
console.error(`house: unknown theme "${themeId}". Known: ${known}`)
|
|
129
|
-
process.exit(2)
|
|
207
|
+
if (args.configPath) {
|
|
208
|
+
console.log(defaultConfigPath())
|
|
209
|
+
process.exit(0)
|
|
130
210
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
211
|
+
|
|
212
|
+
const config = await Effect.runPromise(
|
|
213
|
+
loadConfig({
|
|
214
|
+
cli: {
|
|
215
|
+
theme: args.theme,
|
|
216
|
+
tone: args.tone,
|
|
217
|
+
// --no-mdx is a one-way override: present means "off". When
|
|
218
|
+
// absent, fall through to env/file/default.
|
|
219
|
+
mdx: args.noMdx ? false : null,
|
|
220
|
+
},
|
|
221
|
+
}),
|
|
222
|
+
).catch((err: unknown) => {
|
|
223
|
+
console.error(`house: ${formatConfigError(err)}`)
|
|
134
224
|
process.exit(2)
|
|
135
|
-
}
|
|
225
|
+
})
|
|
226
|
+
const { theme: themeId, tone, mdx } = config
|
|
136
227
|
const themeDef = getThemeDefinition(themeId)
|
|
137
228
|
if (themeDef === undefined) {
|
|
229
|
+
// Unreachable: Config.schema validated themeId against themeDefinitions.
|
|
138
230
|
console.error(`house: unknown theme "${themeId}"`)
|
|
139
231
|
process.exit(2)
|
|
140
232
|
}
|
|
@@ -193,7 +285,25 @@ if (import.meta.main) {
|
|
|
193
285
|
}
|
|
194
286
|
sort = args.sort
|
|
195
287
|
}
|
|
196
|
-
|
|
288
|
+
let sidebarMode: SidebarMode = "auto"
|
|
289
|
+
if (args.sidebar !== null) {
|
|
290
|
+
if (args.sidebar !== "auto" && args.sidebar !== "on" && args.sidebar !== "off") {
|
|
291
|
+
console.error(`house: --sidebar must be "auto", "on", or "off", got "${args.sidebar}"`)
|
|
292
|
+
process.exit(2)
|
|
293
|
+
}
|
|
294
|
+
sidebarMode = args.sidebar
|
|
295
|
+
}
|
|
296
|
+
await runTui({
|
|
297
|
+
target,
|
|
298
|
+
themeId,
|
|
299
|
+
tone,
|
|
300
|
+
maxWidth,
|
|
301
|
+
all: args.all,
|
|
302
|
+
sort,
|
|
303
|
+
mdx,
|
|
304
|
+
sidebarMode,
|
|
305
|
+
updateCheck: !args.noUpdateCheck,
|
|
306
|
+
})
|
|
197
307
|
}
|
|
198
308
|
}
|
|
199
309
|
|
|
@@ -204,6 +314,11 @@ interface TuiBootOptions {
|
|
|
204
314
|
readonly maxWidth: number | null
|
|
205
315
|
readonly all: boolean
|
|
206
316
|
readonly sort: SortOrder
|
|
317
|
+
readonly mdx: boolean
|
|
318
|
+
readonly sidebarMode: SidebarMode
|
|
319
|
+
/** Run the npm-registry probe and surface the "update available" notice.
|
|
320
|
+
* False suppresses both the toast and the quit-time print. */
|
|
321
|
+
readonly updateCheck: boolean
|
|
207
322
|
}
|
|
208
323
|
|
|
209
324
|
async function runTui({
|
|
@@ -213,6 +328,9 @@ async function runTui({
|
|
|
213
328
|
maxWidth,
|
|
214
329
|
all,
|
|
215
330
|
sort,
|
|
331
|
+
mdx,
|
|
332
|
+
sidebarMode,
|
|
333
|
+
updateCheck,
|
|
216
334
|
}: TuiBootOptions): Promise<void> {
|
|
217
335
|
let stats: Awaited<ReturnType<typeof stat>>
|
|
218
336
|
try {
|
|
@@ -222,25 +340,38 @@ async function runTui({
|
|
|
222
340
|
process.exit(1)
|
|
223
341
|
}
|
|
224
342
|
|
|
343
|
+
if (updateCheck) {
|
|
344
|
+
// Fire the npm-registry probe in the background. Result lands in a
|
|
345
|
+
// module singleton; the React tree picks it up via `useUpdateNotice`
|
|
346
|
+
// for the footer toast, and the 'exit' hook below reads it
|
|
347
|
+
// synchronously for the scrollback print. Failures are silent — this
|
|
348
|
+
// whole feature is opportunistic.
|
|
349
|
+
startUpdateProbe(pkg.name, pkg.version)
|
|
350
|
+
// Register once per process. Multiple 'exit' listeners would print
|
|
351
|
+
// the notice multiple times if runTui were ever re-entered.
|
|
352
|
+
if (!updateExitHookRegistered) {
|
|
353
|
+
updateExitHookRegistered = true
|
|
354
|
+
process.on("exit", () => {
|
|
355
|
+
const info = currentUpdateInfo()
|
|
356
|
+
if (info) process.stderr.write(formatQuitNotice(info))
|
|
357
|
+
})
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
225
361
|
const renderer = await createCliRenderer({ exitOnCtrlC: false })
|
|
226
362
|
const initialTheme: ThemeState = { id: themeId, tone }
|
|
227
363
|
|
|
228
364
|
if (stats.isDirectory()) {
|
|
229
|
-
const files = await Effect.runPromise(
|
|
230
|
-
walk(target, { all, sort }).pipe(
|
|
231
|
-
Effect.tapError((err) =>
|
|
232
|
-
Effect.sync(() => {
|
|
233
|
-
console.error(`house: cannot walk ${target}: ${String(err.cause)}`)
|
|
234
|
-
}),
|
|
235
|
-
),
|
|
236
|
-
),
|
|
237
|
-
).catch(() => {
|
|
238
|
-
process.exit(1)
|
|
239
|
-
})
|
|
240
|
-
if (!Array.isArray(files)) process.exit(1)
|
|
241
365
|
createRoot(renderer).render(
|
|
242
366
|
<RegistryProvider initialValues={[[themeAtom, initialTheme]]}>
|
|
243
|
-
<
|
|
367
|
+
<DiscoverShell
|
|
368
|
+
target={target}
|
|
369
|
+
all={all}
|
|
370
|
+
sort={sort}
|
|
371
|
+
mdx={mdx}
|
|
372
|
+
maxWidth={maxWidth}
|
|
373
|
+
sidebarMode={sidebarMode}
|
|
374
|
+
/>
|
|
244
375
|
</RegistryProvider>,
|
|
245
376
|
)
|
|
246
377
|
} else {
|
package/src/keymap/browser.ts
CHANGED
|
@@ -11,14 +11,18 @@ export type BrowserFocus = "sidebar" | "reader"
|
|
|
11
11
|
export interface BrowserCtx {
|
|
12
12
|
readonly files: readonly FileEntry[]
|
|
13
13
|
readonly focus: BrowserFocus
|
|
14
|
-
|
|
14
|
+
/** User's sticky sidebar preference. Visibility is `shown || focus==="sidebar"`. */
|
|
15
|
+
readonly sidebarShown: boolean
|
|
15
16
|
readonly helpVisible: boolean
|
|
16
17
|
readonly filterOpen: boolean
|
|
18
|
+
readonly paletteOpen: boolean
|
|
17
19
|
readonly setFocus: (next: BrowserFocus | ((prev: BrowserFocus) => BrowserFocus)) => void
|
|
18
20
|
readonly setSelectedIndex: (updater: (prev: number) => number) => void
|
|
19
|
-
|
|
21
|
+
/** Toggle `shown` and adjust focus per DESIGN.md §7.1 (see s-behavior table). */
|
|
22
|
+
readonly toggleShown: () => void
|
|
20
23
|
readonly setHelpVisible: (updater: (prev: boolean) => boolean) => void
|
|
21
24
|
readonly openFilter: () => void
|
|
25
|
+
readonly openPalette: () => void
|
|
22
26
|
readonly cycleTheme: (delta: 1 | -1) => void
|
|
23
27
|
readonly toggleTone: () => void
|
|
24
28
|
readonly quit: () => void
|
|
@@ -37,7 +41,8 @@ const stepBy = (c: BrowserCtx, delta: number) =>
|
|
|
37
41
|
c.setSelectedIndex((i) => clamp(i + delta, 0, lastIndex(c)))
|
|
38
42
|
|
|
39
43
|
const inSidebar = (c: BrowserCtx) => c.focus === "sidebar"
|
|
40
|
-
const
|
|
44
|
+
const filterClosed = (c: BrowserCtx) => !c.filterOpen
|
|
45
|
+
const paletteClosed = (c: BrowserCtx) => !c.paletteOpen
|
|
41
46
|
const inReader = (c: BrowserCtx) => c.focus === "reader"
|
|
42
47
|
const inSidebarWithFiles = (c: BrowserCtx) => inSidebar(c) && haveFiles(c)
|
|
43
48
|
const inReaderWithFiles = (c: BrowserCtx) => inReader(c) && haveFiles(c)
|
|
@@ -66,13 +71,7 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
|
|
|
66
71
|
description: "Toggle sidebar visibility",
|
|
67
72
|
hint: "sidebar",
|
|
68
73
|
keys: ["s"],
|
|
69
|
-
run: (c) =>
|
|
70
|
-
const willHide = c.sidebarVisible
|
|
71
|
-
c.setSidebarVisible((v) => !v)
|
|
72
|
-
// When hiding the sidebar, move focus to the reader so input has a
|
|
73
|
-
// target. When revealing it, move focus back to the sidebar.
|
|
74
|
-
c.setFocus(willHide ? "reader" : "sidebar")
|
|
75
|
-
},
|
|
74
|
+
run: (c) => c.toggleShown(),
|
|
76
75
|
},
|
|
77
76
|
{
|
|
78
77
|
id: "help.toggle",
|
|
@@ -88,9 +87,25 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
|
|
|
88
87
|
description: "Filter files (fuzzy match on path)",
|
|
89
88
|
hint: "filter",
|
|
90
89
|
keys: ["/"],
|
|
91
|
-
|
|
90
|
+
// Fires from anywhere except inside an already-open filter. `openFilter`
|
|
91
|
+
// itself force-opens the sidebar and moves focus there, so the binding
|
|
92
|
+
// no longer needs to gate on focus or sidebar visibility.
|
|
93
|
+
when: filterClosed,
|
|
92
94
|
run: (c) => c.openFilter(),
|
|
93
95
|
},
|
|
96
|
+
{
|
|
97
|
+
id: "palette.open",
|
|
98
|
+
group: "Global",
|
|
99
|
+
description: "Command palette",
|
|
100
|
+
hint: "palette",
|
|
101
|
+
keys: ["ctrl+p"],
|
|
102
|
+
// Filter swallows ctrl+p as a typed character in its own branch, so this
|
|
103
|
+
// `when` only matters when the palette is already open (which it
|
|
104
|
+
// shouldn't re-open). #70 Q2 — fires from everywhere except the filter,
|
|
105
|
+
// closes help on its way in (handled in Browser.tsx).
|
|
106
|
+
when: paletteClosed,
|
|
107
|
+
run: (c) => c.openPalette(),
|
|
108
|
+
},
|
|
94
109
|
{
|
|
95
110
|
id: "serve.current",
|
|
96
111
|
group: "Global",
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layout primitives for the two-pane shape (sidebar + reader).
|
|
3
|
+
*
|
|
4
|
+
* Visibility and width are decoupled (see DESIGN.md §7.1):
|
|
5
|
+
* visible = shown || focus === "sidebar"
|
|
6
|
+
* Width is a pure function of viewport, independent of visibility:
|
|
7
|
+
* resolveSidebarWidth(viewport, preferred)
|
|
8
|
+
*
|
|
9
|
+
* Both render code and key handlers consume these primitives — there is no
|
|
10
|
+
* parallel implementation living in JSX.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Minimum useful sidebar width; below this, file rows truncate too aggressively. */
|
|
14
|
+
export const SIDEBAR_MIN_WIDTH = 28
|
|
15
|
+
/** Maximum sidebar width; beyond this, wasted whitespace on wide terminals. */
|
|
16
|
+
export const SIDEBAR_MAX_WIDTH = 60
|
|
17
|
+
/** Reader pane minimum; below this, prose wraps unpleasantly. */
|
|
18
|
+
export const READER_MIN_WIDTH = 40
|
|
19
|
+
/** Column gap painted between the two panes when both are inline. */
|
|
20
|
+
export const DIVIDER_WIDTH = 1
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Continuous clamp. `preferred` is the user's desired width (until #13 lands,
|
|
24
|
+
* derived from viewport). The reader-min ceiling means the sidebar yields
|
|
25
|
+
* space rather than squeezing the reader below readability.
|
|
26
|
+
*
|
|
27
|
+
* Result is not clamped *up* to SIDEBAR_MIN when the viewport itself is too
|
|
28
|
+
* narrow to hold both panes — the caller decides whether to render at all
|
|
29
|
+
* (e.g. single-pane stack instead of inline). See `canFitInline`.
|
|
30
|
+
*/
|
|
31
|
+
export const resolveSidebarWidth = (viewport: number, preferred: number): number => {
|
|
32
|
+
const ceiling = viewport - DIVIDER_WIDTH - READER_MIN_WIDTH
|
|
33
|
+
const lower = Math.min(SIDEBAR_MIN_WIDTH, Math.max(0, ceiling))
|
|
34
|
+
const upper = Math.max(lower, ceiling)
|
|
35
|
+
return Math.max(lower, Math.min(upper, preferred))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Default preferred sidebar width, derived from viewport until persistent
|
|
40
|
+
* config (#13) provides a user-set value. Matches the previous inline math.
|
|
41
|
+
*/
|
|
42
|
+
export const defaultPreferredWidth = (viewport: number): number =>
|
|
43
|
+
Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, Math.floor(viewport * 0.25)))
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* True when an inline (side-by-side) layout still gives the reader at least
|
|
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.
|
|
50
|
+
*/
|
|
51
|
+
export const canFitInline = (viewport: number): boolean =>
|
|
52
|
+
viewport >= SIDEBAR_MIN_WIDTH + DIVIDER_WIDTH + READER_MIN_WIDTH
|
|
53
|
+
|
|
54
|
+
/**
|
|
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.
|
|
59
|
+
*/
|
|
60
|
+
export const initialShownForAuto = (_viewport: number): boolean => true
|
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
|
+
}
|