@carlesandres/house 0.4.3 → 0.4.5
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 +33 -1
- package/README.md +21 -6
- package/package.json +4 -1
- package/src/Browser.tsx +65 -4
- package/src/cli/argv.ts +29 -10
- package/src/commands/buildCommands.ts +5 -0
- package/src/config/load.ts +70 -6
- package/src/discovery/show.ts +48 -0
- package/src/discovery/walk.ts +16 -10
- package/src/index.tsx +65 -9
- package/src/keymap/browser.ts +18 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,36 @@ The publish workflow (`.github/workflows/publish.yml`) runs on the `release: pub
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.4.5] — 2026-05-24
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- npm install/upgrade runtime baseline is now explicit: Node `>=22.22.2` is required when installing `@carlesandres/house` via npm, matching transitive engine requirements.
|
|
14
|
+
|
|
15
|
+
### Docs
|
|
16
|
+
|
|
17
|
+
- README now calls out the Node `>=22.22.2` requirement for npm-based install/upgrade.
|
|
18
|
+
|
|
19
|
+
### Tests
|
|
20
|
+
|
|
21
|
+
- CI now includes an npm global-install smoke matrix on Node `22.22.2` and Node `24` with `engine-strict=true`, catching runtime-floor regressions before release.
|
|
22
|
+
|
|
23
|
+
## [0.4.4] — 2026-05-24
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- Discovery visibility controls: new `--show <list>` CLI flag, `HOUSE_SHOW` env var, and TOML `show = ["..."]` config, replacing boolean discovery toggles with explicit categories (`hidden`, `gitignored`).
|
|
28
|
+
- Session visibility toggle: `shift+a` flips discovery between the configured visibility set and showing all categories, preserving selection across the re-walk.
|
|
29
|
+
- Launch-in-filter option: `--start-in-filter` CLI flag, `HOUSE_START_IN_FILTER` env var, and TOML `start_in_filter = true` open the sidebar filter prompt focused at startup.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- Discovery plumbing now uses a first-class visibility-category model so future categories can be added without reshaping CLI/config/TUI surfaces.
|
|
34
|
+
|
|
35
|
+
### Docs
|
|
36
|
+
|
|
37
|
+
- README updated for the discovery-show vocabulary (`--show`, `HOUSE_SHOW`, `show=[...]`) and the `shift+a` keybind.
|
|
38
|
+
|
|
9
39
|
## [0.4.3] — 2026-05-23
|
|
10
40
|
|
|
11
41
|
### Fixed
|
|
@@ -173,7 +203,9 @@ The v1 MVP, published as `@carlesandres/openmdr` on npm.
|
|
|
173
203
|
|
|
174
204
|
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.
|
|
175
205
|
|
|
176
|
-
[Unreleased]: https://github.com/carlesandres/house/compare/v0.4.
|
|
206
|
+
[Unreleased]: https://github.com/carlesandres/house/compare/v0.4.5...HEAD
|
|
207
|
+
[0.4.5]: https://github.com/carlesandres/house/compare/v0.4.4...v0.4.5
|
|
208
|
+
[0.4.4]: https://github.com/carlesandres/house/compare/v0.4.3...v0.4.4
|
|
177
209
|
[0.4.3]: https://github.com/carlesandres/house/compare/v0.4.2...v0.4.3
|
|
178
210
|
[0.4.2]: https://github.com/carlesandres/house/compare/v0.4.1...v0.4.2
|
|
179
211
|
[0.4.1]: https://github.com/carlesandres/house/compare/v0.4.0...v0.4.1
|
package/README.md
CHANGED
|
@@ -14,7 +14,14 @@ A terminal markdown reader and navigator — themable and configurable, with a k
|
|
|
14
14
|
- **Open in browser**
|
|
15
15
|
- **Open in `$EDITOR`**
|
|
16
16
|
|
|
17
|
-
Requires [Bun](https://bun.sh) on `PATH`.
|
|
17
|
+
Requires [Bun](https://bun.sh) on `PATH`.
|
|
18
|
+
|
|
19
|
+
## Runtime requirements
|
|
20
|
+
|
|
21
|
+
- Bun: required to run `house`.
|
|
22
|
+
- npm install/upgrade path: Node `>=22.22.2` (Node 24 LTS recommended).
|
|
23
|
+
|
|
24
|
+
Supported on macOS and Linux; Windows is unsupported and unvalidated (see [#129](https://github.com/carlesandres/house/issues/129)).
|
|
18
25
|
|
|
19
26
|
## Install
|
|
20
27
|
|
|
@@ -47,12 +54,13 @@ house [options] <path>
|
|
|
47
54
|
| `--theme <name>` | `opencode` | Starting theme (see list below) |
|
|
48
55
|
| `--tone dark\|light` | `dark` | Starting tone |
|
|
49
56
|
| `--width <N>` | — | Cap rendered markdown width at N columns |
|
|
50
|
-
| `--
|
|
57
|
+
| `--show <list>` | `""` | Reveal normally-skipped entries; comma-separated subset of `hidden`, `gitignored`. Use `--show ""` to clear. |
|
|
51
58
|
| `--sort <mode>` | `dirs-first` | Sidebar order: `dirs-first` or `files-first` |
|
|
52
59
|
| `--sidebar <mode>` | `auto` | Initial sidebar visibility: `auto`, `on`, or `off` |
|
|
53
60
|
| `--serve` | off | Serve the given file as HTML in the browser (skips TUI) |
|
|
54
61
|
| `--port <N>` | OS-assigned | Port for `--serve` |
|
|
55
62
|
| `--no-mdx` | off | Exclude `.mdx` files from discovery |
|
|
63
|
+
| `--start-in-filter` | off | Open the sidebar filter prompt on launch so you can type a query immediately. Press Esc to dismiss. |
|
|
56
64
|
| `--no-update-check` | off | Suppress the "newer version available" check (also via `NO_UPDATE_NOTIFIER=1`) |
|
|
57
65
|
| `--config-path` | — | Print the resolved config-file path and exit |
|
|
58
66
|
| `-h`, `--help` | — | Show help and exit |
|
|
@@ -73,16 +81,22 @@ Run `house --config-path` to print the exact location.
|
|
|
73
81
|
theme = "tokyonight"
|
|
74
82
|
tone = "dark"
|
|
75
83
|
mdx = true
|
|
84
|
+
show = ["hidden", "gitignored"]
|
|
85
|
+
start_in_filter = false
|
|
76
86
|
```
|
|
77
87
|
|
|
78
|
-
Supported keys: `theme`, `tone`, `mdx`.
|
|
88
|
+
Supported keys: `theme`, `tone`, `mdx`, `show`, `start_in_filter`.
|
|
89
|
+
|
|
90
|
+
`show` is a list of normally-skipped categories to opt into. Known categories: `hidden` (dot-prefixed entries), `gitignored` (entries matched by a `.gitignore`). Default is the empty list. Hard skips (`node_modules`, `.git`, `.venv`) always apply.
|
|
79
91
|
|
|
80
92
|
Precedence, highest to lowest:
|
|
81
93
|
|
|
82
|
-
1. CLI flags (`--theme`, `--tone`, `--no-mdx`)
|
|
83
|
-
2. Env vars (`HOUSE_THEME`, `HOUSE_TONE`, `HOUSE_MDX`)
|
|
94
|
+
1. CLI flags (`--theme`, `--tone`, `--no-mdx`, `--show`, `--start-in-filter`)
|
|
95
|
+
2. Env vars (`HOUSE_THEME`, `HOUSE_TONE`, `HOUSE_MDX`, `HOUSE_SHOW`, `HOUSE_START_IN_FILTER`)
|
|
84
96
|
3. Config file
|
|
85
|
-
4. Built-in defaults (`opencode` / `dark` / `mdx = true`)
|
|
97
|
+
4. Built-in defaults (`opencode` / `dark` / `mdx = true` / `show = []` / `start_in_filter = false`)
|
|
98
|
+
|
|
99
|
+
`HOUSE_SHOW` takes a comma-separated list (`HOUSE_SHOW=hidden,gitignored`). For `show` specifically, each source completely replaces the next — categories don't merge across layers. Press `shift+a` in the TUI to round-trip between the configured set and the full vocabulary without editing config.
|
|
86
100
|
|
|
87
101
|
The file is optional — a missing file is fine. Invalid keys, unknown themes, or malformed TOML fail loudly with a one-line error. Per-project config (`.house/config.toml`) and additional keys are deferred.
|
|
88
102
|
|
|
@@ -116,6 +130,7 @@ The file is optional — a missing file is fine. Invalid keys, unknown themes, o
|
|
|
116
130
|
| `g` | First file |
|
|
117
131
|
| `G` | Last file |
|
|
118
132
|
| `/` | Filter files (fuzzy match on path) |
|
|
133
|
+
| `A` | Toggle hidden + gitignored entries (session-only; round-trips with the configured `show`) |
|
|
119
134
|
| `↵` / `→` / `l` | Open file (focus reader) |
|
|
120
135
|
|
|
121
136
|
### Reader
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@carlesandres/house",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5",
|
|
4
4
|
"description": "TUI-first markdown reader on opentui",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
"url": "https://github.com/carlesandres/house/issues"
|
|
13
13
|
},
|
|
14
14
|
"homepage": "https://github.com/carlesandres/house#readme",
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=22.22.2"
|
|
17
|
+
},
|
|
15
18
|
"keywords": [
|
|
16
19
|
"markdown",
|
|
17
20
|
"reader",
|
package/src/Browser.tsx
CHANGED
|
@@ -67,6 +67,15 @@ export interface BrowserProps {
|
|
|
67
67
|
/** TTL (ms) for the update-notice toast. Exposed so tests can use a small
|
|
68
68
|
* value instead of sleeping for the production 10s window. */
|
|
69
69
|
readonly updateNoticeTtlMs?: number
|
|
70
|
+
/** Flip the parent's discovery vocabulary (#145). Browser doesn't need
|
|
71
|
+
* to know which categories are currently on — the toggle is opaque
|
|
72
|
+
* from this side; we just snapshot the selected path so it can be
|
|
73
|
+
* restored across the re-walk the parent triggers. */
|
|
74
|
+
readonly onToggleAll?: () => void
|
|
75
|
+
/** Open the sidebar filter prompt on mount so the user can type
|
|
76
|
+
* immediately. Esc closes it through the normal close path — no
|
|
77
|
+
* special "first close" behavior. */
|
|
78
|
+
readonly startInFilter?: boolean
|
|
70
79
|
}
|
|
71
80
|
|
|
72
81
|
const defaultReadFile = (path: string): Promise<string> => Effect.runPromise(readFileText(path))
|
|
@@ -95,6 +104,8 @@ export const Browser = ({
|
|
|
95
104
|
readFile = defaultReadFile,
|
|
96
105
|
updateNotice = null,
|
|
97
106
|
updateNoticeTtlMs = 10000,
|
|
107
|
+
onToggleAll,
|
|
108
|
+
startInFilter = false,
|
|
98
109
|
}: BrowserProps) => {
|
|
99
110
|
const renderer = useRenderer()
|
|
100
111
|
const { width, height } = useTerminalDimensions()
|
|
@@ -123,10 +134,16 @@ export const Browser = ({
|
|
|
123
134
|
return initialShownForAuto(width)
|
|
124
135
|
}
|
|
125
136
|
})
|
|
126
|
-
|
|
137
|
+
// startInFilter mirrors `openFilter`'s focus rule: the filter input lives
|
|
138
|
+
// in the sidebar, so opening it on mount also forces sidebar focus
|
|
139
|
+
// regardless of `--sidebar=off` (§7.1's visibility derivation surfaces
|
|
140
|
+
// the sidebar via focus even when `shown` is false).
|
|
141
|
+
const [focus, setFocus] = useState<"sidebar" | "reader">(() =>
|
|
142
|
+
shown || startInFilter ? "sidebar" : "reader",
|
|
143
|
+
)
|
|
127
144
|
const [sidebarScroll, setSidebarScroll] = useState<number>(0)
|
|
128
145
|
const [helpVisible, setHelpVisible] = useState<boolean>(false)
|
|
129
|
-
const [filterOpen, setFilterOpen] = useState<boolean>(
|
|
146
|
+
const [filterOpen, setFilterOpen] = useState<boolean>(startInFilter)
|
|
130
147
|
const [filterQuery, setFilterQuery] = useState<string>("")
|
|
131
148
|
const [paletteOpen, setPaletteOpen] = useState<boolean>(false)
|
|
132
149
|
const [paletteQuery, setPaletteQuery] = useState<string>("")
|
|
@@ -142,7 +159,7 @@ export const Browser = ({
|
|
|
142
159
|
// updates even when multiple keys arrive in a single React batch (the
|
|
143
160
|
// first key opens the filter; subsequent keys in the same tick would
|
|
144
161
|
// otherwise still observe filterOpen=false through closure).
|
|
145
|
-
const filterOpenRef = useRef(
|
|
162
|
+
const filterOpenRef = useRef(startInFilter)
|
|
146
163
|
const filterQueryRef = useRef("")
|
|
147
164
|
const [footerNotice, setFooterNoticeState] = useState<{
|
|
148
165
|
readonly text: string
|
|
@@ -151,6 +168,13 @@ export const Browser = ({
|
|
|
151
168
|
const pushFooterNotice = (text: string, ttlMs = 2000): void =>
|
|
152
169
|
setFooterNoticeState({ text, ttlMs })
|
|
153
170
|
const serverRef = useRef<ServerHandle | null>(null)
|
|
171
|
+
// #145 selection preservation across an `all` re-walk. When the user
|
|
172
|
+
// toggles, we snapshot the currently selected path; once the new file set
|
|
173
|
+
// streams in, we restore selection by path. If the path isn't present in
|
|
174
|
+
// the new set (e.g. it was a hidden file and `all` just went off), the
|
|
175
|
+
// ref stays armed so toggling back later re-selects it. Any user-driven
|
|
176
|
+
// selection move (j/k/g/G/click) clears it — user intent has moved on.
|
|
177
|
+
const pendingSelectionPathRef = useRef<string | null>(null)
|
|
154
178
|
|
|
155
179
|
// Stop the preview server on unmount so re-mounts (tests) and clean
|
|
156
180
|
// shutdowns don't leak a listening socket.
|
|
@@ -211,6 +235,22 @@ export const Browser = ({
|
|
|
211
235
|
}
|
|
212
236
|
}, [displayedFiles.length, selectedIndex])
|
|
213
237
|
|
|
238
|
+
// #145 selection restoration. Runs whenever the displayed file list
|
|
239
|
+
// changes (re-walk batches, filter changes). If the user has a path
|
|
240
|
+
// armed (set by toggleAll) and it now appears in the displayed subset,
|
|
241
|
+
// restore selection to that index and disarm. If the path is not
|
|
242
|
+
// present, keep the ref armed — toggling back later (or future stream
|
|
243
|
+
// batches in the same toggle) will find it.
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
const target = pendingSelectionPathRef.current
|
|
246
|
+
if (target === null) return
|
|
247
|
+
const idx = displayedFiles.findIndex((f) => f.path === target)
|
|
248
|
+
if (idx >= 0) {
|
|
249
|
+
setSelectedIndex(idx)
|
|
250
|
+
pendingSelectionPathRef.current = null
|
|
251
|
+
}
|
|
252
|
+
}, [displayedFiles])
|
|
253
|
+
|
|
214
254
|
const selected = displayedFiles[selectedIndex]
|
|
215
255
|
|
|
216
256
|
// Track the path whose content is currently rendered. Updated lazily via
|
|
@@ -269,7 +309,15 @@ export const Browser = ({
|
|
|
269
309
|
filterQuery,
|
|
270
310
|
paletteOpen,
|
|
271
311
|
setFocus,
|
|
272
|
-
|
|
312
|
+
// Wrapped so any keymap-driven selection move (j/k/g/G/[/], reader
|
|
313
|
+
// prev/next) clears the pending-restore ref from #145. Internal
|
|
314
|
+
// callers that should NOT clear pending (the filter modal's typing
|
|
315
|
+
// branch, the post-filter clamp effect, the restoration effect
|
|
316
|
+
// itself) deliberately use the raw `setSelectedIndex` setter.
|
|
317
|
+
setSelectedIndex: (updater) => {
|
|
318
|
+
pendingSelectionPathRef.current = null
|
|
319
|
+
setSelectedIndex(updater)
|
|
320
|
+
},
|
|
273
321
|
toggleShown: () => {
|
|
274
322
|
// Two layout shapes, two behaviors:
|
|
275
323
|
// wide → flip the sticky `shown` preference. Per DESIGN.md §7.1
|
|
@@ -330,6 +378,19 @@ export const Browser = ({
|
|
|
330
378
|
},
|
|
331
379
|
cycleTheme,
|
|
332
380
|
toggleTone,
|
|
381
|
+
toggleAll: () => {
|
|
382
|
+
// Snapshot the currently displayed selection unless a snapshot is
|
|
383
|
+
// already armed (a prior toggle's selection survived the re-walk
|
|
384
|
+
// and is still waiting to come back). The armed path is the one
|
|
385
|
+
// the user originally chose; preserving it across a toggle-off /
|
|
386
|
+
// toggle-on round-trip is the headline ergonomic of #145.
|
|
387
|
+
// User-driven nav (j/k/g/G via the wrapped setSelectedIndex below)
|
|
388
|
+
// clears pending, so a follow-up toggle starts a fresh snapshot.
|
|
389
|
+
if (pendingSelectionPathRef.current === null && selected) {
|
|
390
|
+
pendingSelectionPathRef.current = selected.path
|
|
391
|
+
}
|
|
392
|
+
onToggleAll?.()
|
|
393
|
+
},
|
|
333
394
|
serveCurrent: () => {
|
|
334
395
|
const file = displayedFiles[selectedIndex]
|
|
335
396
|
if (!file) return
|
package/src/cli/argv.ts
CHANGED
|
@@ -9,8 +9,6 @@ export interface ParsedArgs {
|
|
|
9
9
|
readonly tone: string | null
|
|
10
10
|
/** Value of `--width <N>`, or null. Validated by the boot layer (must be a positive integer). */
|
|
11
11
|
readonly width: string | null
|
|
12
|
-
/** True when `--all` was passed: include hidden + gitignored files in discovery. */
|
|
13
|
-
readonly all: boolean
|
|
14
12
|
/** Value of `--sort <mode>` (`dirs-first` or `files-first`), or null. Validated by the boot layer. */
|
|
15
13
|
readonly sort: string | null
|
|
16
14
|
/** True when `--serve` was passed: serve the given file as HTML, skip TUI. */
|
|
@@ -31,6 +29,14 @@ export interface ParsedArgs {
|
|
|
31
29
|
readonly noUpdateCheck: boolean
|
|
32
30
|
/** True when `--no-mdx` was passed: exclude `.mdx` files from discovery. */
|
|
33
31
|
readonly noMdx: boolean
|
|
32
|
+
/** True when `--start-in-filter` was passed: open the sidebar filter
|
|
33
|
+
* prompt on launch (one-way override; default is off). */
|
|
34
|
+
readonly startInFilter: boolean
|
|
35
|
+
/** Raw value of `--show <list>`, or null if the flag wasn't passed.
|
|
36
|
+
* Comma-separated list of category names; the boot layer validates
|
|
37
|
+
* tokens against the known vocabulary (see `discovery/show.ts`).
|
|
38
|
+
* `--show ""` is a meaningful value: clears the set. */
|
|
39
|
+
readonly show: string | null
|
|
34
40
|
}
|
|
35
41
|
|
|
36
42
|
/**
|
|
@@ -45,7 +51,6 @@ export const parseArgv = (argv: readonly string[]): ParsedArgs => {
|
|
|
45
51
|
let theme: string | null = null
|
|
46
52
|
let tone: string | null = null
|
|
47
53
|
let width: string | null = null
|
|
48
|
-
let all = false
|
|
49
54
|
let sort: string | null = null
|
|
50
55
|
let serve = false
|
|
51
56
|
let port: string | null = null
|
|
@@ -55,6 +60,8 @@ export const parseArgv = (argv: readonly string[]): ParsedArgs => {
|
|
|
55
60
|
let sidebar: string | null = null
|
|
56
61
|
let noUpdateCheck = false
|
|
57
62
|
let noMdx = false
|
|
63
|
+
let show: string | null = null
|
|
64
|
+
let startInFilter = false
|
|
58
65
|
|
|
59
66
|
for (let i = 0; i < argv.length; i++) {
|
|
60
67
|
const arg = argv[i]!
|
|
@@ -71,9 +78,6 @@ export const parseArgv = (argv: readonly string[]): ParsedArgs => {
|
|
|
71
78
|
width = argv[i + 1] ?? null
|
|
72
79
|
i++
|
|
73
80
|
continue
|
|
74
|
-
case "--all":
|
|
75
|
-
all = true
|
|
76
|
-
continue
|
|
77
81
|
case "--sort":
|
|
78
82
|
sort = argv[i + 1] ?? null
|
|
79
83
|
i++
|
|
@@ -113,6 +117,18 @@ export const parseArgv = (argv: readonly string[]): ParsedArgs => {
|
|
|
113
117
|
case "--no-mdx":
|
|
114
118
|
noMdx = true
|
|
115
119
|
continue
|
|
120
|
+
case "--start-in-filter":
|
|
121
|
+
startInFilter = true
|
|
122
|
+
continue
|
|
123
|
+
case "--show":
|
|
124
|
+
// Always consume the following arg, even when it looks like
|
|
125
|
+
// a flag — `--show ""` is meaningful (explicit empty set),
|
|
126
|
+
// and the empty-string regression matters more than the
|
|
127
|
+
// near-miss case of someone forgetting the value. Boot
|
|
128
|
+
// validates tokens.
|
|
129
|
+
show = argv[i + 1] ?? null
|
|
130
|
+
i++
|
|
131
|
+
continue
|
|
116
132
|
}
|
|
117
133
|
if (path === null && !arg.startsWith("-")) {
|
|
118
134
|
path = arg
|
|
@@ -124,7 +140,6 @@ export const parseArgv = (argv: readonly string[]): ParsedArgs => {
|
|
|
124
140
|
theme,
|
|
125
141
|
tone,
|
|
126
142
|
width,
|
|
127
|
-
all,
|
|
128
143
|
sort,
|
|
129
144
|
serve,
|
|
130
145
|
port,
|
|
@@ -134,6 +149,8 @@ export const parseArgv = (argv: readonly string[]): ParsedArgs => {
|
|
|
134
149
|
sidebar,
|
|
135
150
|
noUpdateCheck,
|
|
136
151
|
noMdx,
|
|
152
|
+
show,
|
|
153
|
+
startInFilter,
|
|
137
154
|
}
|
|
138
155
|
}
|
|
139
156
|
|
|
@@ -147,7 +164,8 @@ options:
|
|
|
147
164
|
--theme <id> color theme: ${themeList} (default: opencode)
|
|
148
165
|
--tone <mode> dark or light (default: dark)
|
|
149
166
|
--width <N> cap rendered markdown width at N columns
|
|
150
|
-
--
|
|
167
|
+
--show <list> reveal normally-skipped entries; comma-separated subset of:
|
|
168
|
+
hidden, gitignored. Use --show "" to clear.
|
|
151
169
|
--sort <mode> sidebar order: dirs-first (default) or files-first
|
|
152
170
|
--sidebar <m> initial sidebar visibility: auto (default), on, or off
|
|
153
171
|
--serve serve the given file as HTML in the browser (skips TUI)
|
|
@@ -157,9 +175,10 @@ options:
|
|
|
157
175
|
--config-path print path to the config file and exit
|
|
158
176
|
--no-update-check suppress the "newer version available" check (also via NO_UPDATE_NOTIFIER=1)
|
|
159
177
|
--no-mdx exclude .mdx files from discovery (default: included)
|
|
178
|
+
--start-in-filter open the sidebar filter prompt on launch
|
|
160
179
|
|
|
161
180
|
configuration:
|
|
162
181
|
file: $XDG_CONFIG_HOME/house/config.toml (default ~/.config/house/config.toml)
|
|
163
|
-
keys: theme, tone, mdx
|
|
164
|
-
env: HOUSE_THEME, HOUSE_TONE, HOUSE_MDX
|
|
182
|
+
keys: theme, tone, mdx, show, start_in_filter
|
|
183
|
+
env: HOUSE_THEME, HOUSE_TONE, HOUSE_MDX, HOUSE_SHOW, HOUSE_START_IN_FILTER
|
|
165
184
|
precedence (high → low): flags → env → file → defaults`
|
|
@@ -44,6 +44,11 @@ const annotations: Record<string, Annotation> = {
|
|
|
44
44
|
"sidebar.toggle": { title: "Toggle sidebar", category: "View" },
|
|
45
45
|
"help.toggle": { title: "Show help", category: "App" },
|
|
46
46
|
"filter.open": { title: "Filter files…", category: "Navigation" },
|
|
47
|
+
"discovery.toggleAll": {
|
|
48
|
+
title: "Toggle hidden / gitignored files",
|
|
49
|
+
category: "Navigation",
|
|
50
|
+
keywords: ["hidden", "gitignore", "dotfiles", "all"],
|
|
51
|
+
},
|
|
47
52
|
"serve.current": { title: "Open in browser", category: "File" },
|
|
48
53
|
"file.edit": { title: "Open in editor", category: "File", keywords: ["editor", "vim", "vscode"] },
|
|
49
54
|
"theme.next": { category: "Appearance" },
|
package/src/config/load.ts
CHANGED
|
@@ -13,23 +13,39 @@
|
|
|
13
13
|
import { homedir } from "node:os"
|
|
14
14
|
import { join } from "node:path"
|
|
15
15
|
import { Config, ConfigProvider, Effect, Schema } from "effect"
|
|
16
|
+
import { parseShowList, SHOW_CATEGORIES, type ShowCategory } from "../discovery/show.ts"
|
|
16
17
|
import { themeDefinitions } from "../theme/registry.ts"
|
|
17
18
|
|
|
18
19
|
export interface HouseConfig {
|
|
19
20
|
readonly theme: string
|
|
20
21
|
readonly tone: "dark" | "light"
|
|
21
22
|
readonly mdx: boolean
|
|
23
|
+
/** Categories of normally-skipped entries to opt into. See
|
|
24
|
+
* `src/discovery/show.ts` for the vocabulary. Empty array (the
|
|
25
|
+
* default) yields the conservative discovery set. */
|
|
26
|
+
readonly show: readonly ShowCategory[]
|
|
27
|
+
/** Open the sidebar filter prompt on launch so the user can type
|
|
28
|
+
* immediately. Esc returns to normal focus (no special behavior).
|
|
29
|
+
* Default false. */
|
|
30
|
+
readonly startInFilter: boolean
|
|
22
31
|
}
|
|
23
32
|
|
|
24
33
|
export interface CliOverrides {
|
|
25
34
|
readonly theme: string | null
|
|
26
35
|
readonly tone: string | null
|
|
27
36
|
readonly mdx: boolean | null
|
|
37
|
+
/** When non-null, the parsed `--show` list completely replaces env/file
|
|
38
|
+
* (no per-category merging — sets compose by replacement, like every
|
|
39
|
+
* other CLI override here). `--show ""` sets the empty set. */
|
|
40
|
+
readonly show: readonly ShowCategory[] | null
|
|
41
|
+
readonly startInFilter: boolean | null
|
|
28
42
|
}
|
|
29
43
|
|
|
30
44
|
const DEFAULT_THEME = "opencode"
|
|
31
45
|
const DEFAULT_TONE: "dark" | "light" = "dark"
|
|
32
46
|
const DEFAULT_MDX = true
|
|
47
|
+
const DEFAULT_SHOW = ""
|
|
48
|
+
const DEFAULT_START_IN_FILTER = false
|
|
33
49
|
|
|
34
50
|
const themeIds = themeDefinitions.map((t) => t.id)
|
|
35
51
|
|
|
@@ -39,7 +55,13 @@ const themeIds = themeDefinitions.map((t) => t.id)
|
|
|
39
55
|
* Used by `fileProvider` to warn about unrecognized keys (with a
|
|
40
56
|
* did-you-mean hint when one is close) while still loading the rest.
|
|
41
57
|
*/
|
|
42
|
-
const KNOWN_FILE_KEYS: ReadonlySet<string> = new Set([
|
|
58
|
+
const KNOWN_FILE_KEYS: ReadonlySet<string> = new Set([
|
|
59
|
+
"theme",
|
|
60
|
+
"tone",
|
|
61
|
+
"mdx",
|
|
62
|
+
"show",
|
|
63
|
+
"start_in_filter",
|
|
64
|
+
])
|
|
43
65
|
|
|
44
66
|
const schema = Config.all({
|
|
45
67
|
theme: Config.schema(Schema.Literals(themeIds), "theme"),
|
|
@@ -48,6 +70,12 @@ const schema = Config.all({
|
|
|
48
70
|
// (TOML bools, env vars, CLI flags all flow through as text). Mapped to
|
|
49
71
|
// a real boolean in `loadConfig` below.
|
|
50
72
|
mdx: Config.schema(Schema.Literals(["true", "false"] as const), "mdx"),
|
|
73
|
+
// `show` arrives as a comma-separated string from every provider
|
|
74
|
+
// (`fileProvider` coerces TOML arrays via `String()`, which produces
|
|
75
|
+
// `"hidden,gitignored"`). Token-level validation happens in `loadConfig`
|
|
76
|
+
// so the error message can list valid categories at the field's path.
|
|
77
|
+
show: Config.schema(Schema.String, "show"),
|
|
78
|
+
start_in_filter: Config.schema(Schema.Literals(["true", "false"] as const), "start_in_filter"),
|
|
51
79
|
})
|
|
52
80
|
|
|
53
81
|
const defaultsProvider = (): ConfigProvider.ConfigProvider =>
|
|
@@ -55,6 +83,8 @@ const defaultsProvider = (): ConfigProvider.ConfigProvider =>
|
|
|
55
83
|
theme: DEFAULT_THEME,
|
|
56
84
|
tone: DEFAULT_TONE,
|
|
57
85
|
mdx: String(DEFAULT_MDX),
|
|
86
|
+
show: DEFAULT_SHOW,
|
|
87
|
+
start_in_filter: String(DEFAULT_START_IN_FILTER),
|
|
58
88
|
})
|
|
59
89
|
|
|
60
90
|
/**
|
|
@@ -170,9 +200,13 @@ const envProvider = (env: Record<string, string | undefined>): ConfigProvider.Co
|
|
|
170
200
|
const theme = env["HOUSE_THEME"]
|
|
171
201
|
const tone = env["HOUSE_TONE"]
|
|
172
202
|
const mdx = env["HOUSE_MDX"]
|
|
203
|
+
const show = env["HOUSE_SHOW"]
|
|
204
|
+
const startInFilter = env["HOUSE_START_IN_FILTER"]
|
|
173
205
|
if (theme !== undefined) entries.push(["theme", theme])
|
|
174
206
|
if (tone !== undefined) entries.push(["tone", tone])
|
|
175
207
|
if (mdx !== undefined) entries.push(["mdx", mdx])
|
|
208
|
+
if (show !== undefined) entries.push(["show", show])
|
|
209
|
+
if (startInFilter !== undefined) entries.push(["start_in_filter", startInFilter])
|
|
176
210
|
return ConfigProvider.fromUnknown(Object.fromEntries(entries))
|
|
177
211
|
}
|
|
178
212
|
|
|
@@ -181,6 +215,9 @@ const cliProvider = (overrides: CliOverrides): ConfigProvider.ConfigProvider =>
|
|
|
181
215
|
if (overrides.theme !== null) entries.push(["theme", overrides.theme])
|
|
182
216
|
if (overrides.tone !== null) entries.push(["tone", overrides.tone])
|
|
183
217
|
if (overrides.mdx !== null) entries.push(["mdx", String(overrides.mdx)])
|
|
218
|
+
if (overrides.show !== null) entries.push(["show", overrides.show.join(",")])
|
|
219
|
+
if (overrides.startInFilter !== null)
|
|
220
|
+
entries.push(["start_in_filter", String(overrides.startInFilter)])
|
|
184
221
|
return ConfigProvider.fromUnknown(Object.fromEntries(entries))
|
|
185
222
|
}
|
|
186
223
|
|
|
@@ -217,15 +254,42 @@ export const formatConfigError = (err: unknown): string => {
|
|
|
217
254
|
|
|
218
255
|
export const loadConfig = (
|
|
219
256
|
options: LoadOptions = {},
|
|
220
|
-
): Effect.Effect<HouseConfig, Config.ConfigError> => {
|
|
221
|
-
const cli = options.cli ?? {
|
|
257
|
+
): Effect.Effect<HouseConfig, Config.ConfigError | Error> => {
|
|
258
|
+
const cli = options.cli ?? {
|
|
259
|
+
theme: null,
|
|
260
|
+
tone: null,
|
|
261
|
+
mdx: null,
|
|
262
|
+
show: null,
|
|
263
|
+
startInFilter: null,
|
|
264
|
+
}
|
|
222
265
|
const onWarning = options.onWarning ?? ((msg) => process.stderr.write(`${msg}\n`))
|
|
223
266
|
const provider = cliProvider(cli).pipe(
|
|
224
267
|
ConfigProvider.orElse(envProvider(options.env ?? process.env)),
|
|
225
268
|
ConfigProvider.orElse(fileProvider(options.filePath ?? defaultConfigPath(), onWarning)),
|
|
226
269
|
ConfigProvider.orElse(defaultsProvider()),
|
|
227
270
|
)
|
|
228
|
-
return schema
|
|
229
|
-
.
|
|
230
|
-
|
|
271
|
+
return schema.parse(provider).pipe(
|
|
272
|
+
Effect.flatMap((raw) => {
|
|
273
|
+
const parsed = parseShowList(raw.show)
|
|
274
|
+
if (!parsed.ok) {
|
|
275
|
+
// Effect's `Config.ConfigError` requires a `SchemaError` or
|
|
276
|
+
// `SourceError` cause that we don't have a clean constructor
|
|
277
|
+
// for here — surface as a plain Error and let the boot
|
|
278
|
+
// layer's existing `formatConfigError` (which already handles
|
|
279
|
+
// `instanceof Error`) render it.
|
|
280
|
+
return Effect.fail(
|
|
281
|
+
new Error(
|
|
282
|
+
`show: unknown category "${parsed.invalid.join('", "')}" (valid: ${SHOW_CATEGORIES.join(", ")})`,
|
|
283
|
+
),
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
return Effect.succeed({
|
|
287
|
+
theme: raw.theme,
|
|
288
|
+
tone: raw.tone,
|
|
289
|
+
mdx: raw.mdx === "true",
|
|
290
|
+
show: parsed.value,
|
|
291
|
+
startInFilter: raw.start_in_filter === "true",
|
|
292
|
+
})
|
|
293
|
+
}),
|
|
294
|
+
)
|
|
231
295
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discovery visibility categories.
|
|
3
|
+
*
|
|
4
|
+
* Each name identifies a class of entries that the walker normally skips and
|
|
5
|
+
* that the user can opt into showing. The set is intentionally an open vocab:
|
|
6
|
+
* adding a future category (e.g. `"build-artifacts"`) means one entry here,
|
|
7
|
+
* one branch in `walk`, and no churn in the CLI/config surface — `--show`,
|
|
8
|
+
* `HOUSE_SHOW`, and TOML `show = [...]` all carry whatever names live in this
|
|
9
|
+
* tuple.
|
|
10
|
+
*/
|
|
11
|
+
export const SHOW_CATEGORIES = ["hidden", "gitignored"] as const
|
|
12
|
+
|
|
13
|
+
export type ShowCategory = (typeof SHOW_CATEGORIES)[number]
|
|
14
|
+
|
|
15
|
+
export const isShowCategory = (value: string): value is ShowCategory =>
|
|
16
|
+
(SHOW_CATEGORIES as readonly string[]).includes(value)
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Parse a comma-separated list of category names. Whitespace around tokens
|
|
20
|
+
* is trimmed; empty tokens (trailing comma, double comma) are dropped.
|
|
21
|
+
* Returns `{ ok, value }` on success or `{ ok: false, invalid }` listing
|
|
22
|
+
* tokens that aren't known categories — callers shape the error message
|
|
23
|
+
* for their surface (CLI vs config).
|
|
24
|
+
*/
|
|
25
|
+
export type ParseShowResult =
|
|
26
|
+
| { readonly ok: true; readonly value: readonly ShowCategory[] }
|
|
27
|
+
| { readonly ok: false; readonly invalid: readonly string[] }
|
|
28
|
+
|
|
29
|
+
export const parseShowList = (raw: string): ParseShowResult => {
|
|
30
|
+
const tokens = raw
|
|
31
|
+
.split(",")
|
|
32
|
+
.map((t) => t.trim())
|
|
33
|
+
.filter((t) => t.length > 0)
|
|
34
|
+
const invalid = tokens.filter((t) => !isShowCategory(t))
|
|
35
|
+
if (invalid.length > 0) return { ok: false, invalid }
|
|
36
|
+
// De-dupe while preserving the configured order — callers that care
|
|
37
|
+
// about presence use Set membership; emitting a unique sequence keeps
|
|
38
|
+
// the round-trip (encode/decode) stable.
|
|
39
|
+
const seen = new Set<ShowCategory>()
|
|
40
|
+
const out: ShowCategory[] = []
|
|
41
|
+
for (const t of tokens as ShowCategory[]) {
|
|
42
|
+
if (!seen.has(t)) {
|
|
43
|
+
seen.add(t)
|
|
44
|
+
out.push(t)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { ok: true, value: out }
|
|
48
|
+
}
|
package/src/discovery/walk.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { readdir, readFile } from "node:fs/promises"
|
|
|
2
2
|
import { extname, join, relative, resolve } from "node:path"
|
|
3
3
|
import { Data, Effect, Stream } from "effect"
|
|
4
4
|
import ignore, { type Ignore } from "ignore"
|
|
5
|
+
import type { ShowCategory } from "./show.ts"
|
|
5
6
|
|
|
6
7
|
export interface FileEntry {
|
|
7
8
|
/** Absolute path on disk. */
|
|
@@ -15,8 +16,11 @@ export interface FileEntry {
|
|
|
15
16
|
export type SortOrder = "dirs-first" | "files-first"
|
|
16
17
|
|
|
17
18
|
export interface WalkOptions {
|
|
18
|
-
/**
|
|
19
|
-
|
|
19
|
+
/** Categories of normally-skipped entries to opt into. Empty (the
|
|
20
|
+
* default) yields the conservative set: no dotfiles, no gitignored
|
|
21
|
+
* entries. Order is irrelevant — semantics are set membership. Hard
|
|
22
|
+
* skips (`node_modules`, `.git`, `.venv`) always apply. */
|
|
23
|
+
readonly show?: Iterable<ShowCategory>
|
|
20
24
|
/** Group order within each directory. Default `dirs-first`. */
|
|
21
25
|
readonly sort?: SortOrder
|
|
22
26
|
/** Include `.mdx` files alongside `.md`/`.markdown`. Default `true`. */
|
|
@@ -88,13 +92,13 @@ async function* walkDirGen(
|
|
|
88
92
|
dirPath: string,
|
|
89
93
|
rootPath: string,
|
|
90
94
|
parentLevels: readonly IgnoreLevel[],
|
|
91
|
-
opts: {
|
|
95
|
+
opts: { showHidden: boolean; showGitignored: boolean; sort: SortOrder; mdx: boolean },
|
|
92
96
|
signal: AbortSignal,
|
|
93
97
|
): AsyncGenerator<FileEntry, void, void> {
|
|
94
98
|
if (signal.aborted) return
|
|
95
99
|
|
|
96
100
|
let levels = parentLevels
|
|
97
|
-
if (!opts.
|
|
101
|
+
if (!opts.showGitignored) {
|
|
98
102
|
const ig = await tryLoadGitignore(dirPath)
|
|
99
103
|
if (signal.aborted) return
|
|
100
104
|
if (ig) levels = [...parentLevels, { dir: dirPath, ig }]
|
|
@@ -114,17 +118,17 @@ async function* walkDirGen(
|
|
|
114
118
|
|
|
115
119
|
if (entry.isDirectory()) {
|
|
116
120
|
if (HARD_SKIP_DIRS.has(entry.name)) continue
|
|
117
|
-
if (!opts.
|
|
118
|
-
if (!opts.
|
|
121
|
+
if (!opts.showHidden && entry.name.startsWith(".")) continue
|
|
122
|
+
if (!opts.showGitignored && isIgnored(entryPath, true, levels)) continue
|
|
119
123
|
yield* walkDirGen(entryPath, rootPath, levels, opts, signal)
|
|
120
124
|
continue
|
|
121
125
|
}
|
|
122
126
|
|
|
123
127
|
if (!entry.isFile()) continue
|
|
124
|
-
if (!opts.
|
|
128
|
+
if (!opts.showHidden && entry.name.startsWith(".")) continue
|
|
125
129
|
const allowed = opts.mdx ? MARKDOWN_EXTENSIONS : MARKDOWN_EXTENSIONS_NO_MDX
|
|
126
130
|
if (!allowed.has(extname(entry.name).toLowerCase())) continue
|
|
127
|
-
if (!opts.
|
|
131
|
+
if (!opts.showGitignored && isIgnored(entryPath, false, levels)) continue
|
|
128
132
|
|
|
129
133
|
yield {
|
|
130
134
|
path: entryPath,
|
|
@@ -143,7 +147,7 @@ async function* walkDirGen(
|
|
|
143
147
|
* Rules (see DESIGN.md §6):
|
|
144
148
|
* - Extensions: `.md`, `.markdown`, and `.mdx` (unless `mdx: false`).
|
|
145
149
|
* - Hard skips (always): `node_modules`, `.git`, `.venv`.
|
|
146
|
-
* - Hidden files/dirs (leading `.`) skipped unless `
|
|
150
|
+
* - Hidden files/dirs (leading `.`) skipped unless `show` contains `"hidden"`.
|
|
147
151
|
* - `.gitignore` honored, including nested `.gitignore` files.
|
|
148
152
|
* - Symlinks not followed.
|
|
149
153
|
* - Sort: alphabetical within each group; directories before files
|
|
@@ -154,8 +158,10 @@ export const walk = (
|
|
|
154
158
|
options: WalkOptions = {},
|
|
155
159
|
): Stream.Stream<FileEntry, DiscoveryError> => {
|
|
156
160
|
const absRoot = resolve(root)
|
|
161
|
+
const show = new Set<ShowCategory>(options.show ?? [])
|
|
157
162
|
const opts = {
|
|
158
|
-
|
|
163
|
+
showHidden: show.has("hidden"),
|
|
164
|
+
showGitignored: show.has("gitignored"),
|
|
159
165
|
sort: options.sort ?? ("dirs-first" as SortOrder),
|
|
160
166
|
mdx: options.mdx ?? true,
|
|
161
167
|
}
|
package/src/index.tsx
CHANGED
|
@@ -19,6 +19,7 @@ import pkg from "../package.json" with { type: "json" }
|
|
|
19
19
|
import { Browser } from "./Browser.tsx"
|
|
20
20
|
import { parseArgv, usage } from "./cli/argv.ts"
|
|
21
21
|
import { defaultConfigPath, formatConfigError, loadConfig } from "./config/load.ts"
|
|
22
|
+
import { parseShowList, SHOW_CATEGORIES, type ShowCategory } from "./discovery/show.ts"
|
|
22
23
|
import { walk, type FileEntry, type SortOrder } from "./discovery/walk.ts"
|
|
23
24
|
import { Header } from "./Header.tsx"
|
|
24
25
|
import { readFileText } from "./io/readFile.ts"
|
|
@@ -59,15 +60,29 @@ export type SidebarMode = "auto" | "on" | "off"
|
|
|
59
60
|
|
|
60
61
|
interface DiscoverShellProps {
|
|
61
62
|
readonly target: string
|
|
62
|
-
|
|
63
|
+
/** Resolved discovery vocabulary from the config layer. The shift+a
|
|
64
|
+
* toggle (#145) is session-only sugar that flips between this set
|
|
65
|
+
* and the full vocabulary; the underlying categories remain
|
|
66
|
+
* independent everywhere else. */
|
|
67
|
+
readonly initialShow: readonly ShowCategory[]
|
|
63
68
|
readonly sort: SortOrder
|
|
64
69
|
readonly mdx: boolean
|
|
65
70
|
readonly maxWidth: number | null
|
|
66
71
|
readonly sidebarMode: SidebarMode
|
|
72
|
+
readonly startInFilter: boolean
|
|
67
73
|
}
|
|
68
74
|
|
|
69
|
-
const DiscoverShell = ({
|
|
75
|
+
const DiscoverShell = ({
|
|
76
|
+
target,
|
|
77
|
+
initialShow,
|
|
78
|
+
sort,
|
|
79
|
+
mdx,
|
|
80
|
+
maxWidth,
|
|
81
|
+
sidebarMode,
|
|
82
|
+
startInFilter,
|
|
83
|
+
}: DiscoverShellProps) => {
|
|
70
84
|
const updateNotice = useUpdateNotice()
|
|
85
|
+
const [show, setShow] = useState<readonly ShowCategory[]>(initialShow)
|
|
71
86
|
const [files, setFiles] = useState<readonly FileEntry[]>([])
|
|
72
87
|
const [scanning, setScanning] = useState<boolean>(true)
|
|
73
88
|
const [scanError, setScanError] = useState<string | null>(null)
|
|
@@ -76,7 +91,15 @@ const DiscoverShell = ({ target, all, sort, mdx, maxWidth, sidebarMode }: Discov
|
|
|
76
91
|
const countRef = useRef(0)
|
|
77
92
|
|
|
78
93
|
useEffect(() => {
|
|
79
|
-
|
|
94
|
+
// Restart from a clean slate every time the discovery set changes.
|
|
95
|
+
// Required for the `all` toggle (#145): without this, a flip would
|
|
96
|
+
// concatenate the new walk onto stale entries and leave `scanning`
|
|
97
|
+
// stuck on whatever the previous walk last set it to.
|
|
98
|
+
setFiles([])
|
|
99
|
+
setScanning(true)
|
|
100
|
+
setScanError(null)
|
|
101
|
+
countRef.current = 0
|
|
102
|
+
const program = walk(target, { show, sort, mdx }).pipe(
|
|
80
103
|
Stream.groupedWithin(64, Duration.millis(60)),
|
|
81
104
|
Stream.runForEach((chunk) =>
|
|
82
105
|
Effect.sync(() => {
|
|
@@ -101,7 +124,7 @@ const DiscoverShell = ({ target, all, sort, mdx, maxWidth, sidebarMode }: Discov
|
|
|
101
124
|
return () => {
|
|
102
125
|
Effect.runFork(Fiber.interrupt(fiber))
|
|
103
126
|
}
|
|
104
|
-
}, [target,
|
|
127
|
+
}, [target, show, sort, mdx])
|
|
105
128
|
|
|
106
129
|
const discoveryStatus = scanError ?? (scanning ? `indexing… ${countRef.current}` : null)
|
|
107
130
|
|
|
@@ -111,7 +134,17 @@ const DiscoverShell = ({ target, all, sort, mdx, maxWidth, sidebarMode }: Discov
|
|
|
111
134
|
maxWidth={maxWidth}
|
|
112
135
|
discoveryStatus={discoveryStatus}
|
|
113
136
|
sidebarMode={sidebarMode}
|
|
137
|
+
startInFilter={startInFilter}
|
|
114
138
|
updateNotice={updateNotice}
|
|
139
|
+
onToggleAll={() => {
|
|
140
|
+
// shift+a is the only place the categories are treated as a
|
|
141
|
+
// single thing. If every category is already on, fall back
|
|
142
|
+
// to "show none"; otherwise opt into the full vocabulary.
|
|
143
|
+
// Each press is a stable round-trip between [] and full.
|
|
144
|
+
const next: readonly ShowCategory[] =
|
|
145
|
+
show.length === SHOW_CATEGORIES.length ? [] : [...SHOW_CATEGORIES]
|
|
146
|
+
setShow(next)
|
|
147
|
+
}}
|
|
115
148
|
/>
|
|
116
149
|
)
|
|
117
150
|
}
|
|
@@ -209,6 +242,20 @@ if (import.meta.main) {
|
|
|
209
242
|
process.exit(0)
|
|
210
243
|
}
|
|
211
244
|
|
|
245
|
+
// Parse --show eagerly so an invalid token fails fast with the CLI-style
|
|
246
|
+
// "house: ..." message, before any I/O work in loadConfig kicks off.
|
|
247
|
+
let cliShow: readonly ShowCategory[] | null = null
|
|
248
|
+
if (args.show !== null) {
|
|
249
|
+
const parsed = parseShowList(args.show)
|
|
250
|
+
if (!parsed.ok) {
|
|
251
|
+
console.error(
|
|
252
|
+
`house: --show: unknown category "${parsed.invalid.join('", "')}" (valid: ${SHOW_CATEGORIES.join(", ")})`,
|
|
253
|
+
)
|
|
254
|
+
process.exit(2)
|
|
255
|
+
}
|
|
256
|
+
cliShow = parsed.value
|
|
257
|
+
}
|
|
258
|
+
|
|
212
259
|
const config = await Effect.runPromise(
|
|
213
260
|
loadConfig({
|
|
214
261
|
cli: {
|
|
@@ -217,13 +264,18 @@ if (import.meta.main) {
|
|
|
217
264
|
// --no-mdx is a one-way override: present means "off". When
|
|
218
265
|
// absent, fall through to env/file/default.
|
|
219
266
|
mdx: args.noMdx ? false : null,
|
|
267
|
+
// `--show` replaces env/file when present (set semantics —
|
|
268
|
+
// no per-category merge across sources). `null` falls through.
|
|
269
|
+
show: cliShow,
|
|
270
|
+
// One-way override: present means "on". Absent → env/file/default.
|
|
271
|
+
startInFilter: args.startInFilter ? true : null,
|
|
220
272
|
},
|
|
221
273
|
}),
|
|
222
274
|
).catch((err: unknown) => {
|
|
223
275
|
console.error(`house: ${formatConfigError(err)}`)
|
|
224
276
|
process.exit(2)
|
|
225
277
|
})
|
|
226
|
-
const { theme: themeId, tone, mdx } = config
|
|
278
|
+
const { theme: themeId, tone, mdx, show, startInFilter } = config
|
|
227
279
|
const themeDef = getThemeDefinition(themeId)
|
|
228
280
|
if (themeDef === undefined) {
|
|
229
281
|
// Unreachable: Config.schema validated themeId against themeDefinitions.
|
|
@@ -298,10 +350,11 @@ if (import.meta.main) {
|
|
|
298
350
|
themeId,
|
|
299
351
|
tone,
|
|
300
352
|
maxWidth,
|
|
301
|
-
|
|
353
|
+
show,
|
|
302
354
|
sort,
|
|
303
355
|
mdx,
|
|
304
356
|
sidebarMode,
|
|
357
|
+
startInFilter,
|
|
305
358
|
updateCheck: !args.noUpdateCheck,
|
|
306
359
|
})
|
|
307
360
|
}
|
|
@@ -312,10 +365,11 @@ interface TuiBootOptions {
|
|
|
312
365
|
readonly themeId: string
|
|
313
366
|
readonly tone: "dark" | "light"
|
|
314
367
|
readonly maxWidth: number | null
|
|
315
|
-
readonly
|
|
368
|
+
readonly show: readonly ShowCategory[]
|
|
316
369
|
readonly sort: SortOrder
|
|
317
370
|
readonly mdx: boolean
|
|
318
371
|
readonly sidebarMode: SidebarMode
|
|
372
|
+
readonly startInFilter: boolean
|
|
319
373
|
/** Run the npm-registry probe and surface the "update available" notice.
|
|
320
374
|
* False suppresses both the toast and the quit-time print. */
|
|
321
375
|
readonly updateCheck: boolean
|
|
@@ -326,10 +380,11 @@ async function runTui({
|
|
|
326
380
|
themeId,
|
|
327
381
|
tone,
|
|
328
382
|
maxWidth,
|
|
329
|
-
|
|
383
|
+
show,
|
|
330
384
|
sort,
|
|
331
385
|
mdx,
|
|
332
386
|
sidebarMode,
|
|
387
|
+
startInFilter,
|
|
333
388
|
updateCheck,
|
|
334
389
|
}: TuiBootOptions): Promise<void> {
|
|
335
390
|
let stats: Awaited<ReturnType<typeof stat>>
|
|
@@ -366,11 +421,12 @@ async function runTui({
|
|
|
366
421
|
<RegistryProvider initialValues={[[themeAtom, initialTheme]]}>
|
|
367
422
|
<DiscoverShell
|
|
368
423
|
target={target}
|
|
369
|
-
|
|
424
|
+
initialShow={show}
|
|
370
425
|
sort={sort}
|
|
371
426
|
mdx={mdx}
|
|
372
427
|
maxWidth={maxWidth}
|
|
373
428
|
sidebarMode={sidebarMode}
|
|
429
|
+
startInFilter={startInFilter}
|
|
374
430
|
/>
|
|
375
431
|
</RegistryProvider>,
|
|
376
432
|
)
|
package/src/keymap/browser.ts
CHANGED
|
@@ -43,6 +43,11 @@ export interface BrowserCtx {
|
|
|
43
43
|
/** Suspend the TUI, hand the TTY to `$EDITOR`, resume and re-read on
|
|
44
44
|
* exit. No-op when nothing is selected; gating is the binding's job. */
|
|
45
45
|
readonly editCurrent: () => void
|
|
46
|
+
/** Toggle hidden + gitignored discovery axes together (#145 — UI sugar
|
|
47
|
+
* for `shift+a`; the underlying flags stay independent everywhere
|
|
48
|
+
* else). Snapshots the current selected path so it can be restored
|
|
49
|
+
* once the re-walk completes. */
|
|
50
|
+
readonly toggleAll: () => void
|
|
46
51
|
}
|
|
47
52
|
|
|
48
53
|
/** Step size for shift+j/k and the space/b/page keys. Constant for v1; could
|
|
@@ -147,6 +152,19 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
|
|
|
147
152
|
when: paletteClosed,
|
|
148
153
|
run: (c) => c.openPalette(),
|
|
149
154
|
},
|
|
155
|
+
{
|
|
156
|
+
id: "discovery.toggleAll",
|
|
157
|
+
group: "Sidebar",
|
|
158
|
+
description: "Show / hide hidden and gitignored files",
|
|
159
|
+
// No footer hint: shift+a is help/palette only. The footer hint row is
|
|
160
|
+
// already at width capacity on narrow viewports, and the toggle isn't
|
|
161
|
+
// a per-row action you reach for constantly.
|
|
162
|
+
keys: ["shift+a"],
|
|
163
|
+
// Selection-preservation logic lives in Browser.tsx — see the
|
|
164
|
+
// `pendingSelectionPath` ref. The toggle itself is session-only and
|
|
165
|
+
// does not write back to the TOML config.
|
|
166
|
+
run: (c) => c.toggleAll(),
|
|
167
|
+
},
|
|
150
168
|
{
|
|
151
169
|
id: "theme.next",
|
|
152
170
|
group: "Global",
|