@carlesandres/house 0.3.1 → 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 +22 -1
- package/README.md +28 -0
- package/package.json +3 -2
- package/src/Browser.tsx +348 -53
- package/src/CommandPalette.tsx +126 -0
- package/src/Footer.tsx +61 -34
- 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/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,25 @@ file is the curated, narrative version.
|
|
|
12
12
|
|
|
13
13
|
## [Unreleased]
|
|
14
14
|
|
|
15
|
+
## [0.4.0] — 2026-05-21
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- Command palette v1: `ctrl+p` opens a modal palette with visible browser
|
|
20
|
+
commands derived from the keymap, including quit, sidebar toggle, help,
|
|
21
|
+
filter, open-in-browser, and theme controls.
|
|
22
|
+
- Palette search uses a small tiered scorer so empty queries stay in keymap
|
|
23
|
+
order while typed queries rank stronger title and word-boundary matches.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- Reader scroll focus is suspended while overlays are open, preventing palette
|
|
28
|
+
arrow navigation from scrolling the markdown pane behind the modal.
|
|
29
|
+
|
|
30
|
+
### Tests
|
|
31
|
+
|
|
32
|
+
- Added command-palette interaction coverage plus scroll regression checks.
|
|
33
|
+
|
|
15
34
|
## [0.3.1] — 2026-05-17
|
|
16
35
|
|
|
17
36
|
Beta release gates (DESIGN §10.2) closed.
|
|
@@ -204,7 +223,9 @@ auto-detect, single-binary distribution (issue
|
|
|
204
223
|
[#2](https://github.com/carlesandres/openmdr/issues/2)),
|
|
205
224
|
Homebrew tap. All tracked.
|
|
206
225
|
|
|
207
|
-
[Unreleased]: https://github.com/carlesandres/house/compare/v0.
|
|
226
|
+
[Unreleased]: https://github.com/carlesandres/house/compare/v0.4.0...HEAD
|
|
227
|
+
[0.4.0]: https://github.com/carlesandres/house/compare/v0.3.1...v0.4.0
|
|
228
|
+
[0.3.1]: https://github.com/carlesandres/house/compare/v0.3.0...v0.3.1
|
|
208
229
|
[0.3.0]: https://github.com/carlesandres/house/compare/v0.2.1...v0.3.0
|
|
209
230
|
[0.2.1]: https://github.com/carlesandres/house/compare/v0.2.0...v0.2.1
|
|
210
231
|
[0.2.0]: https://github.com/carlesandres/house/compare/v0.1.0...v0.2.0
|
package/README.md
CHANGED
|
@@ -37,6 +37,34 @@ house [options] <path>
|
|
|
37
37
|
|------|---------|-------------|
|
|
38
38
|
| `--theme <name>` | `opencode` | Starting theme (see list below) |
|
|
39
39
|
| `--tone dark\|light` | `dark` | Starting tone |
|
|
40
|
+
| `--config-path` | — | Print the resolved config-file path and exit |
|
|
41
|
+
|
|
42
|
+
## Configuration
|
|
43
|
+
|
|
44
|
+
house reads optional defaults from a TOML file:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
$XDG_CONFIG_HOME/house/config.toml (defaults to ~/.config/house/config.toml)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Run `house --config-path` to print the exact location.
|
|
51
|
+
|
|
52
|
+
```toml
|
|
53
|
+
# ~/.config/house/config.toml
|
|
54
|
+
theme = "tokyonight"
|
|
55
|
+
tone = "dark"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Precedence, highest to lowest:
|
|
59
|
+
|
|
60
|
+
1. CLI flags (`--theme`, `--tone`)
|
|
61
|
+
2. Env vars (`HOUSE_THEME`, `HOUSE_TONE`)
|
|
62
|
+
3. Config file
|
|
63
|
+
4. Built-in defaults (`opencode` / `dark`)
|
|
64
|
+
|
|
65
|
+
The file is optional — a missing file is fine. Invalid keys, unknown themes,
|
|
66
|
+
or malformed TOML fail loudly with a one-line error. Per-project config
|
|
67
|
+
(`.house/config.toml`) and additional keys are deferred.
|
|
40
68
|
|
|
41
69
|
## Keys
|
|
42
70
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@carlesandres/house",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "TUI-first markdown reader on opentui",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -45,7 +45,8 @@
|
|
|
45
45
|
"format:check": "oxfmt --check src/",
|
|
46
46
|
"test": "bun test",
|
|
47
47
|
"build:themes": "bun run dev/build-themes.ts",
|
|
48
|
-
"repro:pty-codeblocks": "bun run dev/repro-pty-codeblocks.ts"
|
|
48
|
+
"repro:pty-codeblocks": "bun run dev/repro-pty-codeblocks.ts",
|
|
49
|
+
"prepare": "git config core.hooksPath .githooks 2>/dev/null || true"
|
|
49
50
|
},
|
|
50
51
|
"dependencies": {
|
|
51
52
|
"@effect/atom-react": "4.0.0-beta.59",
|
package/src/Browser.tsx
CHANGED
|
@@ -15,6 +15,9 @@ import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
|
|
|
15
15
|
import { useAtomValue, useAtomSet } from "@effect/atom-react"
|
|
16
16
|
import { Effect } from "effect"
|
|
17
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) => {
|
|
@@ -71,18 +93,47 @@ export const Browser = ({
|
|
|
71
93
|
)
|
|
72
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
|
|
|
@@ -181,17 +232,51 @@ export const Browser = ({
|
|
|
181
232
|
const ctx: BrowserCtx = {
|
|
182
233
|
files: displayedFiles,
|
|
183
234
|
focus,
|
|
184
|
-
|
|
235
|
+
sidebarShown: shown,
|
|
185
236
|
helpVisible,
|
|
186
237
|
filterOpen,
|
|
238
|
+
paletteOpen,
|
|
187
239
|
setFocus,
|
|
188
240
|
setSelectedIndex,
|
|
189
|
-
|
|
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
|
+
},
|
|
190
257
|
setHelpVisible,
|
|
191
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")
|
|
192
265
|
filterOpenRef.current = true
|
|
193
266
|
setFilterOpen(true)
|
|
194
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
|
+
},
|
|
195
280
|
cycleTheme,
|
|
196
281
|
toggleTone,
|
|
197
282
|
serveCurrent: () => {
|
|
@@ -249,17 +334,46 @@ export const Browser = ({
|
|
|
249
334
|
//
|
|
250
335
|
// Centralized so the dual filterOpenRef / filterOpen invariant
|
|
251
336
|
// only has to be maintained in one place (plus `openFilter`).
|
|
252
|
-
const closeFilter = (
|
|
337
|
+
const closeFilter = (commit: boolean) => {
|
|
253
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
|
|
254
343
|
filterOpenRef.current = false
|
|
255
|
-
filterQueryRef.current = ""
|
|
256
344
|
setFilterOpen(false)
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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")
|
|
261
376
|
}
|
|
262
|
-
if (focusReader && picked) setFocus("reader")
|
|
263
377
|
}
|
|
264
378
|
if (key.name === "escape") {
|
|
265
379
|
closeFilter(false)
|
|
@@ -269,7 +383,15 @@ export const Browser = ({
|
|
|
269
383
|
closeFilter(true)
|
|
270
384
|
return
|
|
271
385
|
}
|
|
272
|
-
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
|
+
}
|
|
273
395
|
filterQueryRef.current = filterQueryRef.current.slice(0, -1)
|
|
274
396
|
setFilterQuery(filterQueryRef.current)
|
|
275
397
|
setSelectedIndex(() => 0)
|
|
@@ -296,6 +418,74 @@ export const Browser = ({
|
|
|
296
418
|
}
|
|
297
419
|
return
|
|
298
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
|
+
}
|
|
299
489
|
// While help is open, swallow most keys: only ? (toggle), esc
|
|
300
490
|
// (close), and the theme bindings pass through. Theme keys stay live
|
|
301
491
|
// so users can preview palette changes against the overlay itself —
|
|
@@ -320,12 +510,26 @@ export const Browser = ({
|
|
|
320
510
|
dispatch(browserBindings, ctx, key)
|
|
321
511
|
})
|
|
322
512
|
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
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))
|
|
327
517
|
const sidebarActive = focus === "sidebar"
|
|
328
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
|
|
329
533
|
const sidebarTitle = sidebarActive ? " ▸ files " : " files "
|
|
330
534
|
const readerLabel = selected?.relativePath ?? title
|
|
331
535
|
const readerTitle = readerActive ? ` ▸ ${readerLabel} ` : ` ${readerLabel} `
|
|
@@ -335,8 +539,20 @@ export const Browser = ({
|
|
|
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
|
|
@@ -358,6 +574,30 @@ export const Browser = ({
|
|
|
358
574
|
[sidebarTextWidth],
|
|
359
575
|
)
|
|
360
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) + "…"
|
|
600
|
+
|
|
361
601
|
// While help is open, the `?` key closes the overlay — relabel its hint
|
|
362
602
|
// so the footer accurately describes what pressing the key will do.
|
|
363
603
|
// Memoized: `helpVisible` changes rarely; `browserBindings` and
|
|
@@ -372,6 +612,49 @@ export const Browser = ({
|
|
|
372
612
|
[helpVisible],
|
|
373
613
|
)
|
|
374
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
|
+
)
|
|
657
|
+
|
|
375
658
|
return (
|
|
376
659
|
<box style={{ width, height, flexDirection: "column", backgroundColor: colors.background }}>
|
|
377
660
|
<box
|
|
@@ -382,7 +665,7 @@ export const Browser = ({
|
|
|
382
665
|
backgroundColor: colors.background,
|
|
383
666
|
}}
|
|
384
667
|
>
|
|
385
|
-
{
|
|
668
|
+
{sidebarInline && (
|
|
386
669
|
<box
|
|
387
670
|
title={sidebarTitle}
|
|
388
671
|
titleAlignment="left"
|
|
@@ -395,37 +678,7 @@ export const Browser = ({
|
|
|
395
678
|
backgroundColor: colors.surface,
|
|
396
679
|
}}
|
|
397
680
|
>
|
|
398
|
-
{
|
|
399
|
-
<text
|
|
400
|
-
content={files.length === 0 ? "(no markdown files)" : "(no matches)"}
|
|
401
|
-
style={{ fg: colors.textMuted }}
|
|
402
|
-
/>
|
|
403
|
-
) : (
|
|
404
|
-
visibleFiles.map((file, idx) => {
|
|
405
|
-
const realIdx = desiredScroll + idx
|
|
406
|
-
const isSelected = realIdx === selectedIndex
|
|
407
|
-
const display = truncatePath(file.relativePath)
|
|
408
|
-
if (!isSelected) {
|
|
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
|
-
)}
|
|
681
|
+
{sidebarBody}
|
|
429
682
|
</box>
|
|
430
683
|
)}
|
|
431
684
|
<box
|
|
@@ -451,7 +704,15 @@ export const Browser = ({
|
|
|
451
704
|
flexShrink: 1,
|
|
452
705
|
backgroundColor: colors.background,
|
|
453
706
|
}}
|
|
454
|
-
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}
|
|
455
716
|
>
|
|
456
717
|
<markdown
|
|
457
718
|
key={renderedPath ?? "empty"}
|
|
@@ -466,16 +727,50 @@ export const Browser = ({
|
|
|
466
727
|
)}
|
|
467
728
|
</box>
|
|
468
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
|
+
)}
|
|
469
754
|
<Footer
|
|
470
755
|
bindings={footerBindings}
|
|
471
756
|
ctx={ctx}
|
|
472
757
|
width={width}
|
|
473
758
|
notice={footerNotice}
|
|
474
|
-
|
|
759
|
+
discoveryStatus={discoveryStatus}
|
|
760
|
+
filterQuery={!filterOpen && filterQuery.length > 0 ? filterQuery : null}
|
|
475
761
|
/>
|
|
476
762
|
{helpVisible && (
|
|
477
763
|
<HelpOverlay bindings={browserBindings} viewportWidth={width} viewportHeight={height} />
|
|
478
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
|
+
)}
|
|
479
774
|
</box>
|
|
480
775
|
)
|
|
481
776
|
}
|