@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/Browser.tsx
CHANGED
|
@@ -11,32 +11,59 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { SyntaxStyle } from "@opentui/core"
|
|
14
|
+
import type { BorderSides } from "@opentui/core"
|
|
14
15
|
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
|
|
15
16
|
import { useAtomValue, useAtomSet } from "@effect/atom-react"
|
|
16
17
|
import { Effect } from "effect"
|
|
17
18
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
19
|
+
import { buildCommands } from "./commands/buildCommands.ts"
|
|
20
|
+
import { clampSelectedIndex, filterCommands } from "./commands/score.ts"
|
|
21
|
+
import { CommandPalette } from "./CommandPalette.tsx"
|
|
18
22
|
import { filterFiles } from "./discovery/filter.ts"
|
|
19
23
|
import { type FileEntry } from "./discovery/walk.ts"
|
|
24
|
+
import { BRAND, BRAND_NAME } from "./brand.ts"
|
|
20
25
|
import { Footer, FOOTER_HEIGHT } from "./Footer.tsx"
|
|
26
|
+
import { Header, HEADER_HEIGHT } from "./Header.tsx"
|
|
21
27
|
import { HelpOverlay } from "./HelpOverlay.tsx"
|
|
22
28
|
import { readFileText } from "./io/readFile.ts"
|
|
23
29
|
import { browserBindings, type BrowserCtx } from "./keymap/browser.ts"
|
|
24
30
|
import { dispatch } from "./keymap/keymap.ts"
|
|
31
|
+
import {
|
|
32
|
+
canFitInline,
|
|
33
|
+
defaultPreferredWidth,
|
|
34
|
+
initialShownForAuto,
|
|
35
|
+
resolveSidebarWidth,
|
|
36
|
+
} from "./layout/resolve.ts"
|
|
25
37
|
import { openInBrowser } from "./serve/openBrowser.ts"
|
|
26
38
|
import { startServer, type ServerHandle } from "./serve/server.ts"
|
|
27
39
|
import { colors, setActiveTheme } from "./theme/colors.ts"
|
|
28
40
|
import { themeAtom } from "./theme/atom.ts"
|
|
29
41
|
import { themeDefinitions, getThemeDefinition } from "./theme/registry.ts"
|
|
30
42
|
|
|
43
|
+
export type SidebarMode = "auto" | "on" | "off"
|
|
44
|
+
|
|
31
45
|
export interface BrowserProps {
|
|
32
46
|
readonly files: readonly FileEntry[]
|
|
33
|
-
readonly title?: string
|
|
34
47
|
readonly initialIndex?: number
|
|
35
48
|
/** Cap the rendered markdown's width at N columns. Null = fill the pane. */
|
|
36
49
|
readonly maxWidth?: number | null
|
|
50
|
+
/** Persistent footer indicator (e.g. "indexing… 42"). Pass null/undefined
|
|
51
|
+
* when discovery has finished; the indicator clears. */
|
|
52
|
+
readonly discoveryStatus?: string | null
|
|
53
|
+
/** Initial sidebar visibility (`--sidebar` flag). `auto` consults the
|
|
54
|
+
* launch viewport bucket once; subsequent visibility goes through `s`. */
|
|
55
|
+
readonly sidebarMode?: SidebarMode
|
|
37
56
|
readonly onQuit?: () => void
|
|
38
57
|
/** Test seam: replaces the file reader. */
|
|
39
58
|
readonly readFile?: (path: string) => Promise<string>
|
|
59
|
+
/** Optional one-shot footer toast surfaced on first appearance (e.g. the
|
|
60
|
+
* "update available" nudge). Shown with an extended TTL so the user has
|
|
61
|
+
* time to read it; subsequent transient toasts (theme cycle, etc.)
|
|
62
|
+
* preempt it via the same single-slot channel. Null disables. */
|
|
63
|
+
readonly updateNotice?: string | null
|
|
64
|
+
/** TTL (ms) for the update-notice toast. Exposed so tests can use a small
|
|
65
|
+
* value instead of sleeping for the production 10s window. */
|
|
66
|
+
readonly updateNoticeTtlMs?: number
|
|
40
67
|
}
|
|
41
68
|
|
|
42
69
|
const defaultReadFile = (path: string): Promise<string> => Effect.runPromise(readFileText(path))
|
|
@@ -44,21 +71,27 @@ const defaultReadFile = (path: string): Promise<string> => Effect.runPromise(rea
|
|
|
44
71
|
const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n))
|
|
45
72
|
|
|
46
73
|
/** Bindings the help overlay lets through. Single source of truth for both
|
|
47
|
-
* the keyboard early-return and the footer hint filter.
|
|
74
|
+
* the keyboard early-return and the footer hint filter. `palette.open`
|
|
75
|
+
* passes through so users can jump from help into the palette in one
|
|
76
|
+
* keystroke — `openPalette` closes help on its way in. */
|
|
48
77
|
const HELP_ALLOWED_IDS: ReadonlySet<string> = new Set([
|
|
49
78
|
"help.toggle",
|
|
50
79
|
"theme.next",
|
|
51
80
|
"theme.prev",
|
|
52
81
|
"theme.toneToggle",
|
|
82
|
+
"palette.open",
|
|
53
83
|
])
|
|
54
84
|
|
|
55
85
|
export const Browser = ({
|
|
56
86
|
files,
|
|
57
|
-
title = "house",
|
|
58
87
|
initialIndex = 0,
|
|
59
88
|
maxWidth = null,
|
|
89
|
+
discoveryStatus = null,
|
|
90
|
+
sidebarMode = "auto",
|
|
60
91
|
onQuit,
|
|
61
92
|
readFile = defaultReadFile,
|
|
93
|
+
updateNotice = null,
|
|
94
|
+
updateNoticeTtlMs = 10000,
|
|
62
95
|
}: BrowserProps) => {
|
|
63
96
|
const renderer = useRenderer()
|
|
64
97
|
const { width, height } = useTerminalDimensions()
|
|
@@ -71,19 +104,52 @@ export const Browser = ({
|
|
|
71
104
|
)
|
|
72
105
|
const [loaded, setLoaded] = useState<{ path: string; content: string } | null>(null)
|
|
73
106
|
const [error, setError] = useState<string | null>(null)
|
|
74
|
-
|
|
75
|
-
|
|
107
|
+
// `shown` is the user's sticky preference. Visibility is derived:
|
|
108
|
+
// `visible = shown || focus === "sidebar"`. See DESIGN.md §7.1.
|
|
109
|
+
//
|
|
110
|
+
// Launch consults the viewport bucket once for `--sidebar=auto`. The
|
|
111
|
+
// useState initializer pins this to the first render — buckets are
|
|
112
|
+
// launch-only by design, so resize must NOT re-evaluate.
|
|
113
|
+
const [shown, setShown] = useState<boolean>(() => {
|
|
114
|
+
switch (sidebarMode) {
|
|
115
|
+
case "on":
|
|
116
|
+
return true
|
|
117
|
+
case "off":
|
|
118
|
+
return false
|
|
119
|
+
case "auto":
|
|
120
|
+
return initialShownForAuto(width)
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
const [focus, setFocus] = useState<"sidebar" | "reader">(() => (shown ? "sidebar" : "reader"))
|
|
76
124
|
const [sidebarScroll, setSidebarScroll] = useState<number>(0)
|
|
77
125
|
const [helpVisible, setHelpVisible] = useState<boolean>(false)
|
|
78
126
|
const [filterOpen, setFilterOpen] = useState<boolean>(false)
|
|
79
127
|
const [filterQuery, setFilterQuery] = useState<string>("")
|
|
128
|
+
const [paletteOpen, setPaletteOpen] = useState<boolean>(false)
|
|
129
|
+
const [paletteQuery, setPaletteQuery] = useState<string>("")
|
|
130
|
+
const [paletteIndex, setPaletteIndex] = useState<number>(0)
|
|
131
|
+
// Synchronous mirrors for the keyboard handler — same reason filterOpenRef
|
|
132
|
+
// exists. Modal input can arrive in one React batch (e.g. ctrl+p, Down,
|
|
133
|
+
// Return), so every palette field read by later keys must update its ref
|
|
134
|
+
// before React state commits.
|
|
135
|
+
const paletteOpenRef = useRef(false)
|
|
136
|
+
const paletteQueryRef = useRef("")
|
|
137
|
+
const paletteIndexRef = useRef(0)
|
|
80
138
|
// Mirror filter state into refs so the keyboard handler sees synchronous
|
|
81
139
|
// updates even when multiple keys arrive in a single React batch (the
|
|
82
140
|
// first key opens the filter; subsequent keys in the same tick would
|
|
83
141
|
// otherwise still observe filterOpen=false through closure).
|
|
84
142
|
const filterOpenRef = useRef(false)
|
|
85
143
|
const filterQueryRef = useRef("")
|
|
86
|
-
|
|
144
|
+
// Snapshot the query at filter-open so Esc reverts edits but commit (Return)
|
|
145
|
+
// keeps them.
|
|
146
|
+
const priorFilterQueryRef = useRef("")
|
|
147
|
+
const [footerNotice, setFooterNoticeState] = useState<{
|
|
148
|
+
readonly text: string
|
|
149
|
+
readonly ttlMs: number
|
|
150
|
+
} | null>(null)
|
|
151
|
+
const pushFooterNotice = (text: string, ttlMs = 2000): void =>
|
|
152
|
+
setFooterNoticeState({ text, ttlMs })
|
|
87
153
|
const serverRef = useRef<ServerHandle | null>(null)
|
|
88
154
|
|
|
89
155
|
// Stop the preview server on unmount so re-mounts (tests) and clean
|
|
@@ -95,21 +161,36 @@ export const Browser = ({
|
|
|
95
161
|
}
|
|
96
162
|
}, [])
|
|
97
163
|
|
|
98
|
-
// Single-slot notice with a
|
|
99
|
-
// timer so the latest message gets its own full window.
|
|
164
|
+
// Single-slot notice with a per-message TTL. A new notice cancels the
|
|
165
|
+
// pending timer so the latest message gets its own full window. The TTL
|
|
166
|
+
// travels with the message so a long-lived nudge (update available) and a
|
|
167
|
+
// transient toast (theme cycle) can share one slot without one stealing
|
|
168
|
+
// the other's display window.
|
|
100
169
|
useEffect(() => {
|
|
101
170
|
if (footerNotice === null) return
|
|
102
|
-
const timer = setTimeout(() =>
|
|
171
|
+
const timer = setTimeout(() => setFooterNoticeState(null), footerNotice.ttlMs)
|
|
103
172
|
return () => clearTimeout(timer)
|
|
104
173
|
}, [footerNotice])
|
|
105
174
|
|
|
175
|
+
// Push the update-available nudge once, when it arrives from the parent
|
|
176
|
+
// (the registry probe resolves asynchronously after boot). 10s gives the
|
|
177
|
+
// user time to read it before it auto-clears; the quit-time stderr print
|
|
178
|
+
// is the durable record they can copy from scrollback.
|
|
179
|
+
const updateNoticeSeenRef = useRef<string | null>(null)
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
if (!updateNotice) return
|
|
182
|
+
if (updateNoticeSeenRef.current === updateNotice) return
|
|
183
|
+
updateNoticeSeenRef.current = updateNotice
|
|
184
|
+
pushFooterNotice(updateNotice, updateNoticeTtlMs)
|
|
185
|
+
}, [updateNotice, updateNoticeTtlMs])
|
|
186
|
+
|
|
106
187
|
const cycleTheme = (delta: 1 | -1) => {
|
|
107
188
|
const idx = themeDefinitions.findIndex((d) => d.id === theme.id)
|
|
108
189
|
const next = themeDefinitions[(idx + delta + themeDefinitions.length) % themeDefinitions.length]
|
|
109
190
|
if (!next) return
|
|
110
191
|
setActiveTheme(next, theme.tone)
|
|
111
192
|
setTheme({ id: next.id, tone: theme.tone })
|
|
112
|
-
|
|
193
|
+
pushFooterNotice(`theme: ${next.name}`)
|
|
113
194
|
}
|
|
114
195
|
|
|
115
196
|
const toggleTone = () => {
|
|
@@ -117,7 +198,7 @@ export const Browser = ({
|
|
|
117
198
|
const def = getThemeDefinition(theme.id)
|
|
118
199
|
if (def) setActiveTheme(def, nextTone)
|
|
119
200
|
setTheme({ id: theme.id, tone: nextTone })
|
|
120
|
-
|
|
201
|
+
pushFooterNotice(`tone: ${nextTone}`)
|
|
121
202
|
}
|
|
122
203
|
|
|
123
204
|
const displayedFiles = useMemo(() => filterFiles(files, filterQuery), [files, filterQuery])
|
|
@@ -181,17 +262,60 @@ export const Browser = ({
|
|
|
181
262
|
const ctx: BrowserCtx = {
|
|
182
263
|
files: displayedFiles,
|
|
183
264
|
focus,
|
|
184
|
-
|
|
265
|
+
sidebarShown: shown,
|
|
185
266
|
helpVisible,
|
|
186
267
|
filterOpen,
|
|
268
|
+
paletteOpen,
|
|
187
269
|
setFocus,
|
|
188
270
|
setSelectedIndex,
|
|
189
|
-
|
|
271
|
+
toggleShown: () => {
|
|
272
|
+
// Two layout shapes, two behaviors:
|
|
273
|
+
// wide → flip the sticky `shown` preference. Per DESIGN.md §7.1
|
|
274
|
+
// s-behavior table: also nudge focus so the visibility
|
|
275
|
+
// rule (`visible = shown || focus==="sidebar"`) reflects
|
|
276
|
+
// the user's intent instead of forcing the sidebar back
|
|
277
|
+
// on via focus.
|
|
278
|
+
// narrow → swap which screen is up (focus is the source of truth
|
|
279
|
+
// for render). Sync `shown` to the new screen so a later
|
|
280
|
+
// resize to wide opens with the right pane visible.
|
|
281
|
+
if (!canFitInline(width)) {
|
|
282
|
+
const next = focus === "sidebar" ? "reader" : "sidebar"
|
|
283
|
+
setFocus(next)
|
|
284
|
+
setShown(next === "sidebar")
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
if (shown) {
|
|
288
|
+
setShown(false)
|
|
289
|
+
if (focus === "sidebar") setFocus("reader")
|
|
290
|
+
} else {
|
|
291
|
+
setShown(true)
|
|
292
|
+
if (focus === "reader") setFocus("sidebar")
|
|
293
|
+
}
|
|
294
|
+
},
|
|
190
295
|
setHelpVisible,
|
|
191
296
|
openFilter: () => {
|
|
297
|
+
// Focus the sidebar so the filter input has a home. In wide,
|
|
298
|
+
// §7.1's visibility rule (`shown || focus === "sidebar"`) brings
|
|
299
|
+
// the inline sidebar back on screen if it was hidden. In narrow,
|
|
300
|
+
// focusing the sidebar swaps to the sidebar screen. Either way
|
|
301
|
+
// no need to mutate `shown`.
|
|
302
|
+
priorFilterQueryRef.current = filterQueryRef.current
|
|
303
|
+
if (focus !== "sidebar") setFocus("sidebar")
|
|
192
304
|
filterOpenRef.current = true
|
|
193
305
|
setFilterOpen(true)
|
|
194
306
|
},
|
|
307
|
+
openPalette: () => {
|
|
308
|
+
// Close help if it was open — palette is the active modal now.
|
|
309
|
+
// Reset query/index so each open starts fresh (no stale state from
|
|
310
|
+
// the previous session).
|
|
311
|
+
if (helpVisible) setHelpVisible(() => false)
|
|
312
|
+
paletteQueryRef.current = ""
|
|
313
|
+
setPaletteQuery("")
|
|
314
|
+
paletteIndexRef.current = 0
|
|
315
|
+
setPaletteIndex(0)
|
|
316
|
+
paletteOpenRef.current = true
|
|
317
|
+
setPaletteOpen(true)
|
|
318
|
+
},
|
|
195
319
|
cycleTheme,
|
|
196
320
|
toggleTone,
|
|
197
321
|
serveCurrent: () => {
|
|
@@ -203,9 +327,9 @@ export const Browser = ({
|
|
|
203
327
|
handle = startServer({ path: file.path })
|
|
204
328
|
serverRef.current = handle
|
|
205
329
|
openInBrowser(handle.url)
|
|
206
|
-
|
|
330
|
+
pushFooterNotice(`serving at ${handle.url}`)
|
|
207
331
|
} catch (err) {
|
|
208
|
-
|
|
332
|
+
pushFooterNotice(`serve failed: ${String(err)}`)
|
|
209
333
|
}
|
|
210
334
|
return
|
|
211
335
|
}
|
|
@@ -217,7 +341,7 @@ export const Browser = ({
|
|
|
217
341
|
// an existing tab on the same URL when one is open, so this is
|
|
218
342
|
// idempotent for the common case.
|
|
219
343
|
openInBrowser(handle.url)
|
|
220
|
-
|
|
344
|
+
pushFooterNotice(`serving ${file.relativePath} at ${handle.url}`)
|
|
221
345
|
},
|
|
222
346
|
quit: () => {
|
|
223
347
|
if (onQuit) {
|
|
@@ -249,17 +373,48 @@ export const Browser = ({
|
|
|
249
373
|
//
|
|
250
374
|
// Centralized so the dual filterOpenRef / filterOpen invariant
|
|
251
375
|
// only has to be maintained in one place (plus `openFilter`).
|
|
252
|
-
const closeFilter = (
|
|
376
|
+
const closeFilter = (commit: boolean) => {
|
|
253
377
|
const picked = displayedFiles[selectedIndex] ?? null
|
|
378
|
+
// Return on a zero-match list has nothing to commit. Treat it
|
|
379
|
+
// as Esc so the user isn't stranded in an "applied filter with
|
|
380
|
+
// no visible files" state they'd have to back out of manually.
|
|
381
|
+
const effectiveCommit = commit && picked !== null
|
|
254
382
|
filterOpenRef.current = false
|
|
255
|
-
filterQueryRef.current = ""
|
|
256
383
|
setFilterOpen(false)
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
384
|
+
if (effectiveCommit) {
|
|
385
|
+
// Return keeps the query. selectedIndex is already a valid
|
|
386
|
+
// position in the (still-filtered) displayedFiles list, so
|
|
387
|
+
// no translation is needed.
|
|
388
|
+
} else {
|
|
389
|
+
// Esc reverts the query to its pre-session value. After the
|
|
390
|
+
// revert, displayedFiles may change shape — translate the
|
|
391
|
+
// cursor by path so it stays on whatever the user was
|
|
392
|
+
// looking at, instead of snapping to a numerically-equivalent
|
|
393
|
+
// row in the restored list.
|
|
394
|
+
const before = priorFilterQueryRef.current
|
|
395
|
+
filterQueryRef.current = before
|
|
396
|
+
setFilterQuery(before)
|
|
397
|
+
if (picked) {
|
|
398
|
+
const restored = before === "" ? files : filterFiles(files, before)
|
|
399
|
+
const idx = restored.findIndex((f) => f.path === picked.path)
|
|
400
|
+
if (idx >= 0) setSelectedIndex(() => idx)
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
// Where focus lands after the filter closes:
|
|
404
|
+
// commit (Return on a real pick) → reader, always. The user
|
|
405
|
+
// asked to open the match; show them what they picked.
|
|
406
|
+
// cancel (Esc, or Return with no pick) → restore the user's
|
|
407
|
+
// pre-filter intent. If the sidebar was up before the
|
|
408
|
+
// filter opened (shown=true), stay in it so j/k keeps
|
|
409
|
+
// walking. If the sidebar was hidden (shown=false), go
|
|
410
|
+
// back to the reader so the sidebar dismisses — in narrow
|
|
411
|
+
// that swaps screens; in wide that drops the focus-driven
|
|
412
|
+
// sidebar revival.
|
|
413
|
+
if (effectiveCommit) {
|
|
414
|
+
setFocus("reader")
|
|
415
|
+
} else {
|
|
416
|
+
setFocus(shown ? "sidebar" : "reader")
|
|
261
417
|
}
|
|
262
|
-
if (focusReader && picked) setFocus("reader")
|
|
263
418
|
}
|
|
264
419
|
if (key.name === "escape") {
|
|
265
420
|
closeFilter(false)
|
|
@@ -269,7 +424,15 @@ export const Browser = ({
|
|
|
269
424
|
closeFilter(true)
|
|
270
425
|
return
|
|
271
426
|
}
|
|
272
|
-
if (key.name === "backspace") {
|
|
427
|
+
if (key.name === "backspace" || key.name === "delete") {
|
|
428
|
+
// Pressing backspace/delete with no query left removes the
|
|
429
|
+
// leading `/` — i.e. closes the modal. Equivalent to Esc:
|
|
430
|
+
// reverts to the pre-session query (so an applied filter
|
|
431
|
+
// survives a "I changed my mind" tap).
|
|
432
|
+
if (filterQueryRef.current.length === 0) {
|
|
433
|
+
closeFilter(false)
|
|
434
|
+
return
|
|
435
|
+
}
|
|
273
436
|
filterQueryRef.current = filterQueryRef.current.slice(0, -1)
|
|
274
437
|
setFilterQuery(filterQueryRef.current)
|
|
275
438
|
setSelectedIndex(() => 0)
|
|
@@ -296,6 +459,74 @@ export const Browser = ({
|
|
|
296
459
|
}
|
|
297
460
|
return
|
|
298
461
|
}
|
|
462
|
+
// Command palette modal: capture keystrokes for the query input and
|
|
463
|
+
// list navigation. Esc closes (single press, regardless of query —
|
|
464
|
+
// #70 Q7a). Return runs the selected command. Up/Down navigate.
|
|
465
|
+
// Backspace edits the query and is a no-op on empty (#70 Q7b —
|
|
466
|
+
// intentionally diverges from the filter modal, which closes on
|
|
467
|
+
// empty-backspace, because accidental close feels worse in the
|
|
468
|
+
// palette). Printable characters extend the query and snap selection
|
|
469
|
+
// to 0 (#70 Q7c). Ctrl/Meta-modified keys are swallowed except
|
|
470
|
+
// ctrl+p, which toggles the palette closed (matches help-toggle's
|
|
471
|
+
// re-press-to-close behavior).
|
|
472
|
+
if (paletteOpenRef.current) {
|
|
473
|
+
const closePalette = () => {
|
|
474
|
+
paletteOpenRef.current = false
|
|
475
|
+
paletteQueryRef.current = ""
|
|
476
|
+
paletteIndexRef.current = 0
|
|
477
|
+
setPaletteOpen(false)
|
|
478
|
+
setPaletteQuery("")
|
|
479
|
+
setPaletteIndex(0)
|
|
480
|
+
}
|
|
481
|
+
const setPaletteIndexSync = (next: number) => {
|
|
482
|
+
paletteIndexRef.current = next
|
|
483
|
+
setPaletteIndex(next)
|
|
484
|
+
}
|
|
485
|
+
const allCommands = buildCommands(ctx)
|
|
486
|
+
const filtered = filterCommands(allCommands, paletteQueryRef.current)
|
|
487
|
+
if (key.name === "escape") {
|
|
488
|
+
closePalette()
|
|
489
|
+
return
|
|
490
|
+
}
|
|
491
|
+
if (key.name === "return") {
|
|
492
|
+
const picked = filtered[clampSelectedIndex(paletteIndexRef.current, filtered)]
|
|
493
|
+
closePalette()
|
|
494
|
+
picked?.run()
|
|
495
|
+
return
|
|
496
|
+
}
|
|
497
|
+
if (key.name === "up") {
|
|
498
|
+
setPaletteIndexSync(Math.max(0, paletteIndexRef.current - 1))
|
|
499
|
+
return
|
|
500
|
+
}
|
|
501
|
+
if (key.name === "down") {
|
|
502
|
+
setPaletteIndexSync(Math.min(Math.max(0, filtered.length - 1), paletteIndexRef.current + 1))
|
|
503
|
+
return
|
|
504
|
+
}
|
|
505
|
+
if (key.name === "backspace" || key.name === "delete") {
|
|
506
|
+
if (paletteQueryRef.current.length === 0) return
|
|
507
|
+
paletteQueryRef.current = paletteQueryRef.current.slice(0, -1)
|
|
508
|
+
setPaletteQuery(paletteQueryRef.current)
|
|
509
|
+
setPaletteIndexSync(0)
|
|
510
|
+
return
|
|
511
|
+
}
|
|
512
|
+
// ctrl+p again closes — matches help-toggle behavior.
|
|
513
|
+
if (key.ctrl && !key.meta && key.name === "p") {
|
|
514
|
+
closePalette()
|
|
515
|
+
return
|
|
516
|
+
}
|
|
517
|
+
if (key.ctrl || key.meta) return
|
|
518
|
+
let char: string | null = null
|
|
519
|
+
if (key.name === "space") char = " "
|
|
520
|
+
else if (typeof key.name === "string" && key.name.length === 1) {
|
|
521
|
+
char = key.shift ? key.name.toUpperCase() : key.name
|
|
522
|
+
}
|
|
523
|
+
if (char !== null) {
|
|
524
|
+
paletteQueryRef.current = paletteQueryRef.current + char
|
|
525
|
+
setPaletteQuery(paletteQueryRef.current)
|
|
526
|
+
setPaletteIndexSync(0)
|
|
527
|
+
}
|
|
528
|
+
return
|
|
529
|
+
}
|
|
299
530
|
// While help is open, swallow most keys: only ? (toggle), esc
|
|
300
531
|
// (close), and the theme bindings pass through. Theme keys stay live
|
|
301
532
|
// so users can preview palette changes against the overlay itself —
|
|
@@ -320,23 +551,40 @@ export const Browser = ({
|
|
|
320
551
|
dispatch(browserBindings, ctx, key)
|
|
321
552
|
})
|
|
322
553
|
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
const sidebarWidth =
|
|
554
|
+
// Sidebar width is a pure function of viewport (DESIGN.md §7.1). Until
|
|
555
|
+
// persistent config (#13) lands, `preferred` is derived from viewport,
|
|
556
|
+
// matching the pre-#22 inline math.
|
|
557
|
+
const sidebarWidth = resolveSidebarWidth(width, defaultPreferredWidth(width))
|
|
327
558
|
const sidebarActive = focus === "sidebar"
|
|
328
559
|
const readerActive = focus === "reader"
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
560
|
+
// Wide vs narrow drives the entire layout shape.
|
|
561
|
+
// wide → inline two-pane (today). visible = shown || focus==="sidebar".
|
|
562
|
+
// narrow → single-pane stack: whichever pane has focus fills the area.
|
|
563
|
+
// `shown` is silently ignored for render in narrow; `focus`
|
|
564
|
+
// is the single source of truth.
|
|
565
|
+
// See DESIGN.md §7.1.
|
|
566
|
+
const isNarrow = !canFitInline(width)
|
|
567
|
+
const sidebarInline = isNarrow ? sidebarActive : shown || sidebarActive
|
|
568
|
+
const readerVisible = isNarrow ? readerActive : true
|
|
569
|
+
// Currently-selected file shown in the Header (which replaced the
|
|
570
|
+
// per-pane border title that used to carry this information).
|
|
571
|
+
const currentFile = selected?.relativePath ?? null
|
|
332
572
|
const content = loaded?.path === renderedPath ? loaded.content : ""
|
|
333
573
|
|
|
334
574
|
// Sidebar virtualization: render only the visible window. Without this,
|
|
335
575
|
// every keystroke re-renders all N file rows even though only the bg of
|
|
336
576
|
// two of them changed (old + new selected). On a 195-file vault that
|
|
337
577
|
// dominates the per-keystroke cost.
|
|
338
|
-
//
|
|
339
|
-
|
|
578
|
+
// Chrome budget: header + pane top border + pane bottom border + footer.
|
|
579
|
+
// The filter row eats one more cell when files are present *or* while
|
|
580
|
+
// discovery is in flight (allocates the row up front so it doesn't pop
|
|
581
|
+
// in when the first file arrives).
|
|
582
|
+
const discoveryActive = discoveryStatus !== null && discoveryStatus.length > 0
|
|
583
|
+
const filterRowVisible = files.length > 0 || discoveryActive
|
|
584
|
+
const sidebarBodyHeight = Math.max(
|
|
585
|
+
1,
|
|
586
|
+
height - FOOTER_HEIGHT - HEADER_HEIGHT - 2 - (filterRowVisible ? 1 : 0),
|
|
587
|
+
)
|
|
340
588
|
const maxScroll = Math.max(0, displayedFiles.length - sidebarBodyHeight)
|
|
341
589
|
const desiredScroll = (() => {
|
|
342
590
|
let s = sidebarScroll
|
|
@@ -348,8 +596,12 @@ export const Browser = ({
|
|
|
348
596
|
if (desiredScroll !== sidebarScroll) setSidebarScroll(desiredScroll)
|
|
349
597
|
}, [desiredScroll, sidebarScroll])
|
|
350
598
|
const visibleFiles = displayedFiles.slice(desiredScroll, desiredScroll + sidebarBodyHeight)
|
|
351
|
-
// Available width for sidebar text rows:
|
|
352
|
-
|
|
599
|
+
// Available width for sidebar text rows. Wide-inline: sidebarWidth minus
|
|
600
|
+
// 1-cell left padding and 1-cell right divider border. Narrow-stack: the
|
|
601
|
+
// sidebar flex-grows to fill the viewport with no right divider, so the
|
|
602
|
+
// budget is the viewport minus the 1-cell left padding only.
|
|
603
|
+
const sidebarPaneWidth = isNarrow ? width : sidebarWidth
|
|
604
|
+
const sidebarTextWidth = Math.max(4, sidebarPaneWidth - (isNarrow ? 1 : 2))
|
|
353
605
|
// Right-anchored truncation: keep the filename visible, lose the prefix
|
|
354
606
|
// with a leading ellipsis when the path is too long.
|
|
355
607
|
const truncatePath = useCallback(
|
|
@@ -358,6 +610,30 @@ export const Browser = ({
|
|
|
358
610
|
[sidebarTextWidth],
|
|
359
611
|
)
|
|
360
612
|
|
|
613
|
+
// Filter row content + color. Three reachable states:
|
|
614
|
+
// editing — filterOpen=true → /<query>▏ in textStrong
|
|
615
|
+
// applied — !filterOpen && query !== "" → /<query> in text
|
|
616
|
+
// idle — !filterOpen && query === "" → "/ filter…" in textMuted
|
|
617
|
+
const filterRowFg = filterOpen
|
|
618
|
+
? colors.textStrong
|
|
619
|
+
: filterQuery.length > 0
|
|
620
|
+
? colors.text
|
|
621
|
+
: colors.textMuted
|
|
622
|
+
const filterRowRaw = filterOpen
|
|
623
|
+
? `/${filterQuery}▏`
|
|
624
|
+
: filterQuery.length > 0
|
|
625
|
+
? `/${filterQuery}`
|
|
626
|
+
: "/ filter…"
|
|
627
|
+
// Editing keeps the cursor visible — anchor the right edge with a leading
|
|
628
|
+
// ellipsis when the query overflows. Applied/idle anchor the left edge
|
|
629
|
+
// (lose the tail) so the leading `/` always reads as a filter marker.
|
|
630
|
+
const filterRowContent =
|
|
631
|
+
filterRowRaw.length <= sidebarTextWidth
|
|
632
|
+
? filterRowRaw
|
|
633
|
+
: filterOpen
|
|
634
|
+
? "…" + filterRowRaw.slice(filterRowRaw.length - sidebarTextWidth + 1)
|
|
635
|
+
: filterRowRaw.slice(0, sidebarTextWidth - 1) + "…"
|
|
636
|
+
|
|
361
637
|
// While help is open, the `?` key closes the overlay — relabel its hint
|
|
362
638
|
// so the footer accurately describes what pressing the key will do.
|
|
363
639
|
// Memoized: `helpVisible` changes rarely; `browserBindings` and
|
|
@@ -372,110 +648,207 @@ export const Browser = ({
|
|
|
372
648
|
[helpVisible],
|
|
373
649
|
)
|
|
374
650
|
|
|
651
|
+
// One sidebar body for both wide-inline and narrow-stack rendering; only
|
|
652
|
+
// the wrapper differs (fixed-width sibling vs flex-grow full-pane).
|
|
653
|
+
const sidebarBody = (
|
|
654
|
+
<>
|
|
655
|
+
{filterRowVisible && (
|
|
656
|
+
<text content={filterRowContent} wrapMode="none" style={{ fg: filterRowFg }} />
|
|
657
|
+
)}
|
|
658
|
+
{displayedFiles.length === 0 ? (
|
|
659
|
+
<text
|
|
660
|
+
content={
|
|
661
|
+
files.length === 0
|
|
662
|
+
? discoveryActive
|
|
663
|
+
? "(scanning…)"
|
|
664
|
+
: "(no markdown files)"
|
|
665
|
+
: "(no matches)"
|
|
666
|
+
}
|
|
667
|
+
style={{ fg: colors.textMuted }}
|
|
668
|
+
/>
|
|
669
|
+
) : (
|
|
670
|
+
visibleFiles.map((file, idx) => {
|
|
671
|
+
const realIdx = desiredScroll + idx
|
|
672
|
+
const isSelected = realIdx === selectedIndex
|
|
673
|
+
const display = truncatePath(file.relativePath)
|
|
674
|
+
if (!isSelected) {
|
|
675
|
+
return (
|
|
676
|
+
<text key={file.path} content={display} wrapMode="none" style={{ fg: colors.text }} />
|
|
677
|
+
)
|
|
678
|
+
}
|
|
679
|
+
const bg = sidebarActive ? colors.selectedBg : colors.selectedBgInactive
|
|
680
|
+
return (
|
|
681
|
+
<text
|
|
682
|
+
key={file.path}
|
|
683
|
+
content={display}
|
|
684
|
+
wrapMode="none"
|
|
685
|
+
style={{ fg: colors.textStrong, bg }}
|
|
686
|
+
/>
|
|
687
|
+
)
|
|
688
|
+
})
|
|
689
|
+
)}
|
|
690
|
+
</>
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
// Pane borders draw a connected frame: each pane's top/bottom edges
|
|
694
|
+
// (the horizontal rules) and the sidebar's right edge (the vertical
|
|
695
|
+
// divider) are rendered by opentui in one pass, so the junctions
|
|
696
|
+
// never get painted over by sibling elements. customBorderChars on
|
|
697
|
+
// the sidebar turns its right-side corners from `┐ ┘` into `┬ ┴` so
|
|
698
|
+
// they connect cleanly with the reader's top/bottom rules.
|
|
699
|
+
const sidebarBorderSides: BorderSides[] = ["top", "bottom", "right"]
|
|
700
|
+
const readerBorderSides: BorderSides[] = ["top", "bottom"]
|
|
701
|
+
const SIDEBAR_BORDER_CHARS = {
|
|
702
|
+
topLeft: "┌",
|
|
703
|
+
topRight: "┬",
|
|
704
|
+
bottomLeft: "└",
|
|
705
|
+
bottomRight: "┴",
|
|
706
|
+
horizontal: "─",
|
|
707
|
+
vertical: "│",
|
|
708
|
+
topT: "┬",
|
|
709
|
+
bottomT: "┴",
|
|
710
|
+
leftT: "├",
|
|
711
|
+
rightT: "┤",
|
|
712
|
+
cross: "┼",
|
|
713
|
+
} as const
|
|
714
|
+
|
|
375
715
|
return (
|
|
376
|
-
<box style={{ width, height, flexDirection: "column", backgroundColor: colors.
|
|
716
|
+
<box style={{ width, height, flexDirection: "column", backgroundColor: colors.surface }}>
|
|
717
|
+
<Header width={width} currentFile={currentFile} />
|
|
377
718
|
<box
|
|
378
719
|
style={{
|
|
379
720
|
flexDirection: "row",
|
|
380
721
|
flexGrow: 1,
|
|
381
722
|
flexShrink: 1,
|
|
382
|
-
backgroundColor: colors.
|
|
723
|
+
backgroundColor: colors.surface,
|
|
383
724
|
}}
|
|
384
725
|
>
|
|
385
|
-
{
|
|
726
|
+
{sidebarInline && (
|
|
386
727
|
<box
|
|
387
|
-
title={sidebarTitle}
|
|
388
|
-
titleAlignment="left"
|
|
389
728
|
style={{
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
729
|
+
// Narrow mode runs single-pane: the sidebar fills the area
|
|
730
|
+
// and drops its right divider (no neighbour to abut).
|
|
731
|
+
border: isNarrow ? readerBorderSides : sidebarBorderSides,
|
|
732
|
+
borderColor: colors.textMuted,
|
|
733
|
+
...(isNarrow
|
|
734
|
+
? { flexGrow: 1, flexShrink: 1 }
|
|
735
|
+
: { width: sidebarWidth, flexShrink: 0 }),
|
|
394
736
|
flexDirection: "column",
|
|
737
|
+
// Dim by default. Borders/separators ride on this so they read
|
|
738
|
+
// as a single connected frame regardless of focus; only the
|
|
739
|
+
// active pane's inner body overrides to the raised tint below.
|
|
395
740
|
backgroundColor: colors.surface,
|
|
396
741
|
}}
|
|
742
|
+
{...(isNarrow ? {} : { customBorderChars: SIDEBAR_BORDER_CHARS })}
|
|
397
743
|
>
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
return (
|
|
410
|
-
<text
|
|
411
|
-
key={file.path}
|
|
412
|
-
content={display}
|
|
413
|
-
wrapMode="none"
|
|
414
|
-
style={{ fg: colors.text }}
|
|
415
|
-
/>
|
|
416
|
-
)
|
|
417
|
-
}
|
|
418
|
-
const bg = sidebarActive ? colors.selectedBg : colors.selectedBgInactive
|
|
419
|
-
return (
|
|
420
|
-
<text
|
|
421
|
-
key={file.path}
|
|
422
|
-
content={display}
|
|
423
|
-
wrapMode="none"
|
|
424
|
-
style={{ fg: colors.textStrong, bg }}
|
|
425
|
-
/>
|
|
426
|
-
)
|
|
427
|
-
})
|
|
428
|
-
)}
|
|
744
|
+
<box
|
|
745
|
+
style={{
|
|
746
|
+
flexGrow: 1,
|
|
747
|
+
flexShrink: 1,
|
|
748
|
+
flexDirection: "column",
|
|
749
|
+
paddingLeft: 1,
|
|
750
|
+
backgroundColor: sidebarActive ? colors.background : colors.surface,
|
|
751
|
+
}}
|
|
752
|
+
>
|
|
753
|
+
{sidebarBody}
|
|
754
|
+
</box>
|
|
429
755
|
</box>
|
|
430
756
|
)}
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
<text content={error} style={{ fg: colors.error }} />
|
|
445
|
-
) : (
|
|
446
|
-
<scrollbox
|
|
757
|
+
{readerVisible && (
|
|
758
|
+
<box
|
|
759
|
+
style={{
|
|
760
|
+
border: readerBorderSides,
|
|
761
|
+
borderColor: colors.textMuted,
|
|
762
|
+
flexGrow: 1,
|
|
763
|
+
flexShrink: 1,
|
|
764
|
+
flexDirection: "column",
|
|
765
|
+
// Dim by default (see sidebar note); inner body overrides when active.
|
|
766
|
+
backgroundColor: colors.surface,
|
|
767
|
+
}}
|
|
768
|
+
>
|
|
769
|
+
<box
|
|
447
770
|
style={{
|
|
448
|
-
scrollY: true,
|
|
449
|
-
scrollX: false,
|
|
450
771
|
flexGrow: 1,
|
|
451
772
|
flexShrink: 1,
|
|
452
|
-
|
|
773
|
+
flexDirection: "column",
|
|
774
|
+
padding: 1,
|
|
775
|
+
backgroundColor: readerActive ? colors.background : colors.surface,
|
|
453
776
|
}}
|
|
454
|
-
focused={readerActive}
|
|
455
777
|
>
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
778
|
+
{error ? (
|
|
779
|
+
<text content={error} style={{ fg: colors.error }} />
|
|
780
|
+
) : !renderedPath ? (
|
|
781
|
+
// Reader empty state — no file selected. Brand mark centered as a
|
|
782
|
+
// welcome anchor; in-app tips (#47) will live here too.
|
|
783
|
+
<box
|
|
784
|
+
style={{
|
|
785
|
+
flexGrow: 1,
|
|
786
|
+
flexShrink: 1,
|
|
787
|
+
alignItems: "center",
|
|
788
|
+
justifyContent: "center",
|
|
789
|
+
backgroundColor: readerActive ? colors.background : colors.surface,
|
|
790
|
+
}}
|
|
791
|
+
>
|
|
792
|
+
<text
|
|
793
|
+
content={`${BRAND} ${BRAND_NAME}`}
|
|
794
|
+
wrapMode="none"
|
|
795
|
+
style={{ fg: colors.textMuted }}
|
|
796
|
+
/>
|
|
797
|
+
</box>
|
|
798
|
+
) : (
|
|
799
|
+
<scrollbox
|
|
800
|
+
style={{
|
|
801
|
+
scrollY: true,
|
|
802
|
+
scrollX: false,
|
|
803
|
+
flexGrow: 1,
|
|
804
|
+
flexShrink: 1,
|
|
805
|
+
backgroundColor: readerActive ? colors.background : colors.surface,
|
|
806
|
+
}}
|
|
807
|
+
// opentui's scrollbox consumes arrow keys at the focused-element
|
|
808
|
+
// level *before* useKeyboard fires, so a modal that handles
|
|
809
|
+
// arrow keys itself (palette nav, help dismissal) would still
|
|
810
|
+
// see the reader scroll alongside its own action. Unfocus the
|
|
811
|
+
// scrollbox while any blocking modal is up — useKeyboard's
|
|
812
|
+
// modal branches own the keys in that state. Filter is not
|
|
813
|
+
// listed because it force-focuses the sidebar (readerActive
|
|
814
|
+
// is already false).
|
|
815
|
+
focused={readerActive && !paletteOpen && !helpVisible}
|
|
816
|
+
>
|
|
817
|
+
<markdown
|
|
818
|
+
key={renderedPath ?? "empty"}
|
|
819
|
+
content={content}
|
|
820
|
+
syntaxStyle={syntaxStyle}
|
|
821
|
+
fg={colors.text}
|
|
822
|
+
bg={readerActive ? colors.background : colors.surface}
|
|
823
|
+
conceal
|
|
824
|
+
style={{ width: maxWidth ?? "100%" }}
|
|
825
|
+
/>
|
|
826
|
+
</scrollbox>
|
|
827
|
+
)}
|
|
828
|
+
</box>
|
|
829
|
+
</box>
|
|
830
|
+
)}
|
|
468
831
|
</box>
|
|
469
832
|
<Footer
|
|
470
833
|
bindings={footerBindings}
|
|
471
834
|
ctx={ctx}
|
|
472
835
|
width={width}
|
|
473
|
-
notice={footerNotice}
|
|
474
|
-
|
|
836
|
+
notice={footerNotice?.text ?? null}
|
|
837
|
+
discoveryStatus={discoveryStatus}
|
|
838
|
+
filterQuery={!filterOpen && filterQuery.length > 0 ? filterQuery : null}
|
|
475
839
|
/>
|
|
476
840
|
{helpVisible && (
|
|
477
841
|
<HelpOverlay bindings={browserBindings} viewportWidth={width} viewportHeight={height} />
|
|
478
842
|
)}
|
|
843
|
+
{paletteOpen && (
|
|
844
|
+
<CommandPalette
|
|
845
|
+
commands={filterCommands(buildCommands(ctx), paletteQuery)}
|
|
846
|
+
query={paletteQuery}
|
|
847
|
+
selectedIndex={paletteIndex}
|
|
848
|
+
viewportWidth={width}
|
|
849
|
+
viewportHeight={height}
|
|
850
|
+
/>
|
|
851
|
+
)}
|
|
479
852
|
</box>
|
|
480
853
|
)
|
|
481
854
|
}
|