@carlesandres/house 0.4.8 → 0.4.10
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 -4
- package/README.md +50 -51
- package/package.json +2 -1
- package/src/Browser.tsx +20 -1
- package/src/Footer.tsx +4 -5
- package/src/cli/argv.ts +11 -15
- package/src/config/load.ts +18 -14
- package/src/config/save.ts +95 -0
- package/src/discovery/filter.ts +83 -23
- package/src/discovery/walk.ts +19 -20
- package/src/index.tsx +17 -28
- package/src/layout/sidebarRow.ts +8 -24
- package/src/ui/middleTruncate.ts +27 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,31 @@ The publish workflow (`.github/workflows/publish.yml`) runs on the `release: pub
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.4.10] — 2026-06-14
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Active theme selection is now persisted globally, with hardened config saving that preserves symlinked config files.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Sidebar parent paths now middle-truncate in deep trees to keep filenames scannable.
|
|
18
|
+
- Markdown discovery now uses an extension allow-list for supported markdown file types.
|
|
19
|
+
|
|
20
|
+
## [0.4.9] — 2026-06-09
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- Sidebar rows now keep a tighter single-space filename/path gap, and footer hint width calculation better handles wide glyphs.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- Removed the experimental `--sort` option. The sidebar now consistently lists files before directories, so current-directory files appear before nested subtrees in the base list. This makes less-nested files naturally rank higher even without a search term. When a filter query is active the existing `filterFiles` / `rankFile` scoring (filename bias + strengthened depth penalties) still applies on top.
|
|
29
|
+
|
|
30
|
+
### Docs
|
|
31
|
+
|
|
32
|
+
- Demo recording now lives in `recordings/record-demo.sh`, with docs and recorded assets updated to match the new capture flow.
|
|
33
|
+
|
|
9
34
|
## [0.4.8] — 2026-06-02
|
|
10
35
|
|
|
11
36
|
### Fixed
|
|
@@ -18,7 +43,7 @@ The publish workflow (`.github/workflows/publish.yml`) runs on the `release: pub
|
|
|
18
43
|
### Docs
|
|
19
44
|
|
|
20
45
|
- Corrected usage docs and `--help` text: directory browsing is controlled by `--root` / `defaultRoot`; the positional path now seeds the initial browser filter query.
|
|
21
|
-
- Refreshed unified-browser docs, roadmap
|
|
46
|
+
- Refreshed unified-browser docs, pruned shipped roadmap items, and updated demo tapes after the single Browser model landed.
|
|
22
47
|
- Added ADR 0001 documenting the decision to keep the current `marked`-based browser preview instead of adopting Streamdown for now.
|
|
23
48
|
|
|
24
49
|
### Changed
|
|
@@ -184,7 +209,7 @@ Beta release gates (DESIGN §10.2) closed.
|
|
|
184
209
|
|
|
185
210
|
### Docs
|
|
186
211
|
|
|
187
|
-
- README embeds
|
|
212
|
+
- README embeds the recorded demo asset; `recordings/` holds the capture workflow.
|
|
188
213
|
- `CONTRIBUTING.md` documents the `captureSpans()` / `MockTreeSitterClient` / `TestRecorder` testing patterns and the "before blaming `<markdown>`" stale-watcher debugging checklist.
|
|
189
214
|
|
|
190
215
|
## [0.3.0] — 2026-05-16
|
|
@@ -245,7 +270,7 @@ The v1 MVP, published as `@carlesandres/openmdr` on npm.
|
|
|
245
270
|
|
|
246
271
|
### Added — discovery
|
|
247
272
|
|
|
248
|
-
- Recursive walk from the path argument (or cwd),
|
|
273
|
+
- Recursive walk from the path argument (or cwd), limited to markdown extensions.
|
|
249
274
|
- Honors `.gitignore` (root + nested).
|
|
250
275
|
- Hard-skips `node_modules`, `.git`, `.venv` (always, even with `--all`).
|
|
251
276
|
- Does not follow symlinks.
|
|
@@ -280,7 +305,9 @@ The v1 MVP, published as `@carlesandres/openmdr` on npm.
|
|
|
280
305
|
|
|
281
306
|
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.
|
|
282
307
|
|
|
283
|
-
[Unreleased]: https://github.com/carlesandres/house/compare/v0.4.
|
|
308
|
+
[Unreleased]: https://github.com/carlesandres/house/compare/v0.4.10...HEAD
|
|
309
|
+
[0.4.10]: https://github.com/carlesandres/house/compare/v0.4.9...v0.4.10
|
|
310
|
+
[0.4.9]: https://github.com/carlesandres/house/compare/v0.4.8...v0.4.9
|
|
284
311
|
[0.4.8]: https://github.com/carlesandres/house/compare/v0.4.7...v0.4.8
|
|
285
312
|
[0.4.7]: https://github.com/carlesandres/house/compare/v0.4.6...v0.4.7
|
|
286
313
|
[0.4.6]: https://github.com/carlesandres/house/compare/v0.4.5...v0.4.6
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
A terminal markdown reader and navigator — themable and configurable, with a keyboard-driven modern UI. Browse a directory of `.md` files without leaving the terminal.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
<video src="recordings/house-demo.mp4" controls muted loop playsinline></video>
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
@@ -60,23 +60,22 @@ house --serve README.md
|
|
|
60
60
|
|
|
61
61
|
### Options
|
|
62
62
|
|
|
63
|
-
| Flag
|
|
64
|
-
|
|
65
|
-
| `--theme <name>`
|
|
66
|
-
| `--tone dark\|light` | `dark`
|
|
67
|
-
| `--width <N>`
|
|
68
|
-
| `--show <list>`
|
|
69
|
-
| `--root <dir>`
|
|
70
|
-
| `--
|
|
71
|
-
| `--
|
|
72
|
-
| `--
|
|
73
|
-
| `--
|
|
74
|
-
| `--
|
|
75
|
-
| `--no-
|
|
76
|
-
| `--
|
|
77
|
-
| `--
|
|
78
|
-
| `-
|
|
79
|
-
| `-v`, `--version` | — | Print version and exit |
|
|
63
|
+
| Flag | Default | Description |
|
|
64
|
+
| -------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------ |
|
|
65
|
+
| `--theme <name>` | `opencode` | Starting theme (see list below) |
|
|
66
|
+
| `--tone dark\|light` | `dark` | Starting tone |
|
|
67
|
+
| `--width <N>` | — | Cap rendered markdown width at N columns |
|
|
68
|
+
| `--show <list>` | `""` | Reveal normally-skipped entries; comma-separated subset of `hidden`, `gitignored`. Use `--show ""` to clear. |
|
|
69
|
+
| `--root <dir>` | current directory | Discovery root to walk; overrides `defaultRoot` config/env |
|
|
70
|
+
| `--sidebar <mode>` | `auto` | Initial sidebar visibility: `auto`, `on`, or `off` |
|
|
71
|
+
| `--focus <mode>` | `filter` | Startup focus: `sidebar`, `reader`, or `filter`. `filter` opens the sidebar filter prompt immediately. |
|
|
72
|
+
| `--serve` | off | Serve the positional path as HTML in the browser (skips TUI) |
|
|
73
|
+
| `--port <N>` | OS-assigned | Port for `--serve` |
|
|
74
|
+
| `--ext <list>` | none | Include extra file extensions (comma-separated) |
|
|
75
|
+
| `--no-update-check` | off | Suppress the "newer version available" check (also via `NO_UPDATE_NOTIFIER=1`) |
|
|
76
|
+
| `--config-path` | — | Print the resolved config-file path and exit |
|
|
77
|
+
| `-h`, `--help` | — | Show help and exit |
|
|
78
|
+
| `-v`, `--version` | — | Print version and exit |
|
|
80
79
|
|
|
81
80
|
## Configuration
|
|
82
81
|
|
|
@@ -92,22 +91,22 @@ Run `house --config-path` to print the exact location.
|
|
|
92
91
|
# ~/.config/house/config.toml
|
|
93
92
|
theme = "tokyonight"
|
|
94
93
|
tone = "dark"
|
|
95
|
-
|
|
94
|
+
extensions = []
|
|
96
95
|
show = ["hidden", "gitignored"]
|
|
97
96
|
focus = "filter"
|
|
98
97
|
defaultRoot = "cwd" # or "git"
|
|
99
98
|
```
|
|
100
99
|
|
|
101
|
-
Supported keys: `theme`, `tone`, `
|
|
100
|
+
Supported keys: `theme`, `tone`, `extensions`, `show`, `focus`, `defaultRoot`.
|
|
102
101
|
|
|
103
102
|
`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.
|
|
104
103
|
|
|
105
104
|
Precedence, highest to lowest:
|
|
106
105
|
|
|
107
|
-
1. CLI flags (`--theme`, `--tone`, `--
|
|
108
|
-
2. Env vars (`HOUSE_THEME`, `HOUSE_TONE`, `
|
|
106
|
+
1. CLI flags (`--theme`, `--tone`, `--ext`, `--show`, `--focus`, `--root`)
|
|
107
|
+
2. Env vars (`HOUSE_THEME`, `HOUSE_TONE`, `HOUSE_EXTENSIONS`, `HOUSE_SHOW`, `HOUSE_FOCUS`, `HOUSE_DEFAULT_ROOT`)
|
|
109
108
|
3. Config file
|
|
110
|
-
4. Built-in defaults (`opencode` / `dark` / `
|
|
109
|
+
4. Built-in defaults (`opencode` / `dark` / `extensions = []` / `show = []` / `focus = "filter"` / `defaultRoot = "cwd"`)
|
|
111
110
|
|
|
112
111
|
`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.
|
|
113
112
|
|
|
@@ -117,41 +116,41 @@ The file is optional — a missing file is fine. Invalid keys, unknown themes, o
|
|
|
117
116
|
|
|
118
117
|
### Global
|
|
119
118
|
|
|
120
|
-
| Key
|
|
121
|
-
|
|
122
|
-
| `q` / `ctrl+c` | Quit
|
|
123
|
-
| `tab`
|
|
124
|
-
| `s`
|
|
125
|
-
| `ctrl+p`
|
|
126
|
-
| `O`
|
|
127
|
-
| `E`
|
|
128
|
-
| `t`
|
|
129
|
-
| `T`
|
|
130
|
-
| `L`
|
|
119
|
+
| Key | Action |
|
|
120
|
+
| -------------- | ----------------------------------------------------------- |
|
|
121
|
+
| `q` / `ctrl+c` | Quit |
|
|
122
|
+
| `tab` | Toggle focus (sidebar ↔ reader) |
|
|
123
|
+
| `s` | Toggle sidebar visibility |
|
|
124
|
+
| `ctrl+p` | Command palette |
|
|
125
|
+
| `O` | Open current file in browser as HTML |
|
|
126
|
+
| `E` | Open current file in `$EDITOR` (`$VISUAL` takes precedence) |
|
|
127
|
+
| `t` | Next theme |
|
|
128
|
+
| `T` | Previous theme |
|
|
129
|
+
| `L` | Toggle dark / light tone |
|
|
131
130
|
|
|
132
131
|
### Sidebar
|
|
133
132
|
|
|
134
|
-
| Key
|
|
135
|
-
|
|
136
|
-
| `j` / `↓`
|
|
137
|
-
| `k` / `↑`
|
|
138
|
-
| `J`
|
|
139
|
-
| `K`
|
|
140
|
-
| `space` / `pagedown` / `ctrl+d` | Page down
|
|
141
|
-
| `b` / `pageup` / `ctrl+u`
|
|
142
|
-
| `g`
|
|
143
|
-
| `G`
|
|
144
|
-
| `/`
|
|
145
|
-
| `A`
|
|
146
|
-
| `↵` / `→` / `l`
|
|
133
|
+
| Key | Action |
|
|
134
|
+
| ------------------------------- | ----------------------------------------------------------------------------------------- |
|
|
135
|
+
| `j` / `↓` | Move selection down |
|
|
136
|
+
| `k` / `↑` | Move selection up |
|
|
137
|
+
| `J` | Jump down 8 |
|
|
138
|
+
| `K` | Jump up 8 |
|
|
139
|
+
| `space` / `pagedown` / `ctrl+d` | Page down |
|
|
140
|
+
| `b` / `pageup` / `ctrl+u` | Page up |
|
|
141
|
+
| `g` | First file |
|
|
142
|
+
| `G` | Last file |
|
|
143
|
+
| `/` | Filter files (fuzzy match on path) |
|
|
144
|
+
| `A` | Toggle hidden + gitignored entries (session-only; round-trips with the configured `show`) |
|
|
145
|
+
| `↵` / `→` / `l` | Open file (focus reader) |
|
|
147
146
|
|
|
148
147
|
### Reader
|
|
149
148
|
|
|
150
|
-
| Key
|
|
151
|
-
|
|
149
|
+
| Key | Action |
|
|
150
|
+
| ----------------- | --------------- |
|
|
152
151
|
| `esc` / `←` / `h` | Back to sidebar |
|
|
153
|
-
| `[`
|
|
154
|
-
| `]`
|
|
152
|
+
| `[` | Previous file |
|
|
153
|
+
| `]` | Next file |
|
|
155
154
|
|
|
156
155
|
## Themes
|
|
157
156
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@carlesandres/house",
|
|
3
|
-
|
|
3
|
+
"version": "0.4.10",
|
|
4
4
|
"description": "TUI-first markdown reader on opentui",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"scripts": {
|
|
43
43
|
"dev": "bun --watch src/index.tsx",
|
|
44
44
|
"start": "bun run src/index.tsx",
|
|
45
|
+
"record-demo": "bash recordings/record-demo.sh",
|
|
45
46
|
"typecheck": "tsc --noEmit",
|
|
46
47
|
"lint": "oxlint --tsconfig tsconfig.json src/",
|
|
47
48
|
"format": "oxfmt src/",
|
package/src/Browser.tsx
CHANGED
|
@@ -45,6 +45,8 @@ import { startServer, type ServerHandle } from "./serve/server.ts"
|
|
|
45
45
|
import { colors, setActiveTheme } from "./theme/colors.ts"
|
|
46
46
|
import { themeAtom } from "./theme/atom.ts"
|
|
47
47
|
import { themeDefinitions, getThemeDefinition } from "./theme/registry.ts"
|
|
48
|
+
import { saveThemePreference } from "./config/save.ts"
|
|
49
|
+
import { middleTruncate } from "./ui/middleTruncate.ts"
|
|
48
50
|
|
|
49
51
|
export type SidebarMode = "auto" | "on" | "off"
|
|
50
52
|
export type StartupFocus = "sidebar" | "reader" | "filter"
|
|
@@ -362,6 +364,12 @@ export const Browser = ({
|
|
|
362
364
|
if (!next) return
|
|
363
365
|
setActiveTheme(next, theme.tone)
|
|
364
366
|
setTheme({ id: next.id, tone: theme.tone })
|
|
367
|
+
void saveThemePreference({ theme: next.id, tone: theme.tone }).catch((err) => {
|
|
368
|
+
pushFooterNotice("theme not saved")
|
|
369
|
+
process.stderr.write(
|
|
370
|
+
`house: failed to save theme preference: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
371
|
+
)
|
|
372
|
+
})
|
|
365
373
|
pushFooterNotice(`theme: ${next.name}`)
|
|
366
374
|
}
|
|
367
375
|
|
|
@@ -370,6 +378,12 @@ export const Browser = ({
|
|
|
370
378
|
const def = getThemeDefinition(theme.id)
|
|
371
379
|
if (def) setActiveTheme(def, nextTone)
|
|
372
380
|
setTheme({ id: theme.id, tone: nextTone })
|
|
381
|
+
void saveThemePreference({ theme: theme.id, tone: nextTone }).catch((err) => {
|
|
382
|
+
pushFooterNotice("theme not saved")
|
|
383
|
+
process.stderr.write(
|
|
384
|
+
`house: failed to save theme preference: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
385
|
+
)
|
|
386
|
+
})
|
|
373
387
|
pushFooterNotice(`tone: ${nextTone}`)
|
|
374
388
|
}
|
|
375
389
|
|
|
@@ -967,7 +981,12 @@ export const Browser = ({
|
|
|
967
981
|
<text key={file.path} wrapMode="none" style={rowStyle}>
|
|
968
982
|
<span style={{ fg: basenameFg }}>{basename}</span>
|
|
969
983
|
{parent !== "" && (
|
|
970
|
-
<span style={{ fg: colors.textMuted }}>
|
|
984
|
+
<span style={{ fg: colors.textMuted }}>
|
|
985
|
+
{middleTruncate(
|
|
986
|
+
`${separator}${parent}`,
|
|
987
|
+
Math.max(0, sidebarTextWidth - basename.length),
|
|
988
|
+
)}
|
|
989
|
+
</span>
|
|
971
990
|
)}
|
|
972
991
|
</text>
|
|
973
992
|
)
|
package/src/Footer.tsx
CHANGED
|
@@ -15,12 +15,11 @@
|
|
|
15
15
|
* sidebar (see Browser.tsx). The pattern mirrors ghui's PR list, where the
|
|
16
16
|
* filter is part of the list it filters.
|
|
17
17
|
*
|
|
18
|
-
* Width math
|
|
19
|
-
*
|
|
20
|
-
* length as a proxy for cell count; introducing a CJK or emoji label would
|
|
21
|
-
* require a real cell-width counter (e.g. East Asian Width).
|
|
18
|
+
* Width math uses terminal-style string width instead of raw string length so
|
|
19
|
+
* ambiguous glyphs such as `↵` don't steal spacing from the label beside them.
|
|
22
20
|
*/
|
|
23
21
|
|
|
22
|
+
import stringWidth from "string-width"
|
|
24
23
|
import type React from "react"
|
|
25
24
|
import type { KeyBinding } from "./keymap/keymap.ts"
|
|
26
25
|
import { displayKey } from "./keymap/displayKey.ts"
|
|
@@ -59,7 +58,7 @@ interface Hint {
|
|
|
59
58
|
readonly label: string
|
|
60
59
|
}
|
|
61
60
|
|
|
62
|
-
const hintWidth = (h: Hint): number => h.key
|
|
61
|
+
const hintWidth = (h: Hint): number => stringWidth(h.key) + 1 + stringWidth(h.label) // key + " " + label
|
|
63
62
|
|
|
64
63
|
const formatHint = <C,>(b: KeyBinding<C>): Hint | null => {
|
|
65
64
|
if (!b.hint) return null
|
package/src/cli/argv.ts
CHANGED
|
@@ -12,8 +12,6 @@ export interface ParsedArgs {
|
|
|
12
12
|
readonly tone: string | null
|
|
13
13
|
/** Value of `--width <N>`, or null. Validated by the boot layer (must be a positive integer). */
|
|
14
14
|
readonly width: string | null
|
|
15
|
-
/** Value of `--sort <mode>` (`dirs-first` or `files-first`), or null. Validated by the boot layer. */
|
|
16
|
-
readonly sort: string | null
|
|
17
15
|
/** True when `--serve` was passed: serve the positional path as HTML, skip TUI. */
|
|
18
16
|
readonly serve: boolean
|
|
19
17
|
/** Value of `--port <N>`, or null. Validated by the boot layer. */
|
|
@@ -30,8 +28,8 @@ export interface ParsedArgs {
|
|
|
30
28
|
* probe and the "update available" notice. Mirrors the
|
|
31
29
|
* `NO_UPDATE_NOTIFIER` env var so opt-out is reachable without env state. */
|
|
32
30
|
readonly noUpdateCheck: boolean
|
|
33
|
-
/**
|
|
34
|
-
readonly
|
|
31
|
+
/** Raw value of `--ext [list]`, or null if absent. Comma-separated list. */
|
|
32
|
+
readonly extensions: string | null
|
|
35
33
|
/** Value of `--focus <mode>` (`sidebar`, `reader`, `filter`), or null.
|
|
36
34
|
* Validated by the boot layer. */
|
|
37
35
|
readonly focus: string | null
|
|
@@ -51,13 +49,12 @@ const createProgram = () =>
|
|
|
51
49
|
.option("--theme [id]")
|
|
52
50
|
.option("--tone [mode]")
|
|
53
51
|
.option("--width [N]")
|
|
54
|
-
.option("--sort [mode]")
|
|
55
52
|
.option("--serve")
|
|
56
53
|
.option("--port [N]")
|
|
57
54
|
.option("--config-path")
|
|
58
55
|
.option("--sidebar [mode]")
|
|
59
56
|
.option("--no-update-check")
|
|
60
|
-
.option("--
|
|
57
|
+
.option("--ext [list]")
|
|
61
58
|
.option("--focus [mode]")
|
|
62
59
|
.option("--show [list]")
|
|
63
60
|
.option("--root [dir]")
|
|
@@ -69,19 +66,20 @@ const VALUE_FLAGS: ReadonlySet<string> = new Set([
|
|
|
69
66
|
"--theme",
|
|
70
67
|
"--tone",
|
|
71
68
|
"--width",
|
|
72
|
-
"--sort",
|
|
73
69
|
"--port",
|
|
74
70
|
"--sidebar",
|
|
75
71
|
"--focus",
|
|
76
72
|
"--show",
|
|
77
73
|
"--root",
|
|
74
|
+
"--ext",
|
|
78
75
|
])
|
|
79
76
|
|
|
77
|
+
const REMOVED_VALUE_FLAGS: ReadonlySet<string> = new Set(["--sort"])
|
|
78
|
+
|
|
80
79
|
const BOOLEAN_FLAGS: ReadonlySet<string> = new Set([
|
|
81
80
|
"--serve",
|
|
82
81
|
"--config-path",
|
|
83
82
|
"--no-update-check",
|
|
84
|
-
"--no-mdx",
|
|
85
83
|
"--help",
|
|
86
84
|
"-h",
|
|
87
85
|
"--version",
|
|
@@ -91,7 +89,7 @@ const BOOLEAN_FLAGS: ReadonlySet<string> = new Set([
|
|
|
91
89
|
const findPathArg = (argv: readonly string[]): string | null => {
|
|
92
90
|
for (let i = 0; i < argv.length; i++) {
|
|
93
91
|
const arg = argv[i]!
|
|
94
|
-
if (VALUE_FLAGS.has(arg)) {
|
|
92
|
+
if (VALUE_FLAGS.has(arg) || REMOVED_VALUE_FLAGS.has(arg)) {
|
|
95
93
|
const next = argv[i + 1]
|
|
96
94
|
if (next !== undefined && !next.startsWith("-")) i++
|
|
97
95
|
continue
|
|
@@ -123,7 +121,6 @@ export const parseArgv = (argv: readonly string[]): ParsedArgs => {
|
|
|
123
121
|
theme: stringOrNull(opts["theme"]),
|
|
124
122
|
tone: stringOrNull(opts["tone"]),
|
|
125
123
|
width: stringOrNull(opts["width"]),
|
|
126
|
-
sort: stringOrNull(opts["sort"]),
|
|
127
124
|
serve: opts["serve"] === true,
|
|
128
125
|
port: stringOrNull(opts["port"]),
|
|
129
126
|
help: opts["help"] === true,
|
|
@@ -131,7 +128,7 @@ export const parseArgv = (argv: readonly string[]): ParsedArgs => {
|
|
|
131
128
|
configPath: opts["configPath"] === true,
|
|
132
129
|
sidebar: stringOrNull(opts["sidebar"]),
|
|
133
130
|
noUpdateCheck: opts["noUpdateCheck"] === true,
|
|
134
|
-
|
|
131
|
+
extensions: stringOrNull(opts["ext"]),
|
|
135
132
|
show: stringOrNull(opts["show"]),
|
|
136
133
|
focus: stringOrNull(opts["focus"]),
|
|
137
134
|
}
|
|
@@ -152,7 +149,6 @@ options:
|
|
|
152
149
|
--show <list> reveal normally-skipped entries; comma-separated subset of:
|
|
153
150
|
hidden, gitignored. Use --show "" to clear.
|
|
154
151
|
--root <dir> discovery root to walk (overrides defaultRoot config/env)
|
|
155
|
-
--sort <mode> sidebar order: dirs-first (default) or files-first
|
|
156
152
|
--sidebar <m> initial sidebar visibility: auto (default), on, or off
|
|
157
153
|
--focus <m> startup focus: sidebar, reader, or filter (default: filter)
|
|
158
154
|
--serve serve the positional path as HTML in the browser (skips TUI)
|
|
@@ -161,7 +157,7 @@ options:
|
|
|
161
157
|
-v, --version print version and exit
|
|
162
158
|
--config-path print path to the config file and exit
|
|
163
159
|
--no-update-check suppress the "newer version available" check (also via NO_UPDATE_NOTIFIER=1)
|
|
164
|
-
|
|
160
|
+
--ext <list> include extra file extensions (comma-separated)
|
|
165
161
|
|
|
166
162
|
examples:
|
|
167
163
|
house README.md
|
|
@@ -170,6 +166,6 @@ examples:
|
|
|
170
166
|
|
|
171
167
|
configuration:
|
|
172
168
|
file: $XDG_CONFIG_HOME/house/config.toml (default ~/.config/house/config.toml)
|
|
173
|
-
|
|
174
|
-
|
|
169
|
+
keys: theme, tone, extensions, show, focus, defaultRoot
|
|
170
|
+
env: HOUSE_THEME, HOUSE_TONE, HOUSE_EXTENSIONS, HOUSE_SHOW, HOUSE_FOCUS, HOUSE_DEFAULT_ROOT
|
|
175
171
|
precedence (high → low): flags → env → file → defaults`
|
package/src/config/load.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { themeDefinitions } from "../theme/registry.ts"
|
|
|
19
19
|
export interface HouseConfig {
|
|
20
20
|
readonly theme: string
|
|
21
21
|
readonly tone: "dark" | "light"
|
|
22
|
-
readonly
|
|
22
|
+
readonly extensions: readonly string[]
|
|
23
23
|
/** Default discovery-root strategy when no explicit `--root` flag is passed. */
|
|
24
24
|
readonly defaultRoot: "cwd" | "git"
|
|
25
25
|
/** Categories of normally-skipped entries to opt into. See
|
|
@@ -34,7 +34,7 @@ export interface HouseConfig {
|
|
|
34
34
|
export interface CliOverrides {
|
|
35
35
|
readonly theme: string | null
|
|
36
36
|
readonly tone: string | null
|
|
37
|
-
readonly
|
|
37
|
+
readonly extensions: readonly string[] | null
|
|
38
38
|
/** When non-null, the parsed `--show` list completely replaces env/file
|
|
39
39
|
* (no per-category merging — sets compose by replacement, like every
|
|
40
40
|
* other CLI override here). `--show ""` sets the empty set. */
|
|
@@ -44,7 +44,7 @@ export interface CliOverrides {
|
|
|
44
44
|
|
|
45
45
|
const DEFAULT_THEME = "opencode"
|
|
46
46
|
const DEFAULT_TONE: "dark" | "light" = "dark"
|
|
47
|
-
const
|
|
47
|
+
const DEFAULT_EXTENSIONS: readonly string[] = []
|
|
48
48
|
const DEFAULT_ROOT: "cwd" | "git" = "cwd"
|
|
49
49
|
const DEFAULT_SHOW = ""
|
|
50
50
|
const DEFAULT_FOCUS: "sidebar" | "reader" | "filter" = "filter"
|
|
@@ -60,7 +60,7 @@ const themeIds = themeDefinitions.map((t) => t.id)
|
|
|
60
60
|
const KNOWN_FILE_KEYS: ReadonlySet<string> = new Set([
|
|
61
61
|
"theme",
|
|
62
62
|
"tone",
|
|
63
|
-
"
|
|
63
|
+
"extensions",
|
|
64
64
|
"show",
|
|
65
65
|
"focus",
|
|
66
66
|
"defaultRoot",
|
|
@@ -70,10 +70,8 @@ const schema = Config.all({
|
|
|
70
70
|
theme: Config.schema(Schema.Literals(themeIds), "theme"),
|
|
71
71
|
tone: Config.schema(Schema.Literals(["dark", "light"] as const), "tone"),
|
|
72
72
|
defaultRoot: Config.schema(Schema.String, "defaultRoot"),
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
// a real boolean in `loadConfig` below.
|
|
76
|
-
mdx: Config.schema(Schema.Literals(["true", "false"] as const), "mdx"),
|
|
73
|
+
// Comma-separated extension list. Empty string means no extra extensions.
|
|
74
|
+
extensions: Config.schema(Schema.String, "extensions"),
|
|
77
75
|
// `show` arrives as a comma-separated string from every provider
|
|
78
76
|
// (`fileProvider` coerces TOML arrays via `String()`, which produces
|
|
79
77
|
// `"hidden,gitignored"`). Token-level validation happens in `loadConfig`
|
|
@@ -87,7 +85,7 @@ const defaultsProvider = (): ConfigProvider.ConfigProvider =>
|
|
|
87
85
|
theme: DEFAULT_THEME,
|
|
88
86
|
tone: DEFAULT_TONE,
|
|
89
87
|
defaultRoot: DEFAULT_ROOT,
|
|
90
|
-
|
|
88
|
+
extensions: DEFAULT_EXTENSIONS.join(","),
|
|
91
89
|
show: DEFAULT_SHOW,
|
|
92
90
|
focus: DEFAULT_FOCUS,
|
|
93
91
|
})
|
|
@@ -205,13 +203,13 @@ const envProvider = (env: Record<string, string | undefined>): ConfigProvider.Co
|
|
|
205
203
|
const theme = env["HOUSE_THEME"]
|
|
206
204
|
const tone = env["HOUSE_TONE"]
|
|
207
205
|
const defaultRoot = env["HOUSE_DEFAULT_ROOT"]
|
|
208
|
-
const
|
|
206
|
+
const extensions = env["HOUSE_EXTENSIONS"]
|
|
209
207
|
const show = env["HOUSE_SHOW"]
|
|
210
208
|
const focus = env["HOUSE_FOCUS"]
|
|
211
209
|
if (theme !== undefined) entries.push(["theme", theme])
|
|
212
210
|
if (tone !== undefined) entries.push(["tone", tone])
|
|
213
211
|
if (defaultRoot !== undefined) entries.push(["defaultRoot", defaultRoot])
|
|
214
|
-
if (
|
|
212
|
+
if (extensions !== undefined) entries.push(["extensions", extensions])
|
|
215
213
|
if (show !== undefined) entries.push(["show", show])
|
|
216
214
|
if (focus !== undefined) entries.push(["focus", focus])
|
|
217
215
|
return ConfigProvider.fromUnknown(Object.fromEntries(entries))
|
|
@@ -221,7 +219,7 @@ const cliProvider = (overrides: CliOverrides): ConfigProvider.ConfigProvider =>
|
|
|
221
219
|
const entries: Array<[string, string]> = []
|
|
222
220
|
if (overrides.theme !== null) entries.push(["theme", overrides.theme])
|
|
223
221
|
if (overrides.tone !== null) entries.push(["tone", overrides.tone])
|
|
224
|
-
if (overrides.
|
|
222
|
+
if (overrides.extensions !== null) entries.push(["extensions", overrides.extensions.join(",")])
|
|
225
223
|
if (overrides.show !== null) entries.push(["show", overrides.show.join(",")])
|
|
226
224
|
if (overrides.focus !== null) entries.push(["focus", overrides.focus])
|
|
227
225
|
return ConfigProvider.fromUnknown(Object.fromEntries(entries))
|
|
@@ -264,7 +262,7 @@ export const loadConfig = (
|
|
|
264
262
|
const cli = options.cli ?? {
|
|
265
263
|
theme: null,
|
|
266
264
|
tone: null,
|
|
267
|
-
|
|
265
|
+
extensions: null,
|
|
268
266
|
show: null,
|
|
269
267
|
focus: null,
|
|
270
268
|
}
|
|
@@ -300,7 +298,13 @@ export const loadConfig = (
|
|
|
300
298
|
theme: raw.theme,
|
|
301
299
|
tone: raw.tone,
|
|
302
300
|
defaultRoot,
|
|
303
|
-
|
|
301
|
+
extensions:
|
|
302
|
+
raw.extensions === ""
|
|
303
|
+
? []
|
|
304
|
+
: raw.extensions
|
|
305
|
+
.split(",")
|
|
306
|
+
.map((s) => s.trim())
|
|
307
|
+
.filter(Boolean),
|
|
304
308
|
show: parsed.value,
|
|
305
309
|
focus: raw.focus,
|
|
306
310
|
})
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { dirname } from "node:path"
|
|
2
|
+
import { lstat, mkdir, readFile, realpath, rename, unlink, writeFile } from "node:fs/promises"
|
|
3
|
+
import { defaultConfigPath } from "./load.ts"
|
|
4
|
+
|
|
5
|
+
export interface ThemePreference {
|
|
6
|
+
readonly theme: string
|
|
7
|
+
readonly tone: "dark" | "light"
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
let saveQueue: Promise<void> = Promise.resolve()
|
|
11
|
+
|
|
12
|
+
const encodeTomlString = (value: string): string => JSON.stringify(value)
|
|
13
|
+
|
|
14
|
+
const upsertTopLevelString = (raw: string, key: keyof ThemePreference, value: string): string => {
|
|
15
|
+
const encoded = encodeTomlString(value)
|
|
16
|
+
const lines = raw.split("\n")
|
|
17
|
+
const keyPattern = new RegExp(`^(\\s*)${key}\\s*=.*$`)
|
|
18
|
+
let inTopLevel = true
|
|
19
|
+
let insertAt = lines.length
|
|
20
|
+
|
|
21
|
+
for (let i = 0; i < lines.length; i++) {
|
|
22
|
+
const line = lines[i]!
|
|
23
|
+
if (/^\s*\[[^\]]+\]\s*(?:#.*)?$/.test(line)) {
|
|
24
|
+
inTopLevel = false
|
|
25
|
+
insertAt = Math.min(insertAt, i)
|
|
26
|
+
}
|
|
27
|
+
if (!inTopLevel) continue
|
|
28
|
+
const match = keyPattern.exec(line)
|
|
29
|
+
if (match) {
|
|
30
|
+
lines[i] = `${match[1]}${key} = ${encoded}`
|
|
31
|
+
return lines.join("\n")
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const insertion = `${key} = ${encoded}`
|
|
36
|
+
if (insertAt === lines.length) {
|
|
37
|
+
if (lines.length === 0 || lines[lines.length - 1] !== "") return `${raw}\n${insertion}\n`
|
|
38
|
+
lines.splice(lines.length - 1, 0, insertion)
|
|
39
|
+
return lines.join("\n")
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
lines.splice(insertAt, 0, insertion)
|
|
43
|
+
return lines.join("\n")
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const updateThemePreferenceToml = (raw: string, record: ThemePreference): string => {
|
|
47
|
+
// Validate the existing file before preserving and editing its text. If the
|
|
48
|
+
// user has malformed TOML, fail loudly rather than replacing it wholesale.
|
|
49
|
+
Bun.TOML.parse(raw)
|
|
50
|
+
return upsertTopLevelString(upsertTopLevelString(raw, "theme", record.theme), "tone", record.tone)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const resolveWritableConfigPath = async (path: string): Promise<string> => {
|
|
54
|
+
try {
|
|
55
|
+
const stat = await lstat(path)
|
|
56
|
+
if (stat.isSymbolicLink()) return await realpath(path)
|
|
57
|
+
} catch (err) {
|
|
58
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err
|
|
59
|
+
}
|
|
60
|
+
return path
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const writeThemePreference = async (record: ThemePreference, path: string): Promise<void> => {
|
|
64
|
+
const targetPath = await resolveWritableConfigPath(path)
|
|
65
|
+
path = targetPath
|
|
66
|
+
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`
|
|
67
|
+
try {
|
|
68
|
+
await mkdir(dirname(path), { recursive: true })
|
|
69
|
+
let next = `theme = ${encodeTomlString(record.theme)}\ntone = ${encodeTomlString(record.tone)}\n`
|
|
70
|
+
try {
|
|
71
|
+
const raw = await readFile(path, "utf8")
|
|
72
|
+
next = updateThemePreferenceToml(raw, record)
|
|
73
|
+
} catch (err) {
|
|
74
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err
|
|
75
|
+
}
|
|
76
|
+
await writeFile(tmp, next, "utf8")
|
|
77
|
+
await rename(tmp, path)
|
|
78
|
+
} catch (err) {
|
|
79
|
+
try {
|
|
80
|
+
await unlink(tmp)
|
|
81
|
+
} catch {
|
|
82
|
+
// best-effort cleanup
|
|
83
|
+
}
|
|
84
|
+
throw err
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const saveThemePreference = async (
|
|
89
|
+
record: ThemePreference,
|
|
90
|
+
path = defaultConfigPath(),
|
|
91
|
+
): Promise<void> => {
|
|
92
|
+
const run = saveQueue.catch(() => {}).then(() => writeThemePreference(record, path))
|
|
93
|
+
saveQueue = run.catch(() => {})
|
|
94
|
+
return run
|
|
95
|
+
}
|
package/src/discovery/filter.ts
CHANGED
|
@@ -3,12 +3,26 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Matching stays intentionally small and pure: case-insensitive subsequence on
|
|
5
5
|
* the filename and full relative path. Ranking prefers what users usually mean
|
|
6
|
-
* in a sidebar:
|
|
7
|
-
* - filename matches above folder-only matches
|
|
8
|
-
* -
|
|
9
|
-
* -
|
|
6
|
+
* in a sidebar (no configuration, no user knobs):
|
|
7
|
+
* - filename matches above folder-only matches (zf/fzf-inspired strong bias)
|
|
8
|
+
* - shallower / current-folder paths win over deep nested ones
|
|
9
|
+
* - incidental matches purely from deep directory names are heavily demoted
|
|
10
|
+
* so "nested folders bubbling up" does not happen for typical queries
|
|
10
11
|
*
|
|
11
12
|
* Empty query preserves discovery/tree order.
|
|
13
|
+
*
|
|
14
|
+
* The low-level fuzzyScore uses a "best alignment" scan (try every possible
|
|
15
|
+
* start position for the first query char, complete greedily, take the highest
|
|
16
|
+
* scoring match). This avoids the "early spurious char traps" a pure left-to-right
|
|
17
|
+
* greedy can hit on paths (e.g. "src/r.../readme" for query "readme" must prefer
|
|
18
|
+
* the boundary "r" in the filename, not the "r" in "src"). This draws from fzf's
|
|
19
|
+
* v1 "find occurrence then look for better" idea and the general principle that
|
|
20
|
+
* boundary+consecutive bonuses should win when they exist later in the string.
|
|
21
|
+
* We deliberately do not depend on fuzzysort/fuse/fzf here: the ranking *policy*
|
|
22
|
+
* (heavy filename bias, exact/stem bonuses, depth soft penalty) is application
|
|
23
|
+
* specific to "what makes a sidebar nice" and must be owned so end users get the
|
|
24
|
+
* good experience with zero configuration or thought. See DESIGN.md §7.4 and the
|
|
25
|
+
* filter tests.
|
|
12
26
|
*/
|
|
13
27
|
|
|
14
28
|
import type { FileEntry } from "./walk.ts"
|
|
@@ -17,19 +31,41 @@ export const fuzzyScore = (query: string, target: string): number | null => {
|
|
|
17
31
|
if (query.length === 0) return 0
|
|
18
32
|
const q = query.toLowerCase()
|
|
19
33
|
const t = target.toLowerCase()
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
let
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
34
|
+
if (q.length > t.length) return null
|
|
35
|
+
|
|
36
|
+
let best: number | null = null
|
|
37
|
+
|
|
38
|
+
// Best-alignment: try every viable start for q[0], then complete the rest
|
|
39
|
+
// greedily from there. Take the alignment that accumulates the most bonus
|
|
40
|
+
// points (word-start after /, consecutives). This is what gives good OOB
|
|
41
|
+
// results on tree paths without the caller doing extra work.
|
|
42
|
+
for (let start = 0; start < t.length; start++) {
|
|
43
|
+
if (t[start] !== q[0]) continue
|
|
44
|
+
|
|
45
|
+
let qi = 1
|
|
46
|
+
let score = 0
|
|
47
|
+
let lastMatch = start
|
|
48
|
+
|
|
49
|
+
// score first char
|
|
50
|
+
const isWordStart0 = start === 0 || t[start - 1] === "/"
|
|
51
|
+
score += isWordStart0 ? 10 : 1
|
|
52
|
+
|
|
53
|
+
let i = start + 1
|
|
54
|
+
for (; i < t.length && qi < q.length; i++) {
|
|
55
|
+
if (t[i] !== q[qi]) continue
|
|
56
|
+
const isWordStart = i === 0 || t[i - 1] === "/"
|
|
57
|
+
score += isWordStart ? 10 : 1
|
|
58
|
+
if (lastMatch === i - 1) score += 5
|
|
59
|
+
lastMatch = i
|
|
60
|
+
qi++
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (qi === q.length) {
|
|
64
|
+
if (best === null || score > best) best = score
|
|
65
|
+
}
|
|
30
66
|
}
|
|
31
|
-
|
|
32
|
-
return
|
|
67
|
+
|
|
68
|
+
return best
|
|
33
69
|
}
|
|
34
70
|
|
|
35
71
|
const splitPath = (relativePath: string): { fileName: string; depth: number } => {
|
|
@@ -55,15 +91,31 @@ const rankFile = (query: string, file: FileEntry): number | null => {
|
|
|
55
91
|
const stem = fileStem(fileName).toLowerCase()
|
|
56
92
|
const nameScore = fuzzyScore(query, fileName) ?? 0
|
|
57
93
|
|
|
58
|
-
let score = pathScore *
|
|
59
|
-
score += nameScore *
|
|
94
|
+
let score = pathScore * 4
|
|
95
|
+
score += nameScore * 150
|
|
60
96
|
|
|
97
|
+
// Filename (and stem) priority — the heart of good sidebar UX.
|
|
98
|
+
// Users almost always mean "the file whose name contains this", not
|
|
99
|
+
// "some directory component that happens to have these letters".
|
|
61
100
|
if (name === q || stem === q) score += 5_000
|
|
62
101
|
else if (name.startsWith(q) || stem.startsWith(q)) score += 2_000
|
|
63
102
|
else if (name.includes(q)) score += 1_000
|
|
64
103
|
|
|
65
|
-
|
|
66
|
-
|
|
104
|
+
// Strong depth bias so less-nested files appear near the top.
|
|
105
|
+
// We want shallow files to win over deep "nested folders" even when
|
|
106
|
+
// the deep file has a decent path match (common with short queries or
|
|
107
|
+
// dir names). The penalty is now large enough to matter vs. the
|
|
108
|
+
// match bonuses. Root gets a big kick. This is the main lever for
|
|
109
|
+
// "position in the tree structure" without any user config.
|
|
110
|
+
if (depth === 0) score += 800
|
|
111
|
+
score -= depth * 120
|
|
112
|
+
|
|
113
|
+
// Extra penalty when the match is weak or absent in the *basename*.
|
|
114
|
+
// This aggressively demotes results where the hit is only because the
|
|
115
|
+
// file lives under a matching deep directory ("nested folders bubbling up").
|
|
116
|
+
if (nameScore == null || nameScore < 8) {
|
|
117
|
+
score -= depth * 80
|
|
118
|
+
}
|
|
67
119
|
|
|
68
120
|
return score
|
|
69
121
|
}
|
|
@@ -73,16 +125,24 @@ const rankFile = (query: string, file: FileEntry): number | null => {
|
|
|
73
125
|
* unchanged (preserves the discovery sort order). Non-empty query keeps
|
|
74
126
|
* matches only, sorted by score desc; ties fall back to the input order so
|
|
75
127
|
* the discovery sort still leaks through.
|
|
128
|
+
*
|
|
129
|
+
* All the "make this feel right for users exploring a tree" logic lives here
|
|
130
|
+
* (and in rankFile) so callers (Browser) and end users never have to think
|
|
131
|
+
* about or configure ranking.
|
|
76
132
|
*/
|
|
77
133
|
export const filterFiles = (files: readonly FileEntry[], query: string): readonly FileEntry[] => {
|
|
78
134
|
if (query.length === 0) return files
|
|
79
|
-
const scored: { file: FileEntry; score: number; index: number }[] = []
|
|
135
|
+
const scored: { file: FileEntry; score: number; depth: number; index: number }[] = []
|
|
80
136
|
for (let i = 0; i < files.length; i++) {
|
|
81
137
|
const file = files[i]!
|
|
82
138
|
const score = rankFile(query, file)
|
|
83
139
|
if (score === null) continue
|
|
84
|
-
|
|
140
|
+
const depth = (file.relativePath.match(/\//g) || []).length
|
|
141
|
+
scored.push({ file, score, depth, index: i })
|
|
85
142
|
}
|
|
86
|
-
|
|
143
|
+
// Primary: match quality (fuzzy + name bonuses - depth penalties already in score)
|
|
144
|
+
// Secondary: shallower depth wins (less nested near top)
|
|
145
|
+
// Tertiary: stable original discovery order
|
|
146
|
+
scored.sort((a, b) => b.score - a.score || a.depth - b.depth || a.index - b.index)
|
|
87
147
|
return scored.map((s) => s.file)
|
|
88
148
|
}
|
package/src/discovery/walk.ts
CHANGED
|
@@ -18,18 +18,15 @@ export interface DiscoveryWarning {
|
|
|
18
18
|
readonly cause: unknown
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
export type SortOrder = "dirs-first" | "files-first"
|
|
22
|
-
|
|
23
21
|
export interface WalkOptions {
|
|
24
22
|
/** Categories of normally-skipped entries to opt into. Empty (the
|
|
25
23
|
* default) yields the conservative set: no dotfiles, no gitignored
|
|
26
24
|
* entries. Order is irrelevant — semantics are set membership. Hard
|
|
27
25
|
* skips (`node_modules`, `.git`, `.venv`) always apply. */
|
|
28
26
|
readonly show?: Iterable<ShowCategory>
|
|
29
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
readonly mdx?: boolean
|
|
27
|
+
/** Additional file extensions to include alongside `.md` / `.markdown`.
|
|
28
|
+
* Values may include or omit the leading dot. */
|
|
29
|
+
readonly extensions?: Iterable<string>
|
|
33
30
|
/** Non-fatal subtree read errors. Root-level failures still error the walk. */
|
|
34
31
|
readonly onWarning?: ((warning: DiscoveryWarning) => void) | null
|
|
35
32
|
}
|
|
@@ -39,10 +36,14 @@ export class DiscoveryError extends Data.TaggedError("DiscoveryError")<{
|
|
|
39
36
|
readonly cause: unknown
|
|
40
37
|
}> {}
|
|
41
38
|
|
|
42
|
-
const
|
|
43
|
-
const MARKDOWN_EXTENSIONS_NO_MDX = new Set([".md", ".markdown"])
|
|
39
|
+
const BASE_EXTENSIONS = new Set([".md", ".markdown"])
|
|
44
40
|
const HARD_SKIP_DIRS = new Set(["node_modules", ".git", ".venv"])
|
|
45
41
|
|
|
42
|
+
const normalizeExtension = (ext: string): string => {
|
|
43
|
+
const trimmed = ext.trim().toLowerCase()
|
|
44
|
+
return trimmed.startsWith(".") ? trimmed : `.${trimmed}`
|
|
45
|
+
}
|
|
46
|
+
|
|
46
47
|
interface IgnoreLevel {
|
|
47
48
|
readonly dir: string
|
|
48
49
|
readonly ig: Ignore
|
|
@@ -73,14 +74,12 @@ const tryLoadGitignore = async (dir: string): Promise<Ignore | null> => {
|
|
|
73
74
|
|
|
74
75
|
const sortEntries = <T extends { name: string; isDirectory: () => boolean }>(
|
|
75
76
|
entries: readonly T[],
|
|
76
|
-
order: SortOrder,
|
|
77
77
|
): T[] =>
|
|
78
78
|
[...entries].sort((a, b) => {
|
|
79
79
|
const aDir = a.isDirectory()
|
|
80
80
|
const bDir = b.isDirectory()
|
|
81
81
|
if (aDir !== bDir) {
|
|
82
|
-
|
|
83
|
-
return aDir ? -1 : 1
|
|
82
|
+
return aDir ? 1 : -1
|
|
84
83
|
}
|
|
85
84
|
return a.name.localeCompare(b.name)
|
|
86
85
|
})
|
|
@@ -88,7 +87,7 @@ const sortEntries = <T extends { name: string; isDirectory: () => boolean }>(
|
|
|
88
87
|
/**
|
|
89
88
|
* DFS generator. Yields each markdown FileEntry as it is discovered, before
|
|
90
89
|
* descending further. Per-directory sort still happens before yielding so
|
|
91
|
-
* arrival order
|
|
90
|
+
* arrival order is files-first and alphabetical within each group.
|
|
92
91
|
*
|
|
93
92
|
* Cancellation: `signal.aborted` is checked between syscalls. Node's
|
|
94
93
|
* `readdir` doesn't accept an AbortSignal, so a single in-flight `readdir`
|
|
@@ -99,7 +98,7 @@ async function* walkDirGen(
|
|
|
99
98
|
dirPath: string,
|
|
100
99
|
rootPath: string,
|
|
101
100
|
parentLevels: readonly IgnoreLevel[],
|
|
102
|
-
opts: { showHidden: boolean; showGitignored: boolean;
|
|
101
|
+
opts: { showHidden: boolean; showGitignored: boolean; extensions: ReadonlySet<string> },
|
|
103
102
|
onWarning: ((warning: DiscoveryWarning) => void) | null,
|
|
104
103
|
signal: AbortSignal,
|
|
105
104
|
): AsyncGenerator<FileEntry, void, void> {
|
|
@@ -123,7 +122,7 @@ async function* walkDirGen(
|
|
|
123
122
|
}
|
|
124
123
|
if (signal.aborted) return
|
|
125
124
|
|
|
126
|
-
for (const entry of sortEntries(raw
|
|
125
|
+
for (const entry of sortEntries(raw)) {
|
|
127
126
|
if (signal.aborted) return
|
|
128
127
|
|
|
129
128
|
// Never follow symlinks — cycle hazard, and a markdown reader doesn't
|
|
@@ -142,7 +141,7 @@ async function* walkDirGen(
|
|
|
142
141
|
|
|
143
142
|
if (!entry.isFile()) continue
|
|
144
143
|
if (!opts.showHidden && entry.name.startsWith(".")) continue
|
|
145
|
-
const allowed = opts.
|
|
144
|
+
const allowed = opts.extensions
|
|
146
145
|
if (!allowed.has(extname(entry.name).toLowerCase())) continue
|
|
147
146
|
if (!opts.showGitignored && isIgnored(entryPath, false, levels)) continue
|
|
148
147
|
|
|
@@ -161,13 +160,12 @@ async function* walkDirGen(
|
|
|
161
160
|
* at its next `signal.aborted` check.
|
|
162
161
|
*
|
|
163
162
|
* Rules (see DESIGN.md §6):
|
|
164
|
-
* - Extensions: `.md`, `.markdown`,
|
|
163
|
+
* - Extensions: `.md`, `.markdown`, plus configured extras.
|
|
165
164
|
* - Hard skips (always): `node_modules`, `.git`, `.venv`.
|
|
166
165
|
* - Hidden files/dirs (leading `.`) skipped unless `show` contains `"hidden"`.
|
|
167
166
|
* - `.gitignore` honored, including nested `.gitignore` files.
|
|
168
167
|
* - Symlinks not followed.
|
|
169
|
-
* - Sort: alphabetical within each group
|
|
170
|
-
* (`dirs-first`, default) or files before directories (`files-first`).
|
|
168
|
+
* - Sort: files before directories, alphabetical within each group.
|
|
171
169
|
*/
|
|
172
170
|
export const walk = (
|
|
173
171
|
root: string,
|
|
@@ -175,11 +173,12 @@ export const walk = (
|
|
|
175
173
|
): Stream.Stream<FileEntry, DiscoveryError> => {
|
|
176
174
|
const absRoot = resolve(root)
|
|
177
175
|
const show = new Set<ShowCategory>(options.show ?? [])
|
|
176
|
+
const extensions = new Set(BASE_EXTENSIONS)
|
|
177
|
+
for (const ext of options.extensions ?? []) extensions.add(normalizeExtension(ext))
|
|
178
178
|
const opts = {
|
|
179
179
|
showHidden: show.has("hidden"),
|
|
180
180
|
showGitignored: show.has("gitignored"),
|
|
181
|
-
|
|
182
|
-
mdx: options.mdx ?? true,
|
|
181
|
+
extensions,
|
|
183
182
|
}
|
|
184
183
|
const onWarning = options.onWarning ?? null
|
|
185
184
|
const controller = new AbortController()
|
package/src/index.tsx
CHANGED
|
@@ -13,7 +13,7 @@ import { Browser, type StartupFocus } from "./Browser.tsx"
|
|
|
13
13
|
import { parseArgv, usage } from "./cli/argv.ts"
|
|
14
14
|
import { defaultConfigPath, formatConfigError, loadConfig } from "./config/load.ts"
|
|
15
15
|
import { parseShowList, SHOW_CATEGORIES, type ShowCategory } from "./discovery/show.ts"
|
|
16
|
-
import { walk, type FileEntry
|
|
16
|
+
import { walk, type FileEntry } from "./discovery/walk.ts"
|
|
17
17
|
import { openInBrowser } from "./serve/openBrowser.ts"
|
|
18
18
|
import { startServer } from "./serve/server.ts"
|
|
19
19
|
import { setActiveTheme } from "./theme/colors.ts"
|
|
@@ -106,8 +106,7 @@ interface DiscoverShellProps {
|
|
|
106
106
|
* and the full vocabulary; the underlying categories remain
|
|
107
107
|
* independent everywhere else. */
|
|
108
108
|
readonly initialShow: readonly ShowCategory[]
|
|
109
|
-
readonly
|
|
110
|
-
readonly mdx: boolean
|
|
109
|
+
readonly extensions: readonly string[]
|
|
111
110
|
readonly maxWidth: number | null
|
|
112
111
|
readonly sidebarMode: SidebarMode
|
|
113
112
|
readonly startupFocus: StartupFocus
|
|
@@ -117,8 +116,7 @@ export const DiscoverShell = ({
|
|
|
117
116
|
target,
|
|
118
117
|
initialQuery,
|
|
119
118
|
initialShow,
|
|
120
|
-
|
|
121
|
-
mdx,
|
|
119
|
+
extensions,
|
|
122
120
|
maxWidth,
|
|
123
121
|
sidebarMode,
|
|
124
122
|
startupFocus,
|
|
@@ -147,8 +145,7 @@ export const DiscoverShell = ({
|
|
|
147
145
|
countRef.current = 0
|
|
148
146
|
const warnedProgram = walk(target, {
|
|
149
147
|
show,
|
|
150
|
-
|
|
151
|
-
mdx,
|
|
148
|
+
extensions,
|
|
152
149
|
onWarning: ({ path }) => {
|
|
153
150
|
const relativePath = relative(resolve(target), path)
|
|
154
151
|
setSkippedDirCount((prev) => prev + 1)
|
|
@@ -182,7 +179,7 @@ export const DiscoverShell = ({
|
|
|
182
179
|
return () => {
|
|
183
180
|
Effect.runFork(Fiber.interrupt(fiber))
|
|
184
181
|
}
|
|
185
|
-
}, [target, show,
|
|
182
|
+
}, [target, show, extensions])
|
|
186
183
|
|
|
187
184
|
const discoveryStatus =
|
|
188
185
|
scanError ??
|
|
@@ -271,9 +268,13 @@ if (import.meta.main) {
|
|
|
271
268
|
cli: {
|
|
272
269
|
theme: args.theme,
|
|
273
270
|
tone: args.tone,
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
271
|
+
extensions:
|
|
272
|
+
args.extensions === null
|
|
273
|
+
? null
|
|
274
|
+
: args.extensions
|
|
275
|
+
.split(",")
|
|
276
|
+
.map((s) => s.trim())
|
|
277
|
+
.filter(Boolean),
|
|
277
278
|
// `--show` replaces env/file when present (set semantics —
|
|
278
279
|
// no per-category merge across sources). `null` falls through.
|
|
279
280
|
show: cliShow,
|
|
@@ -287,7 +288,7 @@ if (import.meta.main) {
|
|
|
287
288
|
console.error(`house: ${formatConfigError(err)}`)
|
|
288
289
|
process.exit(2)
|
|
289
290
|
})
|
|
290
|
-
const { theme: themeId, tone,
|
|
291
|
+
const { theme: themeId, tone, extensions, show, focus: startupFocus, defaultRoot } = config
|
|
291
292
|
const themeDef = getThemeDefinition(themeId)
|
|
292
293
|
if (themeDef === undefined) {
|
|
293
294
|
// Unreachable: Config.schema validated themeId against themeDefinitions.
|
|
@@ -348,14 +349,6 @@ if (import.meta.main) {
|
|
|
348
349
|
process.on("SIGTERM", shutdown)
|
|
349
350
|
// Bun.serve keeps the event loop alive until stop().
|
|
350
351
|
} else {
|
|
351
|
-
let sort: SortOrder = "dirs-first"
|
|
352
|
-
if (args.sort !== null) {
|
|
353
|
-
if (args.sort !== "dirs-first" && args.sort !== "files-first") {
|
|
354
|
-
console.error(`house: --sort must be "dirs-first" or "files-first", got "${args.sort}"`)
|
|
355
|
-
process.exit(2)
|
|
356
|
-
}
|
|
357
|
-
sort = args.sort
|
|
358
|
-
}
|
|
359
352
|
let sidebarMode: SidebarMode = "auto"
|
|
360
353
|
if (args.sidebar !== null) {
|
|
361
354
|
if (args.sidebar !== "auto" && args.sidebar !== "on" && args.sidebar !== "off") {
|
|
@@ -379,8 +372,7 @@ if (import.meta.main) {
|
|
|
379
372
|
tone,
|
|
380
373
|
maxWidth,
|
|
381
374
|
show,
|
|
382
|
-
|
|
383
|
-
mdx,
|
|
375
|
+
extensions,
|
|
384
376
|
sidebarMode,
|
|
385
377
|
startupFocus,
|
|
386
378
|
updateCheck: !args.noUpdateCheck,
|
|
@@ -395,8 +387,7 @@ interface TuiBootOptions {
|
|
|
395
387
|
readonly tone: "dark" | "light"
|
|
396
388
|
readonly maxWidth: number | null
|
|
397
389
|
readonly show: readonly ShowCategory[]
|
|
398
|
-
readonly
|
|
399
|
-
readonly mdx: boolean
|
|
390
|
+
readonly extensions: readonly string[]
|
|
400
391
|
readonly sidebarMode: SidebarMode
|
|
401
392
|
readonly startupFocus: StartupFocus
|
|
402
393
|
/** Run the npm-registry probe and surface the "update available" notice.
|
|
@@ -411,8 +402,7 @@ async function runTui({
|
|
|
411
402
|
tone,
|
|
412
403
|
maxWidth,
|
|
413
404
|
show,
|
|
414
|
-
|
|
415
|
-
mdx,
|
|
405
|
+
extensions,
|
|
416
406
|
sidebarMode,
|
|
417
407
|
startupFocus,
|
|
418
408
|
updateCheck,
|
|
@@ -455,8 +445,7 @@ async function runTui({
|
|
|
455
445
|
target={discoveryRoot}
|
|
456
446
|
initialQuery={initialQuery}
|
|
457
447
|
initialShow={show}
|
|
458
|
-
|
|
459
|
-
mdx={mdx}
|
|
448
|
+
extensions={extensions}
|
|
460
449
|
maxWidth={maxWidth}
|
|
461
450
|
sidebarMode={sidebarMode}
|
|
462
451
|
startupFocus={startupFocus}
|
package/src/layout/sidebarRow.ts
CHANGED
|
@@ -10,20 +10,15 @@
|
|
|
10
10
|
* selected row); a future auto-scroll on the selected sidebar row can carry
|
|
11
11
|
* the same information without altering layout for the rest.
|
|
12
12
|
*
|
|
13
|
-
* Truncation policy
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* - When even the tail segment with `…/` overflows, drop the marker.
|
|
18
|
-
* - When the tail segment alone overflows, hard-truncate it from its head
|
|
19
|
-
* (leading `…`) as a last resort.
|
|
20
|
-
*
|
|
21
|
-
* Why head-elide: the immediate parent is the segment closest to the file
|
|
22
|
-
* and the most universally meaningful one when context shrinks.
|
|
13
|
+
* Truncation policy: the basename stays whole when possible; the parent path
|
|
14
|
+
* middle-truncates into `a/…/z`-style output once it exceeds the remaining
|
|
15
|
+
* width. This preserves both the start and the end of the path, which is
|
|
16
|
+
* usually what users need to disambiguate nested docs.
|
|
23
17
|
*/
|
|
24
18
|
|
|
25
|
-
|
|
26
|
-
|
|
19
|
+
import { middleTruncate } from "../ui/middleTruncate.ts"
|
|
20
|
+
|
|
21
|
+
export const SIDEBAR_ROW_SEPARATOR = " "
|
|
27
22
|
const MIN_PARENT_BUDGET = 3
|
|
28
23
|
|
|
29
24
|
export interface SidebarRowParts {
|
|
@@ -54,18 +49,7 @@ export const formatSidebarRow = (relativePath: string, totalWidth: number): Side
|
|
|
54
49
|
return row(basename, parentFull)
|
|
55
50
|
}
|
|
56
51
|
|
|
57
|
-
|
|
58
|
-
for (let k = segments.length - 1; k >= 1; k--) {
|
|
59
|
-
const candidate = ELISION_PREFIX + segments.slice(segments.length - k).join("/")
|
|
60
|
-
if (candidate.length <= remaining) return row(basename, candidate)
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Even one segment with the `…/` marker doesn't fit. Try without the marker.
|
|
64
|
-
const tail = segments[segments.length - 1]!
|
|
65
|
-
if (tail.length <= remaining) return row(basename, tail)
|
|
66
|
-
|
|
67
|
-
// Hard-chop the tail segment from its head as a last resort.
|
|
68
|
-
return row(basename, "…" + tail.slice(tail.length - remaining + 1))
|
|
52
|
+
return row(basename, middleTruncate(parentFull, remaining))
|
|
69
53
|
}
|
|
70
54
|
|
|
71
55
|
const row = (basename: string, parent: string): SidebarRowParts => ({
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Middle truncation for technical strings.
|
|
3
|
+
*
|
|
4
|
+
* Keeps the start and end of a string visible while compressing the middle
|
|
5
|
+
* to a single ellipsis glyph (`…`). Use for paths, IDs, branch names, and
|
|
6
|
+
* other values whose head and tail both matter.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface MiddleTruncateOptions {
|
|
10
|
+
readonly ellipsis?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const middleTruncate = (
|
|
14
|
+
value: string,
|
|
15
|
+
width: number,
|
|
16
|
+
options: MiddleTruncateOptions = {},
|
|
17
|
+
): string => {
|
|
18
|
+
const ellipsis = options.ellipsis ?? "…"
|
|
19
|
+
if (width <= 0) return ""
|
|
20
|
+
if (value.length <= width) return value
|
|
21
|
+
if (width <= ellipsis.length) return value.slice(0, width)
|
|
22
|
+
|
|
23
|
+
const available = width - ellipsis.length
|
|
24
|
+
const left = Math.ceil(available / 2)
|
|
25
|
+
const right = Math.floor(available / 2)
|
|
26
|
+
return value.slice(0, left) + ellipsis + value.slice(value.length - right)
|
|
27
|
+
}
|