@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.
- package/CHANGELOG.md +179 -0
- package/LICENSE +21 -0
- package/README.md +99 -0
- package/package.json +67 -0
- package/src/Browser.tsx +472 -0
- package/src/Footer.tsx +151 -0
- package/src/HelpOverlay.tsx +130 -0
- package/src/cli/argv.ts +106 -0
- package/src/discovery/filter.ts +56 -0
- package/src/discovery/walk.ts +143 -0
- package/src/index.tsx +265 -0
- package/src/io/readFile.ts +14 -0
- package/src/keymap/browser.ts +229 -0
- package/src/keymap/keymap.ts +86 -0
- package/src/serve/css.ts +120 -0
- package/src/serve/openBrowser.ts +19 -0
- package/src/serve/render.ts +56 -0
- package/src/serve/server.ts +163 -0
- package/src/theme/atom.ts +23 -0
- package/src/theme/colors.ts +79 -0
- package/src/theme/loader.ts +110 -0
- package/src/theme/registry.ts +12 -0
- package/src/theme/resolve.ts +168 -0
- package/src/theme/themes/aura.json +58 -0
- package/src/theme/themes/ayu.json +69 -0
- package/src/theme/themes/carbonfox.json +201 -0
- package/src/theme/themes/catppuccin-frappe.json +186 -0
- package/src/theme/themes/catppuccin-macchiato.json +186 -0
- package/src/theme/themes/catppuccin.json +212 -0
- package/src/theme/themes/cobalt2.json +181 -0
- package/src/theme/themes/cursor.json +202 -0
- package/src/theme/themes/dracula.json +172 -0
- package/src/theme/themes/everforest.json +194 -0
- package/src/theme/themes/flexoki.json +190 -0
- package/src/theme/themes/github.json +186 -0
- package/src/theme/themes/gruvbox.json +195 -0
- package/src/theme/themes/kanagawa.json +180 -0
- package/src/theme/themes/lucent-orng.json +186 -0
- package/src/theme/themes/material.json +188 -0
- package/src/theme/themes/matrix.json +180 -0
- package/src/theme/themes/mercury.json +198 -0
- package/src/theme/themes/monokai.json +174 -0
- package/src/theme/themes/nightowl.json +174 -0
- package/src/theme/themes/nord.json +176 -0
- package/src/theme/themes/one-dark.json +184 -0
- package/src/theme/themes/opencode.json +198 -0
- package/src/theme/themes/orng.json +202 -0
- package/src/theme/themes/osaka-jade.json +193 -0
- package/src/theme/themes/palenight.json +175 -0
- package/src/theme/themes/rosepine.json +187 -0
- package/src/theme/themes/solarized.json +176 -0
- package/src/theme/themes/synthwave84.json +179 -0
- package/src/theme/themes/tokyonight.json +196 -0
- package/src/theme/themes/vercel.json +198 -0
- package/src/theme/themes/vesper.json +171 -0
- package/src/theme/themes/zenburn.json +176 -0
- package/src/theme/types.ts +109 -0
package/src/Browser.tsx
ADDED
|
@@ -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
|
+
}
|