@carlesandres/house 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +179 -0
  2. package/LICENSE +21 -0
  3. package/README.md +99 -0
  4. package/package.json +67 -0
  5. package/src/Browser.tsx +472 -0
  6. package/src/Footer.tsx +151 -0
  7. package/src/HelpOverlay.tsx +130 -0
  8. package/src/cli/argv.ts +106 -0
  9. package/src/discovery/filter.ts +56 -0
  10. package/src/discovery/walk.ts +143 -0
  11. package/src/index.tsx +265 -0
  12. package/src/io/readFile.ts +14 -0
  13. package/src/keymap/browser.ts +229 -0
  14. package/src/keymap/keymap.ts +86 -0
  15. package/src/serve/css.ts +120 -0
  16. package/src/serve/openBrowser.ts +19 -0
  17. package/src/serve/render.ts +56 -0
  18. package/src/serve/server.ts +163 -0
  19. package/src/theme/atom.ts +23 -0
  20. package/src/theme/colors.ts +79 -0
  21. package/src/theme/loader.ts +110 -0
  22. package/src/theme/registry.ts +12 -0
  23. package/src/theme/resolve.ts +168 -0
  24. package/src/theme/themes/aura.json +58 -0
  25. package/src/theme/themes/ayu.json +69 -0
  26. package/src/theme/themes/carbonfox.json +201 -0
  27. package/src/theme/themes/catppuccin-frappe.json +186 -0
  28. package/src/theme/themes/catppuccin-macchiato.json +186 -0
  29. package/src/theme/themes/catppuccin.json +212 -0
  30. package/src/theme/themes/cobalt2.json +181 -0
  31. package/src/theme/themes/cursor.json +202 -0
  32. package/src/theme/themes/dracula.json +172 -0
  33. package/src/theme/themes/everforest.json +194 -0
  34. package/src/theme/themes/flexoki.json +190 -0
  35. package/src/theme/themes/github.json +186 -0
  36. package/src/theme/themes/gruvbox.json +195 -0
  37. package/src/theme/themes/kanagawa.json +180 -0
  38. package/src/theme/themes/lucent-orng.json +186 -0
  39. package/src/theme/themes/material.json +188 -0
  40. package/src/theme/themes/matrix.json +180 -0
  41. package/src/theme/themes/mercury.json +198 -0
  42. package/src/theme/themes/monokai.json +174 -0
  43. package/src/theme/themes/nightowl.json +174 -0
  44. package/src/theme/themes/nord.json +176 -0
  45. package/src/theme/themes/one-dark.json +184 -0
  46. package/src/theme/themes/opencode.json +198 -0
  47. package/src/theme/themes/orng.json +202 -0
  48. package/src/theme/themes/osaka-jade.json +193 -0
  49. package/src/theme/themes/palenight.json +175 -0
  50. package/src/theme/themes/rosepine.json +187 -0
  51. package/src/theme/themes/solarized.json +176 -0
  52. package/src/theme/themes/synthwave84.json +179 -0
  53. package/src/theme/themes/tokyonight.json +196 -0
  54. package/src/theme/themes/vercel.json +198 -0
  55. package/src/theme/themes/vesper.json +171 -0
  56. package/src/theme/themes/zenburn.json +176 -0
  57. package/src/theme/types.ts +109 -0
