@carlesandres/house 0.4.6 → 0.4.7
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 +31 -3
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/Browser.tsx +92 -30
- package/src/Footer.tsx +26 -0
- package/src/Spinner.tsx +39 -0
- package/src/cli/argv.ts +8 -2
- package/src/config/load.ts +23 -1
- package/src/index.tsx +41 -2
- package/src/keymap/browser.ts +3 -3
- package/src/serve/server.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,33 @@ The publish workflow (`.github/workflows/publish.yml`) runs on the `release: pub
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.4.7] — 2026-05-27
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `--root <path>` and TOML `defaultRoot` let launches default to a configured browsing root while preserving explicit path arguments.
|
|
14
|
+
- Discovery/indexing status now shows a spinner in the chrome instead of only static text.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- File actions now use uppercase `E` / `O` instead of lowercase `e` / `o`, adding a small amount of friction to edit and open-in-browser actions.
|
|
19
|
+
- Sidebar filtering now applies after a short debounce to reduce churn while typing.
|
|
20
|
+
- Filtered browsing now keeps the first match sticky/selected as results update.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- PTY typing coverage and footer hint tests now match the shifted file-action bindings.
|
|
25
|
+
- CI typechecking now satisfies exact optional prop typing in the release branch.
|
|
26
|
+
|
|
27
|
+
### Docs
|
|
28
|
+
|
|
29
|
+
- Removed the obsolete `CONTEXT.md` glossary and inlined the beta-term explanation into `DESIGN.md` with cleaned-up cross-references.
|
|
30
|
+
- ROADMAP shipped items were pruned after the covered work landed.
|
|
31
|
+
|
|
32
|
+
### Tests
|
|
33
|
+
|
|
34
|
+
- Browser test noise from React act warnings was reduced around the new filter behavior coverage.
|
|
35
|
+
|
|
9
36
|
## [0.4.6] — 2026-05-25
|
|
10
37
|
|
|
11
38
|
### Added
|
|
@@ -73,7 +100,7 @@ The publish workflow (`.github/workflows/publish.yml`) runs on the `release: pub
|
|
|
73
100
|
|
|
74
101
|
### Added
|
|
75
102
|
|
|
76
|
-
- Editor hand-off: press `
|
|
103
|
+
- Editor hand-off: press `E` to open the selected file in `$EDITOR`/`$VISUAL` (`#19`).
|
|
77
104
|
- Sidebar rows now render basename-first with segment-aware parent-path elision, improving scanability in deep trees.
|
|
78
105
|
- `ctrl+\\` clears an active sidebar filter query without leaving filter mode.
|
|
79
106
|
|
|
@@ -207,7 +234,7 @@ The v1 MVP, published as `@carlesandres/openmdr` on npm.
|
|
|
207
234
|
### Added — keymap
|
|
208
235
|
|
|
209
236
|
- `KeyBinding[]` with `id` / `description` / `keys` / `group` / optional `when` / `run`. Single source for both `useKeyboard` dispatch and the help overlay.
|
|
210
|
-
- Bindings: `j`/`k` + arrows, shift-jump, page/half-page, `g`/`G`, `return`/`l`/`→`, `escape`/`h`/`←`, `[`/`]`, `tab`, `\`, `?`, `q`/`ctrl+c`. Reserved (not bound): `/`, `
|
|
237
|
+
- Bindings: `j`/`k` + arrows, shift-jump, page/half-page, `g`/`G`, `return`/`l`/`→`, `escape`/`h`/`←`, `[`/`]`, `tab`, `\`, `?`, `q`/`ctrl+c`. Reserved (not bound): `/`, `r`.
|
|
211
238
|
|
|
212
239
|
### Added — release infra
|
|
213
240
|
|
|
@@ -230,7 +257,8 @@ The v1 MVP, published as `@carlesandres/openmdr` on npm.
|
|
|
230
257
|
|
|
231
258
|
Search, stdin, URL fetching, cross-file link following, `$EDITOR` hand-off, syntax highlighting, persistent config, OS-appearance auto-detect, single-binary distribution (issue [#2](https://github.com/carlesandres/openmdr/issues/2)), Homebrew tap. All tracked.
|
|
232
259
|
|
|
233
|
-
[Unreleased]: https://github.com/carlesandres/house/compare/v0.4.
|
|
260
|
+
[Unreleased]: https://github.com/carlesandres/house/compare/v0.4.7...HEAD
|
|
261
|
+
[0.4.7]: https://github.com/carlesandres/house/compare/v0.4.6...v0.4.7
|
|
234
262
|
[0.4.6]: https://github.com/carlesandres/house/compare/v0.4.5...v0.4.6
|
|
235
263
|
[0.4.5]: https://github.com/carlesandres/house/compare/v0.4.4...v0.4.5
|
|
236
264
|
[0.4.4]: https://github.com/carlesandres/house/compare/v0.4.3...v0.4.4
|
package/README.md
CHANGED
|
@@ -111,8 +111,8 @@ The file is optional — a missing file is fine. Invalid keys, unknown themes, o
|
|
|
111
111
|
| `s` | Toggle sidebar visibility |
|
|
112
112
|
| `?` | Show / dismiss help overlay |
|
|
113
113
|
| `ctrl+p` | Command palette |
|
|
114
|
-
| `
|
|
115
|
-
| `
|
|
114
|
+
| `O` | Open current file in browser as HTML |
|
|
115
|
+
| `E` | Open current file in `$EDITOR` (`$VISUAL` takes precedence) |
|
|
116
116
|
| `t` | Next theme |
|
|
117
117
|
| `T` | Previous theme |
|
|
118
118
|
| `L` | Toggle dark / light tone |
|
package/package.json
CHANGED
package/src/Browser.tsx
CHANGED
|
@@ -22,7 +22,7 @@ import { CommandPalette } from "./CommandPalette.tsx"
|
|
|
22
22
|
import { filterFiles } from "./discovery/filter.ts"
|
|
23
23
|
import { type FileEntry } from "./discovery/walk.ts"
|
|
24
24
|
import { BRAND, BRAND_NAME } from "./brand.ts"
|
|
25
|
-
import { Footer, FOOTER_HEIGHT } from "./Footer.tsx"
|
|
25
|
+
import { Footer, FOOTER_HEIGHT, type FooterProps } from "./Footer.tsx"
|
|
26
26
|
import { Header, HEADER_HEIGHT } from "./Header.tsx"
|
|
27
27
|
import { HelpOverlay } from "./HelpOverlay.tsx"
|
|
28
28
|
import { openInEditor, resolveEditor } from "./io/editor.ts"
|
|
@@ -55,6 +55,17 @@ export interface BrowserProps {
|
|
|
55
55
|
/** Persistent footer indicator (e.g. "indexing… 42"). Pass null/undefined
|
|
56
56
|
* when discovery has finished; the indicator clears. */
|
|
57
57
|
readonly discoveryStatus?: string | null
|
|
58
|
+
/** Test seam: override the footer discovery spinner speed. */
|
|
59
|
+
readonly discoverySpinnerIntervalMs?: number
|
|
60
|
+
readonly discoverySpinnerInitialFrameIndex?: number
|
|
61
|
+
/** Test seam: deterministic footer spinner driver. */
|
|
62
|
+
readonly discoverySpinnerRegisterTick?: ((tick: () => void) => void) | null
|
|
63
|
+
/** Test seam: override filter debounce timing. */
|
|
64
|
+
readonly filterDebounceMs?: number
|
|
65
|
+
/** Test seam: override rendered-path debounce timing. */
|
|
66
|
+
readonly renderedPathDebounceMs?: number
|
|
67
|
+
/** Test seam: disable reader-empty-state tip rotation effect. */
|
|
68
|
+
readonly disableReaderEmptyStateRotation?: boolean
|
|
58
69
|
/** Initial sidebar visibility (`--sidebar` flag). `auto` consults the
|
|
59
70
|
* launch viewport bucket once; subsequent visibility goes through `s`. */
|
|
60
71
|
readonly sidebarMode?: SidebarMode
|
|
@@ -69,6 +80,8 @@ export interface BrowserProps {
|
|
|
69
80
|
/** TTL (ms) for the update-notice toast. Exposed so tests can use a small
|
|
70
81
|
* value instead of sleeping for the production 10s window. */
|
|
71
82
|
readonly updateNoticeTtlMs?: number
|
|
83
|
+
/** Test seam: disable footer-notice auto-clear timers. */
|
|
84
|
+
readonly disableFooterNoticeAutoClear?: boolean
|
|
72
85
|
/** Flip the parent's discovery vocabulary (#145). Browser doesn't need
|
|
73
86
|
* to know which categories are currently on — the toggle is opaque
|
|
74
87
|
* from this side; we just snapshot the selected path so it can be
|
|
@@ -105,16 +118,26 @@ export const setReaderEmptyStateTipRotationForTests = (next: number) => {
|
|
|
105
118
|
nextReaderEmptyStateTipRotation = next
|
|
106
119
|
}
|
|
107
120
|
|
|
121
|
+
const FILTER_DEBOUNCE_MS = 50
|
|
122
|
+
const RENDERED_PATH_DEBOUNCE_MS = 80
|
|
123
|
+
|
|
108
124
|
export const Browser = ({
|
|
109
125
|
files,
|
|
110
126
|
initialIndex = 0,
|
|
111
127
|
maxWidth = null,
|
|
112
128
|
discoveryStatus = null,
|
|
129
|
+
discoverySpinnerIntervalMs,
|
|
130
|
+
discoverySpinnerInitialFrameIndex,
|
|
131
|
+
discoverySpinnerRegisterTick = null,
|
|
132
|
+
filterDebounceMs = FILTER_DEBOUNCE_MS,
|
|
133
|
+
renderedPathDebounceMs = RENDERED_PATH_DEBOUNCE_MS,
|
|
134
|
+
disableReaderEmptyStateRotation = false,
|
|
113
135
|
sidebarMode = "auto",
|
|
114
136
|
onQuit,
|
|
115
137
|
readFile = defaultReadFile,
|
|
116
138
|
updateNotice = null,
|
|
117
139
|
updateNoticeTtlMs = 10000,
|
|
140
|
+
disableFooterNoticeAutoClear = false,
|
|
118
141
|
onToggleAll,
|
|
119
142
|
startupFocus = null,
|
|
120
143
|
}: BrowserProps) => {
|
|
@@ -166,7 +189,8 @@ export const Browser = ({
|
|
|
166
189
|
const [sidebarScroll, setSidebarScroll] = useState<number>(0)
|
|
167
190
|
const [helpVisible, setHelpVisible] = useState<boolean>(false)
|
|
168
191
|
const [filterOpen, setFilterOpen] = useState<boolean>(startInFilter)
|
|
169
|
-
const [
|
|
192
|
+
const [filterInput, setFilterInput] = useState<string>("")
|
|
193
|
+
const [filterApplied, setFilterApplied] = useState<string>("")
|
|
170
194
|
const [paletteOpen, setPaletteOpen] = useState<boolean>(false)
|
|
171
195
|
const [paletteQuery, setPaletteQuery] = useState<string>("")
|
|
172
196
|
const [paletteIndex, setPaletteIndex] = useState<number>(0)
|
|
@@ -186,7 +210,9 @@ export const Browser = ({
|
|
|
186
210
|
// first key opens the filter; subsequent keys in the same tick would
|
|
187
211
|
// otherwise still observe filterOpen=false through closure).
|
|
188
212
|
const filterOpenRef = useRef(startInFilter)
|
|
189
|
-
const
|
|
213
|
+
const filterInputRef = useRef("")
|
|
214
|
+
const filterAppliedRef = useRef("")
|
|
215
|
+
const autoSelectForAppliedFilterRef = useRef(true)
|
|
190
216
|
const focusRef = useRef<"sidebar" | "reader">(focus)
|
|
191
217
|
const restoreFilterOnSidebarFocusRef = useRef(startInFilter)
|
|
192
218
|
const [footerNotice, setFooterNoticeState] = useState<{
|
|
@@ -220,9 +246,10 @@ export const Browser = ({
|
|
|
220
246
|
// the other's display window.
|
|
221
247
|
useEffect(() => {
|
|
222
248
|
if (footerNotice === null) return
|
|
249
|
+
if (disableFooterNoticeAutoClear) return
|
|
223
250
|
const timer = setTimeout(() => setFooterNoticeState(null), footerNotice.ttlMs)
|
|
224
251
|
return () => clearTimeout(timer)
|
|
225
|
-
}, [footerNotice])
|
|
252
|
+
}, [disableFooterNoticeAutoClear, footerNotice])
|
|
226
253
|
|
|
227
254
|
// Push the update-available nudge once, when it arrives from the parent
|
|
228
255
|
// (the registry probe resolves asynchronously after boot). 10s gives the
|
|
@@ -257,8 +284,29 @@ export const Browser = ({
|
|
|
257
284
|
pushFooterNotice(`tone: ${nextTone}`)
|
|
258
285
|
}
|
|
259
286
|
|
|
260
|
-
|
|
261
|
-
|
|
287
|
+
useEffect(() => {
|
|
288
|
+
if (filterInput === filterApplied) return
|
|
289
|
+
const timer = setTimeout(() => {
|
|
290
|
+
filterAppliedRef.current = filterInput
|
|
291
|
+
setFilterApplied(filterInput)
|
|
292
|
+
}, filterDebounceMs)
|
|
293
|
+
return () => clearTimeout(timer)
|
|
294
|
+
}, [filterApplied, filterDebounceMs, filterInput])
|
|
295
|
+
|
|
296
|
+
useEffect(() => {
|
|
297
|
+
autoSelectForAppliedFilterRef.current = true
|
|
298
|
+
}, [filterApplied])
|
|
299
|
+
|
|
300
|
+
const displayedFiles = useMemo(() => filterFiles(files, filterApplied), [files, filterApplied])
|
|
301
|
+
const filterHasNoMatches = filterInput.length > 0 && displayedFiles.length === 0
|
|
302
|
+
|
|
303
|
+
useEffect(() => {
|
|
304
|
+
if (filterApplied.length === 0) return
|
|
305
|
+
if (!autoSelectForAppliedFilterRef.current) return
|
|
306
|
+
if (displayedFiles.length === 0) return
|
|
307
|
+
setSelectedIndex(0)
|
|
308
|
+
autoSelectForAppliedFilterRef.current = false
|
|
309
|
+
}, [displayedFiles, filterApplied])
|
|
262
310
|
// When the filtered list shrinks, keep selectedIndex valid. The reset to 0
|
|
263
311
|
// on every query change happens in the keystroke handler, not here, so a
|
|
264
312
|
// no-op rerender doesn't snap the cursor back to the top.
|
|
@@ -296,9 +344,9 @@ export const Browser = ({
|
|
|
296
344
|
useEffect(() => {
|
|
297
345
|
const target = selected?.path ?? null
|
|
298
346
|
if (target === renderedPath) return
|
|
299
|
-
const timer = setTimeout(() => setRenderedPath(target),
|
|
347
|
+
const timer = setTimeout(() => setRenderedPath(target), renderedPathDebounceMs)
|
|
300
348
|
return () => clearTimeout(timer)
|
|
301
|
-
}, [selected?.path, renderedPath])
|
|
349
|
+
}, [selected?.path, renderedPath, renderedPathDebounceMs])
|
|
302
350
|
|
|
303
351
|
useEffect(() => {
|
|
304
352
|
if (!renderedPath) {
|
|
@@ -340,7 +388,7 @@ export const Browser = ({
|
|
|
340
388
|
helpVisible,
|
|
341
389
|
filterOpen,
|
|
342
390
|
restoreFilterOnSidebarFocus: restoreFilterOnSidebarFocusRef.current,
|
|
343
|
-
filterQuery,
|
|
391
|
+
filterQuery: filterInput,
|
|
344
392
|
paletteOpen,
|
|
345
393
|
setFocus,
|
|
346
394
|
// Wrapped so any keymap-driven selection move (j/k/g/G/[/], reader
|
|
@@ -350,6 +398,7 @@ export const Browser = ({
|
|
|
350
398
|
// itself) deliberately use the raw `setSelectedIndex` setter.
|
|
351
399
|
setSelectedIndex: (updater) => {
|
|
352
400
|
pendingSelectionPathRef.current = null
|
|
401
|
+
autoSelectForAppliedFilterRef.current = false
|
|
353
402
|
setSelectedIndex(updater)
|
|
354
403
|
},
|
|
355
404
|
toggleShown: () => {
|
|
@@ -393,8 +442,10 @@ export const Browser = ({
|
|
|
393
442
|
// Reset both the ref and the state so the freshly-opened modal
|
|
394
443
|
// shows an empty input and selection lands on the first file in
|
|
395
444
|
// the (now unfiltered) list.
|
|
396
|
-
|
|
397
|
-
|
|
445
|
+
filterInputRef.current = ""
|
|
446
|
+
filterAppliedRef.current = ""
|
|
447
|
+
setFilterInput("")
|
|
448
|
+
setFilterApplied("")
|
|
398
449
|
setSelectedIndex(() => 0)
|
|
399
450
|
focusRef.current = "sidebar"
|
|
400
451
|
if (focus !== "sidebar") setFocus("sidebar")
|
|
@@ -467,7 +518,7 @@ export const Browser = ({
|
|
|
467
518
|
if (!file) return
|
|
468
519
|
const editor = resolveEditor(process.env)
|
|
469
520
|
if (!editor) {
|
|
470
|
-
pushFooterNotice("set $EDITOR or $VISUAL to use
|
|
521
|
+
pushFooterNotice("set $EDITOR or $VISUAL to use E")
|
|
471
522
|
return
|
|
472
523
|
}
|
|
473
524
|
if (!renderer) {
|
|
@@ -543,6 +594,8 @@ export const Browser = ({
|
|
|
543
594
|
// the Return semantic (open the match in the reader); false is
|
|
544
595
|
// Esc (stop typing, keep the applied filter, stay in sidebar).
|
|
545
596
|
const closeFilter = (commit: boolean) => {
|
|
597
|
+
filterAppliedRef.current = filterInputRef.current
|
|
598
|
+
setFilterApplied(filterInputRef.current)
|
|
546
599
|
const picked = displayedFiles[selectedIndex] ?? null
|
|
547
600
|
const effectiveCommit = commit && picked !== null
|
|
548
601
|
restoreFilterOnSidebarFocusRef.current = false
|
|
@@ -583,20 +636,22 @@ export const Browser = ({
|
|
|
583
636
|
// outside the modal: clear the query, reset selection. The
|
|
584
637
|
// keymap doesn't see keys in filter mode, so this branch is
|
|
585
638
|
// the in-modal half of that single chord.
|
|
586
|
-
|
|
587
|
-
|
|
639
|
+
filterInputRef.current = ""
|
|
640
|
+
filterAppliedRef.current = ""
|
|
641
|
+
setFilterInput("")
|
|
642
|
+
setFilterApplied("")
|
|
588
643
|
setSelectedIndex(() => 0)
|
|
589
644
|
return
|
|
590
645
|
}
|
|
591
646
|
if (key.name === "backspace" || key.name === "delete") {
|
|
592
647
|
// Backspace on empty input closes the modal — the leading `/`
|
|
593
648
|
// chevron is the last thing left to "delete."
|
|
594
|
-
if (
|
|
649
|
+
if (filterInputRef.current.length === 0) {
|
|
595
650
|
closeFilter(false)
|
|
596
651
|
return
|
|
597
652
|
}
|
|
598
|
-
|
|
599
|
-
|
|
653
|
+
filterInputRef.current = filterInputRef.current.slice(0, -1)
|
|
654
|
+
setFilterInput(filterInputRef.current)
|
|
600
655
|
setSelectedIndex(() => 0)
|
|
601
656
|
return
|
|
602
657
|
}
|
|
@@ -615,8 +670,8 @@ export const Browser = ({
|
|
|
615
670
|
char = key.shift ? key.name.toUpperCase() : key.name
|
|
616
671
|
}
|
|
617
672
|
if (char !== null) {
|
|
618
|
-
|
|
619
|
-
|
|
673
|
+
filterInputRef.current = filterInputRef.current + char
|
|
674
|
+
setFilterInput(filterInputRef.current)
|
|
620
675
|
setSelectedIndex(() => 0)
|
|
621
676
|
}
|
|
622
677
|
return
|
|
@@ -733,11 +788,12 @@ export const Browser = ({
|
|
|
733
788
|
const currentFile = selected?.relativePath ?? null
|
|
734
789
|
const content = loaded?.path === renderedPath ? loaded.content : ""
|
|
735
790
|
const readerEmptyStateTitle = filterHasNoMatches
|
|
736
|
-
? `No files match: ${
|
|
791
|
+
? `No files match: ${filterInput}`
|
|
737
792
|
: `${BRAND} ${BRAND_NAME}`
|
|
738
793
|
const readerEmptyStateVisible = error == null && renderedPath == null
|
|
739
794
|
|
|
740
795
|
useEffect(() => {
|
|
796
|
+
if (disableReaderEmptyStateRotation) return
|
|
741
797
|
if (readerEmptyStateVisible) {
|
|
742
798
|
if (!readerEmptyStateVisibleRef.current) {
|
|
743
799
|
readerEmptyStateVisibleRef.current = true
|
|
@@ -746,7 +802,7 @@ export const Browser = ({
|
|
|
746
802
|
return
|
|
747
803
|
}
|
|
748
804
|
readerEmptyStateVisibleRef.current = false
|
|
749
|
-
}, [readerEmptyStateVisible])
|
|
805
|
+
}, [disableReaderEmptyStateRotation, readerEmptyStateVisible])
|
|
750
806
|
|
|
751
807
|
// Sidebar virtualization: render only the visible window. Without this,
|
|
752
808
|
// every keystroke re-renders all N file rows even though only the bg of
|
|
@@ -797,6 +853,19 @@ export const Browser = ({
|
|
|
797
853
|
: browserBindings,
|
|
798
854
|
[helpVisible],
|
|
799
855
|
)
|
|
856
|
+
const footerProps = {
|
|
857
|
+
bindings: footerBindings,
|
|
858
|
+
ctx,
|
|
859
|
+
width,
|
|
860
|
+
notice: footerNotice?.text ?? null,
|
|
861
|
+
discoveryStatus,
|
|
862
|
+
filterQuery: !filterOpen && filterInput.length > 0 ? filterInput : null,
|
|
863
|
+
...(discoverySpinnerIntervalMs === undefined ? {} : { discoverySpinnerIntervalMs }),
|
|
864
|
+
...(discoverySpinnerInitialFrameIndex === undefined
|
|
865
|
+
? {}
|
|
866
|
+
: { discoverySpinnerInitialFrameIndex }),
|
|
867
|
+
...(discoverySpinnerRegisterTick === undefined ? {} : { discoverySpinnerRegisterTick }),
|
|
868
|
+
} satisfies FooterProps<BrowserCtx>
|
|
800
869
|
const readerEmptyStateTips = useMemo(() => buildReaderEmptyStateTips(browserBindings, ctx), [ctx])
|
|
801
870
|
const readerEmptyStateTip = useMemo(
|
|
802
871
|
() => pickTipByRotation(readerEmptyStateTips, readerEmptyStateTipRotation),
|
|
@@ -809,7 +878,7 @@ export const Browser = ({
|
|
|
809
878
|
<>
|
|
810
879
|
{filterRowVisible && (
|
|
811
880
|
<PromptRow
|
|
812
|
-
query={
|
|
881
|
+
query={filterInput}
|
|
813
882
|
editing={filterOpen}
|
|
814
883
|
placeholder="/ to filter…"
|
|
815
884
|
width={sidebarTextWidth}
|
|
@@ -1007,14 +1076,7 @@ export const Browser = ({
|
|
|
1007
1076
|
</box>
|
|
1008
1077
|
)}
|
|
1009
1078
|
</box>
|
|
1010
|
-
<Footer
|
|
1011
|
-
bindings={footerBindings}
|
|
1012
|
-
ctx={ctx}
|
|
1013
|
-
width={width}
|
|
1014
|
-
notice={footerNotice?.text ?? null}
|
|
1015
|
-
discoveryStatus={discoveryStatus}
|
|
1016
|
-
filterQuery={!filterOpen && filterQuery.length > 0 ? filterQuery : null}
|
|
1017
|
-
/>
|
|
1079
|
+
<Footer {...footerProps} />
|
|
1018
1080
|
{helpVisible && (
|
|
1019
1081
|
<HelpOverlay bindings={browserBindings} viewportWidth={width} viewportHeight={height} />
|
|
1020
1082
|
)}
|
package/src/Footer.tsx
CHANGED
|
@@ -21,8 +21,10 @@
|
|
|
21
21
|
* require a real cell-width counter (e.g. East Asian Width).
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
+
import type React from "react"
|
|
24
25
|
import type { KeyBinding } from "./keymap/keymap.ts"
|
|
25
26
|
import { displayKey } from "./keymap/displayKey.ts"
|
|
27
|
+
import { Spinner } from "./Spinner.tsx"
|
|
26
28
|
import { colors } from "./theme/colors.ts"
|
|
27
29
|
|
|
28
30
|
/** Rows the Footer occupies. Importers use it for layout math so a future
|
|
@@ -43,6 +45,12 @@ export interface FooterProps<C> {
|
|
|
43
45
|
* while the filter input is open (the sidebar already shows the query) or
|
|
44
46
|
* when no filter is applied. */
|
|
45
47
|
readonly filterQuery?: string | null
|
|
48
|
+
/** Test seam: override spinner tick speed so tests don't sleep on the full
|
|
49
|
+
* production interval. Ignored when discoveryStatus is null. */
|
|
50
|
+
readonly discoverySpinnerIntervalMs?: number
|
|
51
|
+
readonly discoverySpinnerInitialFrameIndex?: number
|
|
52
|
+
/** Test seam: deterministic footer spinner driver. */
|
|
53
|
+
readonly discoverySpinnerRegisterTick?: ((tick: () => void) => void) | null
|
|
46
54
|
}
|
|
47
55
|
|
|
48
56
|
const HINT_SEPARATOR = " "
|
|
@@ -92,6 +100,9 @@ export const Footer = <C,>({
|
|
|
92
100
|
notice,
|
|
93
101
|
discoveryStatus,
|
|
94
102
|
filterQuery,
|
|
103
|
+
discoverySpinnerIntervalMs,
|
|
104
|
+
discoverySpinnerInitialFrameIndex,
|
|
105
|
+
discoverySpinnerRegisterTick,
|
|
95
106
|
}: FooterProps<C>) => {
|
|
96
107
|
const usableWidth = Math.max(0, width - 2) // 1-cell horizontal padding each side
|
|
97
108
|
|
|
@@ -185,8 +196,23 @@ export const Footer = <C,>({
|
|
|
185
196
|
}
|
|
186
197
|
|
|
187
198
|
if (status !== null) {
|
|
199
|
+
const spinnerProps = {
|
|
200
|
+
fg: colors.secondary,
|
|
201
|
+
...(discoverySpinnerIntervalMs === undefined
|
|
202
|
+
? {}
|
|
203
|
+
: { intervalMs: discoverySpinnerIntervalMs }),
|
|
204
|
+
...(discoverySpinnerInitialFrameIndex === undefined
|
|
205
|
+
? {}
|
|
206
|
+
: { initialFrameIndex: discoverySpinnerInitialFrameIndex }),
|
|
207
|
+
...(discoverySpinnerRegisterTick === undefined
|
|
208
|
+
? {}
|
|
209
|
+
: { registerTick: discoverySpinnerRegisterTick }),
|
|
210
|
+
} satisfies React.ComponentProps<typeof Spinner>
|
|
211
|
+
|
|
188
212
|
return (
|
|
189
213
|
<box style={rowStyle}>
|
|
214
|
+
<Spinner {...spinnerProps} />
|
|
215
|
+
<text content=" " wrapMode="none" style={{ fg: colors.textMuted }} />
|
|
190
216
|
<text content={statusContent} wrapMode="none" style={{ fg: colors.secondary }} />
|
|
191
217
|
<text content={STATUS_SEPARATOR} wrapMode="none" style={{ fg: colors.textMuted }} />
|
|
192
218
|
{renderHints()}
|
package/src/Spinner.tsx
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react"
|
|
2
|
+
import { colors } from "./theme/colors.ts"
|
|
3
|
+
|
|
4
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const
|
|
5
|
+
const FRAME_COUNT = FRAMES.length
|
|
6
|
+
const normalizeFrameIndex = (index: number) => ((index % FRAME_COUNT) + FRAME_COUNT) % FRAME_COUNT
|
|
7
|
+
|
|
8
|
+
export interface SpinnerProps {
|
|
9
|
+
readonly fg?: string
|
|
10
|
+
readonly intervalMs?: number
|
|
11
|
+
readonly initialFrameIndex?: number
|
|
12
|
+
/** Test seam: deterministic driver for frame advancement. When present,
|
|
13
|
+
* Spinner registers its tick callback here instead of starting an interval. */
|
|
14
|
+
readonly registerTick?: ((tick: () => void) => void) | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const Spinner = ({
|
|
18
|
+
fg = colors.textMuted,
|
|
19
|
+
intervalMs = 100,
|
|
20
|
+
initialFrameIndex = 0,
|
|
21
|
+
registerTick = null,
|
|
22
|
+
}: SpinnerProps) => {
|
|
23
|
+
const [index, setIndex] = useState(() => normalizeFrameIndex(initialFrameIndex))
|
|
24
|
+
const tickRef = useRef<() => void>(() => undefined)
|
|
25
|
+
tickRef.current = () => setIndex((prev) => (prev + 1) % FRAME_COUNT)
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (registerTick) {
|
|
29
|
+
registerTick(() => tickRef.current())
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
const id = setInterval(() => {
|
|
33
|
+
tickRef.current()
|
|
34
|
+
}, intervalMs)
|
|
35
|
+
return () => clearInterval(id)
|
|
36
|
+
}, [intervalMs, registerTick])
|
|
37
|
+
|
|
38
|
+
return <text content={FRAMES[index] ?? FRAMES[0]} wrapMode="none" style={{ fg }} />
|
|
39
|
+
}
|
package/src/cli/argv.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { themeDefinitions } from "../theme/registry.ts"
|
|
|
4
4
|
export interface ParsedArgs {
|
|
5
5
|
/** First positional argument, or null if none was given. */
|
|
6
6
|
readonly path: string | null
|
|
7
|
+
/** Value of `--root <dir>`, or null. */
|
|
8
|
+
readonly root: string | null
|
|
7
9
|
/** Value of `--theme <id>`, or null. Validated by the boot layer against the registry. */
|
|
8
10
|
readonly theme: string | null
|
|
9
11
|
/** Value of `--tone dark|light`, or null. Validated by the boot layer. */
|
|
@@ -58,6 +60,7 @@ const createProgram = () =>
|
|
|
58
60
|
.option("--no-mdx")
|
|
59
61
|
.option("--focus [mode]")
|
|
60
62
|
.option("--show [list]")
|
|
63
|
+
.option("--root [dir]")
|
|
61
64
|
.option("-h, --help")
|
|
62
65
|
.option("-v, --version")
|
|
63
66
|
.argument("[path]")
|
|
@@ -71,6 +74,7 @@ const VALUE_FLAGS: ReadonlySet<string> = new Set([
|
|
|
71
74
|
"--sidebar",
|
|
72
75
|
"--focus",
|
|
73
76
|
"--show",
|
|
77
|
+
"--root",
|
|
74
78
|
])
|
|
75
79
|
|
|
76
80
|
const BOOLEAN_FLAGS: ReadonlySet<string> = new Set([
|
|
@@ -115,6 +119,7 @@ export const parseArgv = (argv: readonly string[]): ParsedArgs => {
|
|
|
115
119
|
|
|
116
120
|
return {
|
|
117
121
|
path: typeof pathArg === "string" ? pathArg : null,
|
|
122
|
+
root: stringOrNull(opts["root"]),
|
|
118
123
|
theme: stringOrNull(opts["theme"]),
|
|
119
124
|
tone: stringOrNull(opts["tone"]),
|
|
120
125
|
width: stringOrNull(opts["width"]),
|
|
@@ -144,6 +149,7 @@ options:
|
|
|
144
149
|
--width <N> cap rendered markdown width at N columns
|
|
145
150
|
--show <list> reveal normally-skipped entries; comma-separated subset of:
|
|
146
151
|
hidden, gitignored. Use --show "" to clear.
|
|
152
|
+
--root <dir> discovery root to walk (overrides defaultRoot config)
|
|
147
153
|
--sort <mode> sidebar order: dirs-first (default) or files-first
|
|
148
154
|
--sidebar <m> initial sidebar visibility: auto (default), on, or off
|
|
149
155
|
--focus <m> startup focus: sidebar, reader, or filter (default: filter)
|
|
@@ -157,6 +163,6 @@ options:
|
|
|
157
163
|
|
|
158
164
|
configuration:
|
|
159
165
|
file: $XDG_CONFIG_HOME/house/config.toml (default ~/.config/house/config.toml)
|
|
160
|
-
keys: theme, tone, mdx, show, focus
|
|
161
|
-
env: HOUSE_THEME, HOUSE_TONE, HOUSE_MDX, HOUSE_SHOW, HOUSE_FOCUS
|
|
166
|
+
keys: theme, tone, mdx, show, focus, defaultRoot
|
|
167
|
+
env: HOUSE_THEME, HOUSE_TONE, HOUSE_MDX, HOUSE_SHOW, HOUSE_FOCUS, HOUSE_DEFAULT_ROOT
|
|
162
168
|
precedence (high → low): flags → env → file → defaults`
|
package/src/config/load.ts
CHANGED
|
@@ -20,6 +20,8 @@ export interface HouseConfig {
|
|
|
20
20
|
readonly theme: string
|
|
21
21
|
readonly tone: "dark" | "light"
|
|
22
22
|
readonly mdx: boolean
|
|
23
|
+
/** Default discovery-root strategy when no explicit `--root` flag is passed. */
|
|
24
|
+
readonly defaultRoot: "cwd" | "git"
|
|
23
25
|
/** Categories of normally-skipped entries to opt into. See
|
|
24
26
|
* `src/discovery/show.ts` for the vocabulary. Empty array (the
|
|
25
27
|
* default) yields the conservative discovery set. */
|
|
@@ -43,6 +45,7 @@ export interface CliOverrides {
|
|
|
43
45
|
const DEFAULT_THEME = "opencode"
|
|
44
46
|
const DEFAULT_TONE: "dark" | "light" = "dark"
|
|
45
47
|
const DEFAULT_MDX = true
|
|
48
|
+
const DEFAULT_ROOT: "cwd" | "git" = "cwd"
|
|
46
49
|
const DEFAULT_SHOW = ""
|
|
47
50
|
const DEFAULT_FOCUS: "sidebar" | "reader" | "filter" = "filter"
|
|
48
51
|
|
|
@@ -54,11 +57,19 @@ const themeIds = themeDefinitions.map((t) => t.id)
|
|
|
54
57
|
* Used by `fileProvider` to warn about unrecognized keys (with a
|
|
55
58
|
* did-you-mean hint when one is close) while still loading the rest.
|
|
56
59
|
*/
|
|
57
|
-
const KNOWN_FILE_KEYS: ReadonlySet<string> = new Set([
|
|
60
|
+
const KNOWN_FILE_KEYS: ReadonlySet<string> = new Set([
|
|
61
|
+
"theme",
|
|
62
|
+
"tone",
|
|
63
|
+
"mdx",
|
|
64
|
+
"show",
|
|
65
|
+
"focus",
|
|
66
|
+
"defaultRoot",
|
|
67
|
+
])
|
|
58
68
|
|
|
59
69
|
const schema = Config.all({
|
|
60
70
|
theme: Config.schema(Schema.Literals(themeIds), "theme"),
|
|
61
71
|
tone: Config.schema(Schema.Literals(["dark", "light"] as const), "tone"),
|
|
72
|
+
defaultRoot: Config.schema(Schema.String, "defaultRoot"),
|
|
62
73
|
// Boolean stored as string literal because providers stringify values
|
|
63
74
|
// (TOML bools, env vars, CLI flags all flow through as text). Mapped to
|
|
64
75
|
// a real boolean in `loadConfig` below.
|
|
@@ -75,6 +86,7 @@ const defaultsProvider = (): ConfigProvider.ConfigProvider =>
|
|
|
75
86
|
ConfigProvider.fromUnknown({
|
|
76
87
|
theme: DEFAULT_THEME,
|
|
77
88
|
tone: DEFAULT_TONE,
|
|
89
|
+
defaultRoot: DEFAULT_ROOT,
|
|
78
90
|
mdx: String(DEFAULT_MDX),
|
|
79
91
|
show: DEFAULT_SHOW,
|
|
80
92
|
focus: DEFAULT_FOCUS,
|
|
@@ -192,11 +204,13 @@ const envProvider = (env: Record<string, string | undefined>): ConfigProvider.Co
|
|
|
192
204
|
const entries: Array<[string, string]> = []
|
|
193
205
|
const theme = env["HOUSE_THEME"]
|
|
194
206
|
const tone = env["HOUSE_TONE"]
|
|
207
|
+
const defaultRoot = env["HOUSE_DEFAULT_ROOT"]
|
|
195
208
|
const mdx = env["HOUSE_MDX"]
|
|
196
209
|
const show = env["HOUSE_SHOW"]
|
|
197
210
|
const focus = env["HOUSE_FOCUS"]
|
|
198
211
|
if (theme !== undefined) entries.push(["theme", theme])
|
|
199
212
|
if (tone !== undefined) entries.push(["tone", tone])
|
|
213
|
+
if (defaultRoot !== undefined) entries.push(["defaultRoot", defaultRoot])
|
|
200
214
|
if (mdx !== undefined) entries.push(["mdx", mdx])
|
|
201
215
|
if (show !== undefined) entries.push(["show", show])
|
|
202
216
|
if (focus !== undefined) entries.push(["focus", focus])
|
|
@@ -262,6 +276,13 @@ export const loadConfig = (
|
|
|
262
276
|
)
|
|
263
277
|
return schema.parse(provider).pipe(
|
|
264
278
|
Effect.flatMap((raw) => {
|
|
279
|
+
const defaultRoot =
|
|
280
|
+
raw.defaultRoot === "cwd" || raw.defaultRoot === "git" ? raw.defaultRoot : DEFAULT_ROOT
|
|
281
|
+
if (raw.defaultRoot !== defaultRoot) {
|
|
282
|
+
onWarning(
|
|
283
|
+
`house: ignoring invalid value ${JSON.stringify(raw.defaultRoot)} for defaultRoot in config/env; using "${DEFAULT_ROOT}"`,
|
|
284
|
+
)
|
|
285
|
+
}
|
|
265
286
|
const parsed = parseShowList(raw.show)
|
|
266
287
|
if (!parsed.ok) {
|
|
267
288
|
// Effect's `Config.ConfigError` requires a `SchemaError` or
|
|
@@ -278,6 +299,7 @@ export const loadConfig = (
|
|
|
278
299
|
return Effect.succeed({
|
|
279
300
|
theme: raw.theme,
|
|
280
301
|
tone: raw.tone,
|
|
302
|
+
defaultRoot,
|
|
281
303
|
mdx: raw.mdx === "true",
|
|
282
304
|
show: parsed.value,
|
|
283
305
|
focus: raw.focus,
|
package/src/index.tsx
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { stat } from "node:fs/promises"
|
|
12
|
+
import { dirname, resolve } from "node:path"
|
|
12
13
|
import { createCliRenderer, SyntaxStyle } from "@opentui/core"
|
|
13
14
|
import type { BorderSides } from "@opentui/core"
|
|
14
15
|
import { createRoot, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
|
|
@@ -58,6 +59,39 @@ export interface AppProps {
|
|
|
58
59
|
*/
|
|
59
60
|
export type SidebarMode = "auto" | "on" | "off"
|
|
60
61
|
|
|
62
|
+
const pathIsDirectory = async (path: string): Promise<boolean> => {
|
|
63
|
+
try {
|
|
64
|
+
return (await stat(path)).isDirectory()
|
|
65
|
+
} catch {
|
|
66
|
+
return false
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const findGitRoot = async (cwd: string): Promise<string> => {
|
|
71
|
+
const start = resolve(cwd)
|
|
72
|
+
let current = start
|
|
73
|
+
for (;;) {
|
|
74
|
+
if (await pathIsDirectory(resolve(current, ".git"))) return current
|
|
75
|
+
const parent = dirname(current)
|
|
76
|
+
if (parent === current) return start
|
|
77
|
+
current = parent
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const resolveDiscoveryRoot = async ({
|
|
82
|
+
cliRoot,
|
|
83
|
+
defaultRoot,
|
|
84
|
+
cwd,
|
|
85
|
+
}: {
|
|
86
|
+
readonly cliRoot: string | null
|
|
87
|
+
readonly defaultRoot: "cwd" | "git"
|
|
88
|
+
readonly cwd: string
|
|
89
|
+
}): Promise<string> => {
|
|
90
|
+
if (cliRoot !== null) return cliRoot
|
|
91
|
+
if (defaultRoot === "git") return findGitRoot(cwd)
|
|
92
|
+
return cwd
|
|
93
|
+
}
|
|
94
|
+
|
|
61
95
|
interface DiscoverShellProps {
|
|
62
96
|
readonly target: string
|
|
63
97
|
/** Resolved discovery vocabulary from the config layer. The shift+a
|
|
@@ -277,7 +311,7 @@ if (import.meta.main) {
|
|
|
277
311
|
console.error(`house: ${formatConfigError(err)}`)
|
|
278
312
|
process.exit(2)
|
|
279
313
|
})
|
|
280
|
-
const { theme: themeId, tone, mdx, show, focus: startupFocus } = config
|
|
314
|
+
const { theme: themeId, tone, mdx, show, focus: startupFocus, defaultRoot } = config
|
|
281
315
|
const themeDef = getThemeDefinition(themeId)
|
|
282
316
|
if (themeDef === undefined) {
|
|
283
317
|
// Unreachable: Config.schema validated themeId against themeDefinitions.
|
|
@@ -296,7 +330,9 @@ if (import.meta.main) {
|
|
|
296
330
|
maxWidth = n
|
|
297
331
|
}
|
|
298
332
|
|
|
333
|
+
const cwd = process.cwd()
|
|
299
334
|
const target = args.path ?? "."
|
|
335
|
+
const discoveryRoot = await resolveDiscoveryRoot({ cliRoot: args.root, defaultRoot, cwd })
|
|
300
336
|
|
|
301
337
|
if (args.serve) {
|
|
302
338
|
let stats: Awaited<ReturnType<typeof stat>>
|
|
@@ -357,6 +393,7 @@ if (import.meta.main) {
|
|
|
357
393
|
}
|
|
358
394
|
await runTui({
|
|
359
395
|
target,
|
|
396
|
+
discoveryRoot,
|
|
360
397
|
themeId,
|
|
361
398
|
tone,
|
|
362
399
|
maxWidth,
|
|
@@ -372,6 +409,7 @@ if (import.meta.main) {
|
|
|
372
409
|
|
|
373
410
|
interface TuiBootOptions {
|
|
374
411
|
readonly target: string
|
|
412
|
+
readonly discoveryRoot: string
|
|
375
413
|
readonly themeId: string
|
|
376
414
|
readonly tone: "dark" | "light"
|
|
377
415
|
readonly maxWidth: number | null
|
|
@@ -387,6 +425,7 @@ interface TuiBootOptions {
|
|
|
387
425
|
|
|
388
426
|
async function runTui({
|
|
389
427
|
target,
|
|
428
|
+
discoveryRoot,
|
|
390
429
|
themeId,
|
|
391
430
|
tone,
|
|
392
431
|
maxWidth,
|
|
@@ -430,7 +469,7 @@ async function runTui({
|
|
|
430
469
|
createRoot(renderer).render(
|
|
431
470
|
<RegistryProvider initialValues={[[themeAtom, initialTheme]]}>
|
|
432
471
|
<DiscoverShell
|
|
433
|
-
target={
|
|
472
|
+
target={discoveryRoot}
|
|
434
473
|
initialShow={show}
|
|
435
474
|
sort={sort}
|
|
436
475
|
mdx={mdx}
|
package/src/keymap/browser.ts
CHANGED
|
@@ -11,7 +11,7 @@ export type BrowserFocus = "sidebar" | "reader"
|
|
|
11
11
|
export interface BrowserCtx {
|
|
12
12
|
readonly files: readonly FileEntry[]
|
|
13
13
|
/** True iff `files[selectedIndex]` resolves to an entry. The honest
|
|
14
|
-
* predicate for File-group actions (`
|
|
14
|
+
* predicate for File-group actions (`O`, `E`, `[`, `]`): with debounced
|
|
15
15
|
* filter and sticky auto-select, `files.length > 0` can be true while
|
|
16
16
|
* `selectedIndex` is invalid for the displayed list. See #115. */
|
|
17
17
|
readonly hasSelected: boolean
|
|
@@ -278,7 +278,7 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
|
|
|
278
278
|
group: "File",
|
|
279
279
|
description: "Open current file in browser as HTML",
|
|
280
280
|
hint: "html",
|
|
281
|
-
keys: ["o"],
|
|
281
|
+
keys: ["shift+o"],
|
|
282
282
|
when: hasSelected,
|
|
283
283
|
run: (c) => c.serveCurrent(),
|
|
284
284
|
},
|
|
@@ -287,7 +287,7 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
|
|
|
287
287
|
group: "File",
|
|
288
288
|
description: "Open current file in $EDITOR",
|
|
289
289
|
hint: "edit",
|
|
290
|
-
keys: ["e"],
|
|
290
|
+
keys: ["shift+e"],
|
|
291
291
|
when: hasSelected,
|
|
292
292
|
run: (c) => c.editCurrent(),
|
|
293
293
|
},
|
package/src/serve/server.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Local HTML preview server for a single markdown file.
|
|
3
3
|
*
|
|
4
4
|
* One long-lived `Bun.serve` instance. The served file path is swappable
|
|
5
|
-
* via `setTarget(path)` — used by the TUI's `
|
|
5
|
+
* via `setTarget(path)` — used by the TUI's `O` binding so pressing it on a
|
|
6
6
|
* new file retargets the existing server (live-reload fires) instead of
|
|
7
7
|
* spawning a second one.
|
|
8
8
|
*
|