@carlesandres/house 0.3.0 → 0.4.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 +55 -2
- package/README.md +43 -13
- package/package.json +6 -4
- package/src/Browser.tsx +371 -67
- package/src/CommandPalette.tsx +126 -0
- package/src/Footer.tsx +65 -38
- package/src/cli/argv.ts +30 -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 +166 -0
- package/src/discovery/walk.ts +59 -20
- package/src/index.tsx +106 -28
- package/src/keymap/browser.ts +26 -11
- package/src/layout/resolve.ts +60 -0
- package/src/theme/colors.ts +3 -0
package/src/Browser.tsx
CHANGED
|
@@ -14,7 +14,10 @@ import { SyntaxStyle } from "@opentui/core"
|
|
|
14
14
|
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
|
|
15
15
|
import { useAtomValue, useAtomSet } from "@effect/atom-react"
|
|
16
16
|
import { Effect } from "effect"
|
|
17
|
-
import { useEffect, useMemo, useRef, useState } from "react"
|
|
17
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
18
|
+
import { buildCommands } from "./commands/buildCommands.ts"
|
|
19
|
+
import { clampSelectedIndex, filterCommands } from "./commands/score.ts"
|
|
20
|
+
import { CommandPalette } from "./CommandPalette.tsx"
|
|
18
21
|
import { filterFiles } from "./discovery/filter.ts"
|
|
19
22
|
import { type FileEntry } from "./discovery/walk.ts"
|
|
20
23
|
import { Footer, FOOTER_HEIGHT } from "./Footer.tsx"
|
|
@@ -22,18 +25,32 @@ import { HelpOverlay } from "./HelpOverlay.tsx"
|
|
|
22
25
|
import { readFileText } from "./io/readFile.ts"
|
|
23
26
|
import { browserBindings, type BrowserCtx } from "./keymap/browser.ts"
|
|
24
27
|
import { dispatch } from "./keymap/keymap.ts"
|
|
28
|
+
import {
|
|
29
|
+
canFitInline,
|
|
30
|
+
defaultPreferredWidth,
|
|
31
|
+
initialShownForAuto,
|
|
32
|
+
resolveSidebarWidth,
|
|
33
|
+
} from "./layout/resolve.ts"
|
|
25
34
|
import { openInBrowser } from "./serve/openBrowser.ts"
|
|
26
35
|
import { startServer, type ServerHandle } from "./serve/server.ts"
|
|
27
36
|
import { colors, setActiveTheme } from "./theme/colors.ts"
|
|
28
37
|
import { themeAtom } from "./theme/atom.ts"
|
|
29
38
|
import { themeDefinitions, getThemeDefinition } from "./theme/registry.ts"
|
|
30
39
|
|
|
40
|
+
export type SidebarMode = "auto" | "on" | "off"
|
|
41
|
+
|
|
31
42
|
export interface BrowserProps {
|
|
32
43
|
readonly files: readonly FileEntry[]
|
|
33
44
|
readonly title?: string
|
|
34
45
|
readonly initialIndex?: number
|
|
35
46
|
/** Cap the rendered markdown's width at N columns. Null = fill the pane. */
|
|
36
47
|
readonly maxWidth?: number | null
|
|
48
|
+
/** Persistent footer indicator (e.g. "indexing… 42"). Pass null/undefined
|
|
49
|
+
* when discovery has finished; the indicator clears. */
|
|
50
|
+
readonly discoveryStatus?: string | null
|
|
51
|
+
/** Initial sidebar visibility (`--sidebar` flag). `auto` consults the
|
|
52
|
+
* launch viewport bucket once; subsequent visibility goes through `s`. */
|
|
53
|
+
readonly sidebarMode?: SidebarMode
|
|
37
54
|
readonly onQuit?: () => void
|
|
38
55
|
/** Test seam: replaces the file reader. */
|
|
39
56
|
readonly readFile?: (path: string) => Promise<string>
|
|
@@ -44,12 +61,15 @@ const defaultReadFile = (path: string): Promise<string> => Effect.runPromise(rea
|
|
|
44
61
|
const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n))
|
|
45
62
|
|
|
46
63
|
/** Bindings the help overlay lets through. Single source of truth for both
|
|
47
|
-
* the keyboard early-return and the footer hint filter.
|
|
64
|
+
* the keyboard early-return and the footer hint filter. `palette.open`
|
|
65
|
+
* passes through so users can jump from help into the palette in one
|
|
66
|
+
* keystroke — `openPalette` closes help on its way in. */
|
|
48
67
|
const HELP_ALLOWED_IDS: ReadonlySet<string> = new Set([
|
|
49
68
|
"help.toggle",
|
|
50
69
|
"theme.next",
|
|
51
70
|
"theme.prev",
|
|
52
71
|
"theme.toneToggle",
|
|
72
|
+
"palette.open",
|
|
53
73
|
])
|
|
54
74
|
|
|
55
75
|
export const Browser = ({
|
|
@@ -57,6 +77,8 @@ export const Browser = ({
|
|
|
57
77
|
title = "house",
|
|
58
78
|
initialIndex = 0,
|
|
59
79
|
maxWidth = null,
|
|
80
|
+
discoveryStatus = null,
|
|
81
|
+
sidebarMode = "auto",
|
|
60
82
|
onQuit,
|
|
61
83
|
readFile = defaultReadFile,
|
|
62
84
|
}: BrowserProps) => {
|
|
@@ -69,20 +91,49 @@ export const Browser = ({
|
|
|
69
91
|
const [selectedIndex, setSelectedIndex] = useState(() =>
|
|
70
92
|
clamp(initialIndex, 0, Math.max(0, files.length - 1)),
|
|
71
93
|
)
|
|
72
|
-
const [
|
|
94
|
+
const [loaded, setLoaded] = useState<{ path: string; content: string } | null>(null)
|
|
73
95
|
const [error, setError] = useState<string | null>(null)
|
|
74
|
-
|
|
75
|
-
|
|
96
|
+
// `shown` is the user's sticky preference. Visibility is derived:
|
|
97
|
+
// `visible = shown || focus === "sidebar"`. See DESIGN.md §7.1.
|
|
98
|
+
//
|
|
99
|
+
// Launch consults the viewport bucket once for `--sidebar=auto`. The
|
|
100
|
+
// useState initializer pins this to the first render — buckets are
|
|
101
|
+
// launch-only by design, so resize must NOT re-evaluate.
|
|
102
|
+
const [shown, setShown] = useState<boolean>(() => {
|
|
103
|
+
switch (sidebarMode) {
|
|
104
|
+
case "on":
|
|
105
|
+
return true
|
|
106
|
+
case "off":
|
|
107
|
+
return false
|
|
108
|
+
case "auto":
|
|
109
|
+
return initialShownForAuto(width)
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
const [focus, setFocus] = useState<"sidebar" | "reader">(() => (shown ? "sidebar" : "reader"))
|
|
76
113
|
const [sidebarScroll, setSidebarScroll] = useState<number>(0)
|
|
77
114
|
const [helpVisible, setHelpVisible] = useState<boolean>(false)
|
|
78
115
|
const [filterOpen, setFilterOpen] = useState<boolean>(false)
|
|
79
116
|
const [filterQuery, setFilterQuery] = useState<string>("")
|
|
117
|
+
const [paletteOpen, setPaletteOpen] = useState<boolean>(false)
|
|
118
|
+
const [paletteQuery, setPaletteQuery] = useState<string>("")
|
|
119
|
+
const [paletteIndex, setPaletteIndex] = useState<number>(0)
|
|
120
|
+
// Synchronous mirrors for the keyboard handler — same reason filterOpenRef
|
|
121
|
+
// exists. Modal input can arrive in one React batch (e.g. ctrl+p, Down,
|
|
122
|
+
// Return), so every palette field read by later keys must update its ref
|
|
123
|
+
// before React state commits.
|
|
124
|
+
const paletteOpenRef = useRef(false)
|
|
125
|
+
const paletteQueryRef = useRef("")
|
|
126
|
+
const paletteIndexRef = useRef(0)
|
|
80
127
|
// Mirror filter state into refs so the keyboard handler sees synchronous
|
|
81
128
|
// updates even when multiple keys arrive in a single React batch (the
|
|
82
129
|
// first key opens the filter; subsequent keys in the same tick would
|
|
83
130
|
// otherwise still observe filterOpen=false through closure).
|
|
84
131
|
const filterOpenRef = useRef(false)
|
|
85
132
|
const filterQueryRef = useRef("")
|
|
133
|
+
// Snapshot the query at filter-open so Esc reverts edits but commit (Return)
|
|
134
|
+
// keeps them. Layout snapshots are no longer needed — focus drives drawer
|
|
135
|
+
// dismissal under the §7.1 visibility rule.
|
|
136
|
+
const priorFilterQueryRef = useRef("")
|
|
86
137
|
const [footerNotice, setFooterNotice] = useState<string | null>(null)
|
|
87
138
|
const serverRef = useRef<ServerHandle | null>(null)
|
|
88
139
|
|
|
@@ -121,7 +172,6 @@ export const Browser = ({
|
|
|
121
172
|
}
|
|
122
173
|
|
|
123
174
|
const displayedFiles = useMemo(() => filterFiles(files, filterQuery), [files, filterQuery])
|
|
124
|
-
|
|
125
175
|
// When the filtered list shrinks, keep selectedIndex valid. The reset to 0
|
|
126
176
|
// on every query change happens in the keystroke handler, not here, so a
|
|
127
177
|
// no-op rerender doesn't snap the cursor back to the top.
|
|
@@ -145,24 +195,24 @@ export const Browser = ({
|
|
|
145
195
|
if (target === renderedPath) return
|
|
146
196
|
const timer = setTimeout(() => setRenderedPath(target), 80)
|
|
147
197
|
return () => clearTimeout(timer)
|
|
148
|
-
}, [selected, renderedPath])
|
|
198
|
+
}, [selected?.path, renderedPath])
|
|
149
199
|
|
|
150
200
|
useEffect(() => {
|
|
151
201
|
if (!renderedPath) {
|
|
152
|
-
|
|
202
|
+
setLoaded(null)
|
|
153
203
|
return
|
|
154
204
|
}
|
|
155
205
|
let cancelled = false
|
|
156
206
|
readFile(renderedPath).then(
|
|
157
207
|
(text) => {
|
|
158
208
|
if (!cancelled) {
|
|
159
|
-
|
|
209
|
+
setLoaded({ path: renderedPath, content: text })
|
|
160
210
|
setError(null)
|
|
161
211
|
}
|
|
162
212
|
},
|
|
163
213
|
(err: unknown) => {
|
|
164
214
|
if (!cancelled) {
|
|
165
|
-
|
|
215
|
+
setLoaded(null)
|
|
166
216
|
setError(`Cannot read ${renderedPath}: ${String(err)}`)
|
|
167
217
|
}
|
|
168
218
|
},
|
|
@@ -182,17 +232,51 @@ export const Browser = ({
|
|
|
182
232
|
const ctx: BrowserCtx = {
|
|
183
233
|
files: displayedFiles,
|
|
184
234
|
focus,
|
|
185
|
-
|
|
235
|
+
sidebarShown: shown,
|
|
186
236
|
helpVisible,
|
|
187
237
|
filterOpen,
|
|
238
|
+
paletteOpen,
|
|
188
239
|
setFocus,
|
|
189
240
|
setSelectedIndex,
|
|
190
|
-
|
|
241
|
+
toggleShown: () => {
|
|
242
|
+
// Per DESIGN.md §7.1 s-behavior table:
|
|
243
|
+
// shown=true, focus=sidebar → shown=false, focus=reader
|
|
244
|
+
// (otherwise the drawer would
|
|
245
|
+
// immediately re-appear)
|
|
246
|
+
// shown=true, focus=reader → shown=false, focus=reader
|
|
247
|
+
// shown=false, focus=reader → shown=true, focus=sidebar
|
|
248
|
+
// shown=false, focus=sidebar → shown=true, focus=sidebar
|
|
249
|
+
if (shown) {
|
|
250
|
+
setShown(false)
|
|
251
|
+
if (focus === "sidebar") setFocus("reader")
|
|
252
|
+
} else {
|
|
253
|
+
setShown(true)
|
|
254
|
+
if (focus === "reader") setFocus("sidebar")
|
|
255
|
+
}
|
|
256
|
+
},
|
|
191
257
|
setHelpVisible,
|
|
192
258
|
openFilter: () => {
|
|
259
|
+
// Focus the sidebar so the filter input has a home. Under §7.1's
|
|
260
|
+
// `visible = shown || focus==="sidebar"` rule, focus alone makes
|
|
261
|
+
// the sidebar visible (as a drawer when `shown=false`), so we no
|
|
262
|
+
// longer need to mutate `shown` here.
|
|
263
|
+
priorFilterQueryRef.current = filterQueryRef.current
|
|
264
|
+
if (focus !== "sidebar") setFocus("sidebar")
|
|
193
265
|
filterOpenRef.current = true
|
|
194
266
|
setFilterOpen(true)
|
|
195
267
|
},
|
|
268
|
+
openPalette: () => {
|
|
269
|
+
// Close help if it was open — palette is the active modal now.
|
|
270
|
+
// Reset query/index so each open starts fresh (no stale state from
|
|
271
|
+
// the previous session).
|
|
272
|
+
if (helpVisible) setHelpVisible(() => false)
|
|
273
|
+
paletteQueryRef.current = ""
|
|
274
|
+
setPaletteQuery("")
|
|
275
|
+
paletteIndexRef.current = 0
|
|
276
|
+
setPaletteIndex(0)
|
|
277
|
+
paletteOpenRef.current = true
|
|
278
|
+
setPaletteOpen(true)
|
|
279
|
+
},
|
|
196
280
|
cycleTheme,
|
|
197
281
|
toggleTone,
|
|
198
282
|
serveCurrent: () => {
|
|
@@ -250,17 +334,46 @@ export const Browser = ({
|
|
|
250
334
|
//
|
|
251
335
|
// Centralized so the dual filterOpenRef / filterOpen invariant
|
|
252
336
|
// only has to be maintained in one place (plus `openFilter`).
|
|
253
|
-
const closeFilter = (
|
|
337
|
+
const closeFilter = (commit: boolean) => {
|
|
254
338
|
const picked = displayedFiles[selectedIndex] ?? null
|
|
339
|
+
// Return on a zero-match list has nothing to commit. Treat it
|
|
340
|
+
// as Esc so the user isn't stranded in an "applied filter with
|
|
341
|
+
// no visible files" state they'd have to back out of manually.
|
|
342
|
+
const effectiveCommit = commit && picked !== null
|
|
255
343
|
filterOpenRef.current = false
|
|
256
|
-
filterQueryRef.current = ""
|
|
257
344
|
setFilterOpen(false)
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
345
|
+
if (effectiveCommit) {
|
|
346
|
+
// Return keeps the query. selectedIndex is already a valid
|
|
347
|
+
// position in the (still-filtered) displayedFiles list, so
|
|
348
|
+
// no translation is needed.
|
|
349
|
+
} else {
|
|
350
|
+
// Esc reverts the query to its pre-session value. After the
|
|
351
|
+
// revert, displayedFiles may change shape — translate the
|
|
352
|
+
// cursor by path so it stays on whatever the user was
|
|
353
|
+
// looking at, instead of snapping to a numerically-equivalent
|
|
354
|
+
// row in the restored list.
|
|
355
|
+
const before = priorFilterQueryRef.current
|
|
356
|
+
filterQueryRef.current = before
|
|
357
|
+
setFilterQuery(before)
|
|
358
|
+
if (picked) {
|
|
359
|
+
const restored = before === "" ? files : filterFiles(files, before)
|
|
360
|
+
const idx = restored.findIndex((f) => f.path === picked.path)
|
|
361
|
+
if (idx >= 0) setSelectedIndex(() => idx)
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
// Where focus lands depends on layout intent (DESIGN.md §7.1):
|
|
365
|
+
// commit (Return on a real pick) → reader, always. The user
|
|
366
|
+
// asked to open the match; show them what they picked.
|
|
367
|
+
// cancel (Esc, or Return with no pick) → if the sidebar is
|
|
368
|
+
// inline (shown && fits), keep focus there so j/k keeps
|
|
369
|
+
// walking the list; if the sidebar was only up as a drawer
|
|
370
|
+
// (shown=false), dismiss focus to the reader so the drawer
|
|
371
|
+
// disappears under the §7.1 visibility rule.
|
|
372
|
+
if (effectiveCommit) {
|
|
373
|
+
setFocus("reader")
|
|
374
|
+
} else {
|
|
375
|
+
setFocus(shown && canFitInline(width) ? "sidebar" : "reader")
|
|
262
376
|
}
|
|
263
|
-
if (focusReader && picked) setFocus("reader")
|
|
264
377
|
}
|
|
265
378
|
if (key.name === "escape") {
|
|
266
379
|
closeFilter(false)
|
|
@@ -270,7 +383,15 @@ export const Browser = ({
|
|
|
270
383
|
closeFilter(true)
|
|
271
384
|
return
|
|
272
385
|
}
|
|
273
|
-
if (key.name === "backspace") {
|
|
386
|
+
if (key.name === "backspace" || key.name === "delete") {
|
|
387
|
+
// Pressing backspace/delete with no query left removes the
|
|
388
|
+
// leading `/` — i.e. closes the modal. Equivalent to Esc:
|
|
389
|
+
// reverts to the pre-session query (so an applied filter
|
|
390
|
+
// survives a "I changed my mind" tap).
|
|
391
|
+
if (filterQueryRef.current.length === 0) {
|
|
392
|
+
closeFilter(false)
|
|
393
|
+
return
|
|
394
|
+
}
|
|
274
395
|
filterQueryRef.current = filterQueryRef.current.slice(0, -1)
|
|
275
396
|
setFilterQuery(filterQueryRef.current)
|
|
276
397
|
setSelectedIndex(() => 0)
|
|
@@ -297,6 +418,74 @@ export const Browser = ({
|
|
|
297
418
|
}
|
|
298
419
|
return
|
|
299
420
|
}
|
|
421
|
+
// Command palette modal: capture keystrokes for the query input and
|
|
422
|
+
// list navigation. Esc closes (single press, regardless of query —
|
|
423
|
+
// #70 Q7a). Return runs the selected command. Up/Down navigate.
|
|
424
|
+
// Backspace edits the query and is a no-op on empty (#70 Q7b —
|
|
425
|
+
// intentionally diverges from the filter modal, which closes on
|
|
426
|
+
// empty-backspace, because accidental close feels worse in the
|
|
427
|
+
// palette). Printable characters extend the query and snap selection
|
|
428
|
+
// to 0 (#70 Q7c). Ctrl/Meta-modified keys are swallowed except
|
|
429
|
+
// ctrl+p, which toggles the palette closed (matches help-toggle's
|
|
430
|
+
// re-press-to-close behavior).
|
|
431
|
+
if (paletteOpenRef.current) {
|
|
432
|
+
const closePalette = () => {
|
|
433
|
+
paletteOpenRef.current = false
|
|
434
|
+
paletteQueryRef.current = ""
|
|
435
|
+
paletteIndexRef.current = 0
|
|
436
|
+
setPaletteOpen(false)
|
|
437
|
+
setPaletteQuery("")
|
|
438
|
+
setPaletteIndex(0)
|
|
439
|
+
}
|
|
440
|
+
const setPaletteIndexSync = (next: number) => {
|
|
441
|
+
paletteIndexRef.current = next
|
|
442
|
+
setPaletteIndex(next)
|
|
443
|
+
}
|
|
444
|
+
const allCommands = buildCommands(ctx)
|
|
445
|
+
const filtered = filterCommands(allCommands, paletteQueryRef.current)
|
|
446
|
+
if (key.name === "escape") {
|
|
447
|
+
closePalette()
|
|
448
|
+
return
|
|
449
|
+
}
|
|
450
|
+
if (key.name === "return") {
|
|
451
|
+
const picked = filtered[clampSelectedIndex(paletteIndexRef.current, filtered)]
|
|
452
|
+
closePalette()
|
|
453
|
+
picked?.run()
|
|
454
|
+
return
|
|
455
|
+
}
|
|
456
|
+
if (key.name === "up") {
|
|
457
|
+
setPaletteIndexSync(Math.max(0, paletteIndexRef.current - 1))
|
|
458
|
+
return
|
|
459
|
+
}
|
|
460
|
+
if (key.name === "down") {
|
|
461
|
+
setPaletteIndexSync(Math.min(Math.max(0, filtered.length - 1), paletteIndexRef.current + 1))
|
|
462
|
+
return
|
|
463
|
+
}
|
|
464
|
+
if (key.name === "backspace" || key.name === "delete") {
|
|
465
|
+
if (paletteQueryRef.current.length === 0) return
|
|
466
|
+
paletteQueryRef.current = paletteQueryRef.current.slice(0, -1)
|
|
467
|
+
setPaletteQuery(paletteQueryRef.current)
|
|
468
|
+
setPaletteIndexSync(0)
|
|
469
|
+
return
|
|
470
|
+
}
|
|
471
|
+
// ctrl+p again closes — matches help-toggle behavior.
|
|
472
|
+
if (key.ctrl && !key.meta && key.name === "p") {
|
|
473
|
+
closePalette()
|
|
474
|
+
return
|
|
475
|
+
}
|
|
476
|
+
if (key.ctrl || key.meta) return
|
|
477
|
+
let char: string | null = null
|
|
478
|
+
if (key.name === "space") char = " "
|
|
479
|
+
else if (typeof key.name === "string" && key.name.length === 1) {
|
|
480
|
+
char = key.shift ? key.name.toUpperCase() : key.name
|
|
481
|
+
}
|
|
482
|
+
if (char !== null) {
|
|
483
|
+
paletteQueryRef.current = paletteQueryRef.current + char
|
|
484
|
+
setPaletteQuery(paletteQueryRef.current)
|
|
485
|
+
setPaletteIndexSync(0)
|
|
486
|
+
}
|
|
487
|
+
return
|
|
488
|
+
}
|
|
300
489
|
// While help is open, swallow most keys: only ? (toggle), esc
|
|
301
490
|
// (close), and the theme bindings pass through. Theme keys stay live
|
|
302
491
|
// so users can preview palette changes against the overlay itself —
|
|
@@ -321,22 +510,49 @@ export const Browser = ({
|
|
|
321
510
|
dispatch(browserBindings, ctx, key)
|
|
322
511
|
})
|
|
323
512
|
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
const sidebarWidth =
|
|
513
|
+
// Sidebar width is a pure function of viewport (DESIGN.md §7.1). Until
|
|
514
|
+
// persistent config (#13) lands, `preferred` is derived from viewport,
|
|
515
|
+
// matching the pre-#22 inline math.
|
|
516
|
+
const sidebarWidth = resolveSidebarWidth(width, defaultPreferredWidth(width))
|
|
328
517
|
const sidebarActive = focus === "sidebar"
|
|
329
518
|
const readerActive = focus === "reader"
|
|
519
|
+
// Visibility = shown OR sidebar-focused. When visible-because-focused
|
|
520
|
+
// only, render as a drawer (absolute) on top of the reader. We also
|
|
521
|
+
// fall back to drawer rendering when the viewport is too narrow for
|
|
522
|
+
// the inline two-pane layout even with `shown=true` (Q2 in DESIGN.md
|
|
523
|
+
// §7.1) — preserves the user's preference without squeezing the reader
|
|
524
|
+
// below READER_MIN_WIDTH.
|
|
525
|
+
const sidebarVisible = shown || sidebarActive
|
|
526
|
+
const sidebarAsDrawer = sidebarVisible && (!shown || !canFitInline(width))
|
|
527
|
+
const sidebarInline = sidebarVisible && !sidebarAsDrawer
|
|
528
|
+
// Drawer is offset 1 row from the top so the reader's title stays visible.
|
|
529
|
+
// That row comes off the drawer's own height, so the body has one fewer
|
|
530
|
+
// usable row than the inline sidebar. Tracked here so the virtualization
|
|
531
|
+
// slice matches the wrapper that actually paints it.
|
|
532
|
+
const drawerTopOffset = 1
|
|
330
533
|
const sidebarTitle = sidebarActive ? " ▸ files " : " files "
|
|
331
534
|
const readerLabel = selected?.relativePath ?? title
|
|
332
535
|
const readerTitle = readerActive ? ` ▸ ${readerLabel} ` : ` ${readerLabel} `
|
|
536
|
+
const content = loaded?.path === renderedPath ? loaded.content : ""
|
|
333
537
|
|
|
334
538
|
// Sidebar virtualization: render only the visible window. Without this,
|
|
335
539
|
// every keystroke re-renders all N file rows even though only the bg of
|
|
336
540
|
// two of them changed (old + new selected). On a 195-file vault that
|
|
337
541
|
// dominates the per-keystroke cost.
|
|
338
|
-
// Sidebar box adds top/bottom borders (2); footer eats FOOTER_HEIGHT
|
|
339
|
-
|
|
542
|
+
// Sidebar box adds top/bottom borders (2); footer eats FOOTER_HEIGHT;
|
|
543
|
+
// the filter row eats one more cell when files are present *or* while
|
|
544
|
+
// discovery is in flight (allocates the row up front so it doesn't pop
|
|
545
|
+
// in when the first file arrives).
|
|
546
|
+
const discoveryActive = discoveryStatus !== null && discoveryStatus.length > 0
|
|
547
|
+
const filterRowVisible = files.length > 0 || discoveryActive
|
|
548
|
+
const sidebarBodyHeight = Math.max(
|
|
549
|
+
1,
|
|
550
|
+
height -
|
|
551
|
+
2 -
|
|
552
|
+
FOOTER_HEIGHT -
|
|
553
|
+
(filterRowVisible ? 1 : 0) -
|
|
554
|
+
(sidebarAsDrawer ? drawerTopOffset : 0),
|
|
555
|
+
)
|
|
340
556
|
const maxScroll = Math.max(0, displayedFiles.length - sidebarBodyHeight)
|
|
341
557
|
const desiredScroll = (() => {
|
|
342
558
|
let s = sidebarScroll
|
|
@@ -352,16 +568,92 @@ export const Browser = ({
|
|
|
352
568
|
const sidebarTextWidth = Math.max(4, sidebarWidth - 2)
|
|
353
569
|
// Right-anchored truncation: keep the filename visible, lose the prefix
|
|
354
570
|
// with a leading ellipsis when the path is too long.
|
|
355
|
-
const truncatePath = (
|
|
356
|
-
s
|
|
571
|
+
const truncatePath = useCallback(
|
|
572
|
+
(s: string): string =>
|
|
573
|
+
s.length <= sidebarTextWidth ? s : "…" + s.slice(s.length - sidebarTextWidth + 1),
|
|
574
|
+
[sidebarTextWidth],
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
// Filter row content + color. Three reachable states:
|
|
578
|
+
// editing — filterOpen=true → /<query>▏ in textStrong
|
|
579
|
+
// applied — !filterOpen && query !== "" → /<query> in text
|
|
580
|
+
// idle — !filterOpen && query === "" → "/ filter…" in textMuted
|
|
581
|
+
const filterRowFg = filterOpen
|
|
582
|
+
? colors.textStrong
|
|
583
|
+
: filterQuery.length > 0
|
|
584
|
+
? colors.text
|
|
585
|
+
: colors.textMuted
|
|
586
|
+
const filterRowRaw = filterOpen
|
|
587
|
+
? `/${filterQuery}▏`
|
|
588
|
+
: filterQuery.length > 0
|
|
589
|
+
? `/${filterQuery}`
|
|
590
|
+
: "/ filter…"
|
|
591
|
+
// Editing keeps the cursor visible — anchor the right edge with a leading
|
|
592
|
+
// ellipsis when the query overflows. Applied/idle anchor the left edge
|
|
593
|
+
// (lose the tail) so the leading `/` always reads as a filter marker.
|
|
594
|
+
const filterRowContent =
|
|
595
|
+
filterRowRaw.length <= sidebarTextWidth
|
|
596
|
+
? filterRowRaw
|
|
597
|
+
: filterOpen
|
|
598
|
+
? "…" + filterRowRaw.slice(filterRowRaw.length - sidebarTextWidth + 1)
|
|
599
|
+
: filterRowRaw.slice(0, sidebarTextWidth - 1) + "…"
|
|
357
600
|
|
|
358
601
|
// While help is open, the `?` key closes the overlay — relabel its hint
|
|
359
602
|
// so the footer accurately describes what pressing the key will do.
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
603
|
+
// Memoized: `helpVisible` changes rarely; `browserBindings` and
|
|
604
|
+
// `HELP_ALLOWED_IDS` are module-level constants.
|
|
605
|
+
const footerBindings = useMemo(
|
|
606
|
+
() =>
|
|
607
|
+
helpVisible
|
|
608
|
+
? browserBindings
|
|
609
|
+
.filter((b) => HELP_ALLOWED_IDS.has(b.id))
|
|
610
|
+
.map((b) => (b.id === "help.toggle" ? { ...b, hint: "close" } : b))
|
|
611
|
+
: browserBindings,
|
|
612
|
+
[helpVisible],
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
// One sidebar element is reused for inline and drawer rendering; only
|
|
616
|
+
// the wrapper differs (flex sibling vs absolute-positioned). The body is
|
|
617
|
+
// identical so file rows / filter row don't drift between modes.
|
|
618
|
+
const sidebarBody = (
|
|
619
|
+
<>
|
|
620
|
+
{filterRowVisible && (
|
|
621
|
+
<text content={filterRowContent} wrapMode="none" style={{ fg: filterRowFg }} />
|
|
622
|
+
)}
|
|
623
|
+
{displayedFiles.length === 0 ? (
|
|
624
|
+
<text
|
|
625
|
+
content={
|
|
626
|
+
files.length === 0
|
|
627
|
+
? discoveryActive
|
|
628
|
+
? "(scanning…)"
|
|
629
|
+
: "(no markdown files)"
|
|
630
|
+
: "(no matches)"
|
|
631
|
+
}
|
|
632
|
+
style={{ fg: colors.textMuted }}
|
|
633
|
+
/>
|
|
634
|
+
) : (
|
|
635
|
+
visibleFiles.map((file, idx) => {
|
|
636
|
+
const realIdx = desiredScroll + idx
|
|
637
|
+
const isSelected = realIdx === selectedIndex
|
|
638
|
+
const display = truncatePath(file.relativePath)
|
|
639
|
+
if (!isSelected) {
|
|
640
|
+
return (
|
|
641
|
+
<text key={file.path} content={display} wrapMode="none" style={{ fg: colors.text }} />
|
|
642
|
+
)
|
|
643
|
+
}
|
|
644
|
+
const bg = sidebarActive ? colors.selectedBg : colors.selectedBgInactive
|
|
645
|
+
return (
|
|
646
|
+
<text
|
|
647
|
+
key={file.path}
|
|
648
|
+
content={display}
|
|
649
|
+
wrapMode="none"
|
|
650
|
+
style={{ fg: colors.textStrong, bg }}
|
|
651
|
+
/>
|
|
652
|
+
)
|
|
653
|
+
})
|
|
654
|
+
)}
|
|
655
|
+
</>
|
|
656
|
+
)
|
|
365
657
|
|
|
366
658
|
return (
|
|
367
659
|
<box style={{ width, height, flexDirection: "column", backgroundColor: colors.background }}>
|
|
@@ -373,7 +665,7 @@ export const Browser = ({
|
|
|
373
665
|
backgroundColor: colors.background,
|
|
374
666
|
}}
|
|
375
667
|
>
|
|
376
|
-
{
|
|
668
|
+
{sidebarInline && (
|
|
377
669
|
<box
|
|
378
670
|
title={sidebarTitle}
|
|
379
671
|
titleAlignment="left"
|
|
@@ -386,37 +678,7 @@ export const Browser = ({
|
|
|
386
678
|
backgroundColor: colors.surface,
|
|
387
679
|
}}
|
|
388
680
|
>
|
|
389
|
-
{
|
|
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
|
-
)}
|
|
681
|
+
{sidebarBody}
|
|
420
682
|
</box>
|
|
421
683
|
)}
|
|
422
684
|
<box
|
|
@@ -442,7 +704,15 @@ export const Browser = ({
|
|
|
442
704
|
flexShrink: 1,
|
|
443
705
|
backgroundColor: colors.background,
|
|
444
706
|
}}
|
|
445
|
-
focused
|
|
707
|
+
// opentui's scrollbox consumes arrow keys at the focused-element
|
|
708
|
+
// level *before* useKeyboard fires, so a modal that handles
|
|
709
|
+
// arrow keys itself (palette nav, help dismissal) would still
|
|
710
|
+
// see the reader scroll alongside its own action. Unfocus the
|
|
711
|
+
// scrollbox while any blocking modal is up — useKeyboard's
|
|
712
|
+
// modal branches own the keys in that state. Filter is not
|
|
713
|
+
// listed because it force-focuses the sidebar (readerActive
|
|
714
|
+
// is already false).
|
|
715
|
+
focused={readerActive && !paletteOpen && !helpVisible}
|
|
446
716
|
>
|
|
447
717
|
<markdown
|
|
448
718
|
key={renderedPath ?? "empty"}
|
|
@@ -457,16 +727,50 @@ export const Browser = ({
|
|
|
457
727
|
)}
|
|
458
728
|
</box>
|
|
459
729
|
</box>
|
|
730
|
+
{sidebarAsDrawer && (
|
|
731
|
+
// Offset by 1 row so the reader pane's top border (which carries
|
|
732
|
+
// the current file name) stays visible above the drawer. Without
|
|
733
|
+
// this, the user loses the only on-screen indicator of which
|
|
734
|
+
// file they're reading whenever the drawer is up.
|
|
735
|
+
<box
|
|
736
|
+
position="absolute"
|
|
737
|
+
left={0}
|
|
738
|
+
top={drawerTopOffset}
|
|
739
|
+
width={sidebarWidth}
|
|
740
|
+
height={Math.max(1, height - FOOTER_HEIGHT - drawerTopOffset)}
|
|
741
|
+
zIndex={5}
|
|
742
|
+
title={sidebarTitle}
|
|
743
|
+
titleAlignment="left"
|
|
744
|
+
style={{
|
|
745
|
+
border: true,
|
|
746
|
+
borderColor: sidebarActive ? colors.borderActive : colors.border,
|
|
747
|
+
flexDirection: "column",
|
|
748
|
+
backgroundColor: colors.surface,
|
|
749
|
+
}}
|
|
750
|
+
>
|
|
751
|
+
{sidebarBody}
|
|
752
|
+
</box>
|
|
753
|
+
)}
|
|
460
754
|
<Footer
|
|
461
755
|
bindings={footerBindings}
|
|
462
756
|
ctx={ctx}
|
|
463
757
|
width={width}
|
|
464
758
|
notice={footerNotice}
|
|
465
|
-
|
|
759
|
+
discoveryStatus={discoveryStatus}
|
|
760
|
+
filterQuery={!filterOpen && filterQuery.length > 0 ? filterQuery : null}
|
|
466
761
|
/>
|
|
467
762
|
{helpVisible && (
|
|
468
763
|
<HelpOverlay bindings={browserBindings} viewportWidth={width} viewportHeight={height} />
|
|
469
764
|
)}
|
|
765
|
+
{paletteOpen && (
|
|
766
|
+
<CommandPalette
|
|
767
|
+
commands={filterCommands(buildCommands(ctx), paletteQuery)}
|
|
768
|
+
query={paletteQuery}
|
|
769
|
+
selectedIndex={paletteIndex}
|
|
770
|
+
viewportWidth={width}
|
|
771
|
+
viewportHeight={height}
|
|
772
|
+
/>
|
|
773
|
+
)}
|
|
470
774
|
</box>
|
|
471
775
|
)
|
|
472
776
|
}
|