@@ -0,0 +1,472 @@
1
+ /**
2
+ * Browser — two-pane mode: file sidebar (left) + reader (right).
3
+ *
4
+ * Minimum-viable iteration:
5
+ * - j/k or arrow keys move selection in the sidebar.
6
+ * - The reader always shows the currently selected file's contents.
7
+ * - q / ctrl+c quit.
8
+ *
9
+ * Deferred to next iteration: focus model, reader scrolling via j/k,
10
+ * sidebar collapse with `\`, help overlay.
11
+ */
12
+
13
+ import { SyntaxStyle } from "@opentui/core"
14
+ import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
15
+ import { useAtomValue, useAtomSet } from "@effect/atom-react"
16
+ import { Effect } from "effect"
17
+ import { useEffect, useMemo, useRef, useState } from "react"
18
+ import { filterFiles } from "./discovery/filter.ts"
19
+ import { type FileEntry } from "./discovery/walk.ts"
20
+ import { Footer, FOOTER_HEIGHT } from "./Footer.tsx"
21
+ import { HelpOverlay } from "./HelpOverlay.tsx"
22
+ import { readFileText } from "./io/readFile.ts"
23
+ import { browserBindings, type BrowserCtx } from "./keymap/browser.ts"
24
+ import { dispatch } from "./keymap/keymap.ts"
25
+ import { openInBrowser } from "./serve/openBrowser.ts"
26
+ import { startServer, type ServerHandle } from "./serve/server.ts"
27
+ import { colors, setActiveTheme } from "./theme/colors.ts"
28
+ import { themeAtom } from "./theme/atom.ts"
29
+ import { themeDefinitions, getThemeDefinition } from "./theme/registry.ts"
30
+
31
+ export interface BrowserProps {
32
+ readonly files: readonly FileEntry[]
33
+ readonly title?: string
34
+ readonly initialIndex?: number
35
+ /** Cap the rendered markdown's width at N columns. Null = fill the pane. */
36
+ readonly maxWidth?: number | null
37
+ readonly onQuit?: () => void
38
+ /** Test seam: replaces the file reader. */
39
+ readonly readFile?: (path: string) => Promise<string>
40
+ }
41
+
42
+ const defaultReadFile = (path: string): Promise<string> => Effect.runPromise(readFileText(path))
43
+
44
+ const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n))
45
+
46
+ /** Bindings the help overlay lets through. Single source of truth for both
47
+ * the keyboard early-return and the footer hint filter. */
48
+ const HELP_ALLOWED_IDS: ReadonlySet<string> = new Set([
49
+ "help.toggle",
50
+ "theme.next",
51
+ "theme.prev",
52
+ "theme.toneToggle",
53
+ ])
54
+
55
+ export const Browser = ({
56
+ files,
57
+ title = "house",
58
+ initialIndex = 0,
59
+ maxWidth = null,
60
+ onQuit,
61
+ readFile = defaultReadFile,
62
+ }: BrowserProps) => {
63
+ const renderer = useRenderer()
64
+ const { width, height } = useTerminalDimensions()
65
+ const theme = useAtomValue(themeAtom)
66
+ const setTheme = useAtomSet(themeAtom)
67
+ const syntaxStyle = useMemo(() => SyntaxStyle.fromStyles(colors.syntax), [theme])
68
+
69
+ const [selectedIndex, setSelectedIndex] = useState(() =>
70
+ clamp(initialIndex, 0, Math.max(0, files.length - 1)),
71
+ )
72
+ const [content, setContent] = useState<string>("")
73
+ const [error, setError] = useState<string | null>(null)
74
+ const [focus, setFocus] = useState<"sidebar" | "reader">("sidebar")
75
+ const [sidebarVisible, setSidebarVisible] = useState<boolean>(true)
76
+ const [sidebarScroll, setSidebarScroll] = useState<number>(0)
77
+ const [helpVisible, setHelpVisible] = useState<boolean>(false)
78
+ const [filterOpen, setFilterOpen] = useState<boolean>(false)
79
+ const [filterQuery, setFilterQuery] = useState<string>("")
80
+ // Mirror filter state into refs so the keyboard handler sees synchronous
81
+ // updates even when multiple keys arrive in a single React batch (the
82
+ // first key opens the filter; subsequent keys in the same tick would
83
+ // otherwise still observe filterOpen=false through closure).
84
+ const filterOpenRef = useRef(false)
85
+ const filterQueryRef = useRef("")
86
+ const [footerNotice, setFooterNotice] = useState<string | null>(null)
87
+ const serverRef = useRef<ServerHandle | null>(null)
88
+
89
+ // Stop the preview server on unmount so re-mounts (tests) and clean
90
+ // shutdowns don't leak a listening socket.
91
+ useEffect(() => {
92
+ return () => {
93
+ void serverRef.current?.stop()
94
+ serverRef.current = null
95
+ }
96
+ }, [])
97
+
98
+ // Single-slot notice with a 2s TTL. A new notice cancels the pending
99
+ // timer so the latest message gets its own full window.
100
+ useEffect(() => {
101
+ if (footerNotice === null) return
102
+ const timer = setTimeout(() => setFooterNotice(null), 2000)
103
+ return () => clearTimeout(timer)
104
+ }, [footerNotice])
105
+
106
+ const cycleTheme = (delta: 1 | -1) => {
107
+ const idx = themeDefinitions.findIndex((d) => d.id === theme.id)
108
+ const next = themeDefinitions[(idx + delta + themeDefinitions.length) % themeDefinitions.length]
109
+ if (!next) return
110
+ setActiveTheme(next, theme.tone)
111
+ setTheme({ id: next.id, tone: theme.tone })
112
+ setFooterNotice(`theme: ${next.name}`)
113
+ }
114
+
115
+ const toggleTone = () => {
116
+ const nextTone = theme.tone === "dark" ? "light" : "dark"
117
+ const def = getThemeDefinition(theme.id)
118
+ if (def) setActiveTheme(def, nextTone)
119
+ setTheme({ id: theme.id, tone: nextTone })
120
+ setFooterNotice(`tone: ${nextTone}`)
121
+ }
122
+
123
+ const displayedFiles = useMemo(() => filterFiles(files, filterQuery), [files, filterQuery])
124
+
125
+ // When the filtered list shrinks, keep selectedIndex valid. The reset to 0
126
+ // on every query change happens in the keystroke handler, not here, so a
127
+ // no-op rerender doesn't snap the cursor back to the top.
128
+ useEffect(() => {
129
+ if (selectedIndex >= displayedFiles.length) {
130
+ setSelectedIndex(displayedFiles.length === 0 ? 0 : displayedFiles.length - 1)
131
+ }
132
+ }, [displayedFiles.length, selectedIndex])
133
+
134
+ const selected = displayedFiles[selectedIndex]
135
+
136
+ // Track the path whose content is currently rendered. Updated lazily via
137
+ // a debounce: rapid j/k presses don't trigger a load+<markdown>-reflow
138
+ // per keystroke. The reflow is the synchronous, main-thread-blocking
139
+ // step inside opentui's host commit — useDeferredValue can't yield once
140
+ // the host begins it. A real debounce gates the load itself.
141
+ const [renderedPath, setRenderedPath] = useState<string | null>(selected?.path ?? null)
142
+
143
+ useEffect(() => {
144
+ const target = selected?.path ?? null
145
+ if (target === renderedPath) return
146
+ const timer = setTimeout(() => setRenderedPath(target), 80)
147
+ return () => clearTimeout(timer)
148
+ }, [selected, renderedPath])
149
+
150
+ useEffect(() => {
151
+ if (!renderedPath) {
152
+ setContent("")
153
+ return
154
+ }
155
+ let cancelled = false
156
+ readFile(renderedPath).then(
157
+ (text) => {
158
+ if (!cancelled) {
159
+ setContent(text)
160
+ setError(null)
161
+ }
162
+ },
163
+ (err: unknown) => {
164
+ if (!cancelled) {
165
+ setContent("")
166
+ setError(`Cannot read ${renderedPath}: ${String(err)}`)
167
+ }
168
+ },
169
+ )
170
+ return () => {
171
+ cancelled = true
172
+ }
173
+ }, [renderedPath, readFile])
174
+
175
+ // One BrowserCtx per render, reused by the keyboard handler and the
176
+ // footer's `when`-evaluation. Keeping a single object eliminates the
177
+ // drift risk between the two consumers as BrowserCtx grows.
178
+ //
179
+ // `files` in ctx refers to the *displayed* list (post-filter) so that
180
+ // keymap when-clauses like `haveFiles` and selection-index actions
181
+ // operate on what the user actually sees.
182
+ const ctx: BrowserCtx = {
183
+ files: displayedFiles,
184
+ focus,
185
+ sidebarVisible,
186
+ helpVisible,
187
+ filterOpen,
188
+ setFocus,
189
+ setSelectedIndex,
190
+ setSidebarVisible,
191
+ setHelpVisible,
192
+ openFilter: () => {
193
+ filterOpenRef.current = true
194
+ setFilterOpen(true)
195
+ },
196
+ cycleTheme,
197
+ toggleTone,
198
+ serveCurrent: () => {
199
+ const file = displayedFiles[selectedIndex]
200
+ if (!file) return
201
+ let handle = serverRef.current
202
+ if (!handle) {
203
+ try {
204
+ handle = startServer({ path: file.path })
205
+ serverRef.current = handle
206
+ openInBrowser(handle.url)
207
+ setFooterNotice(`serving at ${handle.url}`)
208
+ } catch (err) {
209
+ setFooterNotice(`serve failed: ${String(err)}`)
210
+ }
211
+ return
212
+ }
213
+ if (handle.currentTarget() !== file.path) {
214
+ handle.setTarget(file.path)
215
+ }
216
+ // Always re-open: if the user closed the tab, retargeting alone
217
+ // would leave them with nothing visible. `open`/`xdg-open` focus
218
+ // an existing tab on the same URL when one is open, so this is
219
+ // idempotent for the common case.
220
+ openInBrowser(handle.url)
221
+ setFooterNotice(`serving ${file.relativePath} at ${handle.url}`)
222
+ },
223
+ quit: () => {
224
+ if (onQuit) {
225
+ onQuit()
226
+ return
227
+ }
228
+ renderer?.destroy()
229
+ process.exit(0)
230
+ },
231
+ }
232
+
233
+ useKeyboard((key) => {
234
+ // Filter modal: capture keystrokes for the input. Esc closes and
235
+ // clears; Return closes, clears, and focuses the reader (open the
236
+ // match); Backspace edits; Up/Down navigate the filtered list;
237
+ // printable characters extend the query and reset selection to 0.
238
+ // Everything else is swallowed so normal bindings (j/k as nav,
239
+ // `s`, `t`, …) don't fire while the user is typing. This sits
240
+ // outside the data-driven keymap for the same reason the help
241
+ // branch does — see DESIGN.md §12.
242
+ if (filterOpenRef.current) {
243
+ // One close path used by both Esc and Return. Closing the filter
244
+ // restores the full list; translating the highlighted match to
245
+ // its index in `files` keeps the cursor on whatever the user was
246
+ // looking at when they hit the key, instead of landing on a
247
+ // random file at the same numeric position in a now-different
248
+ // list. `focusReader=true` is the Return semantic (open the
249
+ // match); false is Esc (cancel, stay in sidebar).
250
+ //
251
+ // Centralized so the dual filterOpenRef / filterOpen invariant
252
+ // only has to be maintained in one place (plus `openFilter`).
253
+ const closeFilter = (focusReader: boolean) => {
254
+ const picked = displayedFiles[selectedIndex] ?? null
255
+ filterOpenRef.current = false
256
+ filterQueryRef.current = ""
257
+ setFilterOpen(false)
258
+ setFilterQuery("")
259
+ if (picked) {
260
+ const fullIdx = files.findIndex((f) => f.path === picked.path)
261
+ if (fullIdx >= 0) setSelectedIndex(() => fullIdx)
262
+ }
263
+ if (focusReader && picked) setFocus("reader")
264
+ }
265
+ if (key.name === "escape") {
266
+ closeFilter(false)
267
+ return
268
+ }
269
+ if (key.name === "return") {
270
+ closeFilter(true)
271
+ return
272
+ }
273
+ if (key.name === "backspace") {
274
+ filterQueryRef.current = filterQueryRef.current.slice(0, -1)
275
+ setFilterQuery(filterQueryRef.current)
276
+ setSelectedIndex(() => 0)
277
+ return
278
+ }
279
+ if (key.name === "up") {
280
+ setSelectedIndex((i) => Math.max(0, i - 1))
281
+ return
282
+ }
283
+ if (key.name === "down") {
284
+ setSelectedIndex((i) => Math.min(Math.max(0, displayedFiles.length - 1), i + 1))
285
+ return
286
+ }
287
+ if (key.ctrl || key.meta) return
288
+ let char: string | null = null
289
+ if (key.name === "space") char = " "
290
+ else if (typeof key.name === "string" && key.name.length === 1) {
291
+ char = key.shift ? key.name.toUpperCase() : key.name
292
+ }
293
+ if (char !== null) {
294
+ filterQueryRef.current = filterQueryRef.current + char
295
+ setFilterQuery(filterQueryRef.current)
296
+ setSelectedIndex(() => 0)
297
+ }
298
+ return
299
+ }
300
+ // While help is open, swallow most keys: only ? (toggle), esc
301
+ // (close), and the theme bindings pass through. Theme keys stay live
302
+ // so users can preview palette changes against the overlay itself —
303
+ // it is the largest theme-painted surface in the app. Everything else
304
+ // is suppressed so the user can read without driving the UI behind.
305
+ // This is the one place we step outside the data-driven keymap; the
306
+ // alternative — adding `when: !c.helpVisible` to every other binding
307
+ // — would clutter the array. See DESIGN.md §12 (keymap composition).
308
+ if (helpVisible) {
309
+ if (key.name === "escape") {
310
+ setHelpVisible(() => false)
311
+ return
312
+ }
313
+ const allowed = browserBindings.filter((b) => HELP_ALLOWED_IDS.has(b.id))
314
+ // Defensive: stub quit even though no allowed binding currently
315
+ // calls it. Keeps the invariant local to this branch instead of
316
+ // relying on a future maintainer remembering not to add quit-ish
317
+ // bindings to HELP_ALLOWED_IDS.
318
+ dispatch(allowed, { ...ctx, quit: () => {} }, key)
319
+ return
320
+ }
321
+ dispatch(browserBindings, ctx, key)
322
+ })
323
+
324
+ // Min/max-clamped percentage of viewport: narrow terminals stay readable,
325
+ // wide terminals get more room without wasting space at extremes.
326
+ // User-configurable width is deferred — see DESIGN.md §12.
327
+ const sidebarWidth = Math.max(28, Math.min(60, Math.floor(width * 0.25)))
328
+ const sidebarActive = focus === "sidebar"
329
+ const readerActive = focus === "reader"
330
+ const sidebarTitle = sidebarActive ? " ▸ files " : " files "
331
+ const readerLabel = selected?.relativePath ?? title
332
+ const readerTitle = readerActive ? ` ▸ ${readerLabel} ` : ` ${readerLabel} `
333
+
334
+ // Sidebar virtualization: render only the visible window. Without this,
335
+ // every keystroke re-renders all N file rows even though only the bg of
336
+ // two of them changed (old + new selected). On a 195-file vault that
337
+ // dominates the per-keystroke cost.
338
+ // Sidebar box adds top/bottom borders (2); footer eats FOOTER_HEIGHT.
339
+ const sidebarBodyHeight = Math.max(1, height - 2 - FOOTER_HEIGHT)
340
+ const maxScroll = Math.max(0, displayedFiles.length - sidebarBodyHeight)
341
+ const desiredScroll = (() => {
342
+ let s = sidebarScroll
343
+ if (selectedIndex < s) s = selectedIndex
344
+ else if (selectedIndex >= s + sidebarBodyHeight) s = selectedIndex - sidebarBodyHeight + 1
345
+ return clamp(s, 0, maxScroll)
346
+ })()
347
+ useEffect(() => {
348
+ if (desiredScroll !== sidebarScroll) setSidebarScroll(desiredScroll)
349
+ }, [desiredScroll, sidebarScroll])
350
+ const visibleFiles = displayedFiles.slice(desiredScroll, desiredScroll + sidebarBodyHeight)
351
+ // Available width for sidebar text rows: box width minus 1-cell border on each side.
352
+ const sidebarTextWidth = Math.max(4, sidebarWidth - 2)
353
+ // Right-anchored truncation: keep the filename visible, lose the prefix
354
+ // with a leading ellipsis when the path is too long.
355
+ const truncatePath = (s: string): string =>
356
+ s.length <= sidebarTextWidth ? s : "…" + s.slice(s.length - sidebarTextWidth + 1)
357
+
358
+ // While help is open, the `?` key closes the overlay — relabel its hint
359
+ // so the footer accurately describes what pressing the key will do.
360
+ const footerBindings = helpVisible
361
+ ? browserBindings
362
+ .filter((b) => HELP_ALLOWED_IDS.has(b.id))
363
+ .map((b) => (b.id === "help.toggle" ? { ...b, hint: "close" } : b))
364
+ : browserBindings
365
+
366
+ return (
367
+ <box style={{ width, height, flexDirection: "column", backgroundColor: colors.background }}>
368
+ <box
369
+ style={{
370
+ flexDirection: "row",
371
+ flexGrow: 1,
372
+ flexShrink: 1,
373
+ backgroundColor: colors.background,
374
+ }}
375
+ >
376
+ {sidebarVisible && (
377
+ <box
378
+ title={sidebarTitle}
379
+ titleAlignment="left"
380
+ style={{
381
+ border: true,
382
+ borderColor: sidebarActive ? colors.borderActive : colors.border,
383
+ width: sidebarWidth,
384
+ flexShrink: 0,
385
+ flexDirection: "column",
386
+ backgroundColor: colors.surface,
387
+ }}
388
+ >
389
+ {displayedFiles.length === 0 ? (
390
+ <text
391
+ content={files.length === 0 ? "(no markdown files)" : "(no matches)"}
392
+ style={{ fg: colors.textMuted }}
393
+ />
394
+ ) : (
395
+ visibleFiles.map((file, idx) => {
396
+ const realIdx = desiredScroll + idx
397
+ const isSelected = realIdx === selectedIndex
398
+ const display = truncatePath(file.relativePath)
399
+ if (!isSelected) {
400
+ return (
401
+ <text
402
+ key={file.path}
403
+ content={display}
404
+ wrapMode="none"
405
+ style={{ fg: colors.text }}
406
+ />
407
+ )
408
+ }
409
+ const bg = sidebarActive ? colors.selectedBg : colors.selectedBgInactive
410
+ return (
411
+ <text
412
+ key={file.path}
413
+ content={display}
414
+ wrapMode="none"
415
+ style={{ fg: colors.textStrong, bg }}
416
+ />
417
+ )
418
+ })
419
+ )}
420
+ </box>
421
+ )}
422
+ <box
423
+ title={readerTitle}
424
+ titleAlignment="left"
425
+ style={{
426
+ border: true,
427
+ borderColor: readerActive ? colors.borderActive : colors.border,
428
+ padding: 1,
429
+ flexGrow: 1,
430
+ flexShrink: 1,
431
+ backgroundColor: colors.background,
432
+ }}
433
+ >
434
+ {error ? (
435
+ <text content={error} style={{ fg: colors.error }} />
436
+ ) : (
437
+ <scrollbox
438
+ style={{
439
+ scrollY: true,
440
+ scrollX: false,
441
+ flexGrow: 1,
442
+ flexShrink: 1,
443
+ backgroundColor: colors.background,
444
+ }}
445
+ focused={readerActive}
446
+ >
447
+ <markdown
448
+ key={renderedPath ?? "empty"}
449
+ content={content}
450
+ syntaxStyle={syntaxStyle}
451
+ fg={colors.text}
452
+ bg={colors.background}
453
+ conceal
454
+ style={{ width: maxWidth ?? "100%" }}
455
+ />
456
+ </scrollbox>
457
+ )}
458
+ </box>
459
+ </box>
460
+ <Footer
461
+ bindings={footerBindings}
462
+ ctx={ctx}
463
+ width={width}
464
+ notice={footerNotice}
465
+ filter={filterOpen ? { query: filterQuery } : null}
466
+ />
467
+ {helpVisible && (
468
+ <HelpOverlay bindings={browserBindings} viewportWidth={width} viewportHeight={height} />
469
+ )}
470
+ </box>
471
+ )
472
+ }
package/src/Footer.tsx ADDED
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Footer — single-row chrome under the two-pane area.
3
+ *
4
+ * Renders either a notice line (when one is active) or a compact hint row
5
+ * derived from the keymap. Hints are filtered by each binding's `when`
6
+ * against the current context, so the row reflects what the user can
7
+ * actually do right now. Overflow is handled by truncating from the right
8
+ * (later-in-array bindings drop off first).
9
+ *
10
+ * Empty vault: the footer renders normally even when no markdown files
11
+ * were discovered. Intentional — `q:quit` and `?:help` are exactly what an
12
+ * empty-vault user needs as an exit and discoverability anchor.
13
+ *
14
+ * Width math assumes hint labels are ASCII plus a small set of single-cell
15
+ * BMP glyphs (see `displayKey`). `fitHints` and notice clipping use string
16
+ * length as a proxy for cell count; introducing a CJK or emoji label would
17
+ * require a real cell-width counter (e.g. East Asian Width).
18
+ */
19
+
20
+ import type { KeyBinding } from "./keymap/keymap.ts"
21
+ import { colors } from "./theme/colors.ts"
22
+
23
+ /** Rows the Footer occupies. Importers use it for layout math so a future
24
+ * taller footer doesn't require touching two files. */
25
+ export const FOOTER_HEIGHT = 1
26
+
27
+ export interface FooterProps<C> {
28
+ readonly bindings: readonly KeyBinding<C>[]
29
+ readonly ctx: C
30
+ readonly width: number
31
+ readonly notice?: string | null
32
+ /** When set, the footer row turns into the filter input — `/<query>▏` —
33
+ * and suppresses both the hint row and the notice. Mirrors hunk's
34
+ * StatusBar: one row of chrome, content swaps by state. */
35
+ readonly filter?: { readonly query: string } | null
36
+ }
37
+
38
+ const HINT_SEPARATOR = " "
39
+
40
+ /** Display form for the first key of a binding. Picks the first chord and
41
+ * rewrites a few names to terminal-friendly shorthands.
42
+ *
43
+ * Footer policy: only the first key is shown, even when a binding has
44
+ * aliases (e.g. `sidebar.open` accepts `return`/`right`/`l`). The footer
45
+ * is a narrow real-estate budget, and listing every alias would push out
46
+ * other bindings on tight viewports. The full alias list lives in the
47
+ * help overlay (`?`). */
48
+ const displayKey = (raw: string): string => {
49
+ switch (raw) {
50
+ case "return":
51
+ return "↵"
52
+ case "escape":
53
+ return "esc"
54
+ case "space":
55
+ return "␣"
56
+ case "pageup":
57
+ return "pgup"
58
+ case "pagedown":
59
+ return "pgdn"
60
+ default:
61
+ return raw
62
+ }
63
+ }
64
+
65
+ const formatHint = <C,>(b: KeyBinding<C>): string | null => {
66
+ if (!b.hint) return null
67
+ const first = b.keys[0]
68
+ if (!first) return null
69
+ return `${displayKey(first)}:${b.hint}`
70
+ }
71
+
72
+ /** Drop hints from the end until the joined string fits within `width`.
73
+ * If not even the first hint fits, fall back to the bare key portion so
74
+ * the user still sees a discoverability anchor (e.g. `?` instead of an
75
+ * empty row on an 8-column terminal). */
76
+ const fitHints = (hints: readonly string[], width: number): string => {
77
+ if (width <= 0 || hints.length === 0) return ""
78
+ let acc = ""
79
+ for (const h of hints) {
80
+ const next = acc.length === 0 ? h : `${acc}${HINT_SEPARATOR}${h}`
81
+ if (next.length > width) break
82
+ acc = next
83
+ }
84
+ if (acc.length > 0) return acc
85
+ // Nothing fit. Render just the first hint's key (everything before `:`)
86
+ // truncated to width, so the row is never silently blank.
87
+ const firstKey = hints[0]!.split(":")[0] ?? ""
88
+ return firstKey.slice(0, width)
89
+ }
90
+
91
+ /** Hints shown alongside the filter input. Mirrors the modal-mode key
92
+ * handler in `Browser.tsx`; if those bindings change, update both. */
93
+ const FILTER_HINTS = "↵:open esc:cancel"
94
+ /** Minimum gap between the filter input and its hints on the same row. */
95
+ const FILTER_HINT_GAP = 2
96
+
97
+ export const Footer = <C,>({ bindings, ctx, width, notice, filter }: FooterProps<C>) => {
98
+ const usableWidth = Math.max(0, width - 2) // 1-cell horizontal padding each side
99
+
100
+ const rowStyle = {
101
+ width,
102
+ height: FOOTER_HEIGHT,
103
+ flexShrink: 0,
104
+ flexDirection: "row",
105
+ paddingLeft: 1,
106
+ paddingRight: 1,
107
+ backgroundColor: colors.background,
108
+ } as const
109
+
110
+ // Filter mode is two-column: input left, hints right, separated by a
111
+ // flex-grow spacer. Hints drop entirely on narrow viewports rather than
112
+ // pushing the input off-screen — the input is the primary surface.
113
+ if (filter) {
114
+ const input = `/${filter.query}▏`
115
+ const showHints = input.length + FILTER_HINT_GAP + FILTER_HINTS.length <= usableWidth
116
+ return (
117
+ <box style={rowStyle}>
118
+ <text content={input} wrapMode="none" style={{ fg: colors.textStrong }} />
119
+ {showHints && (
120
+ <>
121
+ <box style={{ flexGrow: 1 }} />
122
+ <text content={FILTER_HINTS} wrapMode="none" style={{ fg: colors.textMuted }} />
123
+ </>
124
+ )}
125
+ </box>
126
+ )
127
+ }
128
+
129
+ const hintContent = fitHints(
130
+ bindings
131
+ .filter((b) => (b.when ? b.when(ctx) : true))
132
+ .map(formatHint)
133
+ .filter((s): s is string => s !== null),
134
+ usableWidth,
135
+ )
136
+ const noticeContent = notice
137
+ ? notice.length > usableWidth
138
+ ? notice.slice(0, usableWidth)
139
+ : notice
140
+ : null
141
+
142
+ // Notice > hints. Notice fg is strong; hints are muted.
143
+ const content = noticeContent ?? hintContent
144
+ const fg = noticeContent ? colors.textStrong : colors.textMuted
145
+
146
+ return (
147
+ <box style={rowStyle}>
148
+ <text content={content} wrapMode="none" style={{ fg }} />
149
+ </box>
150
+ )
151
+ }