@carlesandres/house 0.4.8 → 0.4.9
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 +18 -3
- package/README.md +45 -46
- package/package.json +2 -1
- package/src/Footer.tsx +4 -5
- package/src/cli/argv.ts +3 -7
- package/src/discovery/filter.ts +83 -23
- package/src/discovery/walk.ts +5 -13
- package/src/index.tsx +2 -17
- package/src/layout/sidebarRow.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,20 @@ The publish workflow (`.github/workflows/publish.yml`) runs on the `release: pub
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.4.9] — 2026-06-09
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Sidebar rows now keep a tighter single-space filename/path gap, and footer hint width calculation better handles wide glyphs.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- 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.
|
|
18
|
+
|
|
19
|
+
### Docs
|
|
20
|
+
|
|
21
|
+
- Demo recording now lives in `recordings/record-demo.sh`, with docs and recorded assets updated to match the new capture flow.
|
|
22
|
+
|
|
9
23
|
## [0.4.8] — 2026-06-02
|
|
10
24
|
|
|
11
25
|
### Fixed
|
|
@@ -18,7 +32,7 @@ The publish workflow (`.github/workflows/publish.yml`) runs on the `release: pub
|
|
|
18
32
|
### Docs
|
|
19
33
|
|
|
20
34
|
- 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
|
|
35
|
+
- Refreshed unified-browser docs, pruned shipped roadmap items, and updated demo tapes after the single Browser model landed.
|
|
22
36
|
- Added ADR 0001 documenting the decision to keep the current `marked`-based browser preview instead of adopting Streamdown for now.
|
|
23
37
|
|
|
24
38
|
### Changed
|
|
@@ -184,7 +198,7 @@ Beta release gates (DESIGN §10.2) closed.
|
|
|
184
198
|
|
|
185
199
|
### Docs
|
|
186
200
|
|
|
187
|
-
- README embeds
|
|
201
|
+
- README embeds the recorded demo asset; `recordings/` holds the capture workflow.
|
|
188
202
|
- `CONTRIBUTING.md` documents the `captureSpans()` / `MockTreeSitterClient` / `TestRecorder` testing patterns and the "before blaming `<markdown>`" stale-watcher debugging checklist.
|
|
189
203
|
|
|
190
204
|
## [0.3.0] — 2026-05-16
|
|
@@ -280,7 +294,8 @@ The v1 MVP, published as `@carlesandres/openmdr` on npm.
|
|
|
280
294
|
|
|
281
295
|
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
296
|
|
|
283
|
-
[Unreleased]: https://github.com/carlesandres/house/compare/v0.4.
|
|
297
|
+
[Unreleased]: https://github.com/carlesandres/house/compare/v0.4.9...HEAD
|
|
298
|
+
[0.4.9]: https://github.com/carlesandres/house/compare/v0.4.8...v0.4.9
|
|
284
299
|
[0.4.8]: https://github.com/carlesandres/house/compare/v0.4.7...v0.4.8
|
|
285
300
|
[0.4.7]: https://github.com/carlesandres/house/compare/v0.4.6...v0.4.7
|
|
286
301
|
[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
|
+
| `--no-mdx` | off | Exclude `.mdx` files from discovery |
|
|
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
|
|
|
@@ -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
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.9",
|
|
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/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. */
|
|
@@ -51,7 +49,6 @@ 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")
|
|
@@ -69,7 +66,6 @@ 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",
|
|
@@ -77,6 +73,8 @@ const VALUE_FLAGS: ReadonlySet<string> = new Set([
|
|
|
77
73
|
"--root",
|
|
78
74
|
])
|
|
79
75
|
|
|
76
|
+
const REMOVED_VALUE_FLAGS: ReadonlySet<string> = new Set(["--sort"])
|
|
77
|
+
|
|
80
78
|
const BOOLEAN_FLAGS: ReadonlySet<string> = new Set([
|
|
81
79
|
"--serve",
|
|
82
80
|
"--config-path",
|
|
@@ -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,
|
|
@@ -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)
|
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,16 +18,12 @@ 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
|
-
/** Group order within each directory. Default `dirs-first`. */
|
|
30
|
-
readonly sort?: SortOrder
|
|
31
27
|
/** Include `.mdx` files alongside `.md`/`.markdown`. Default `true`. */
|
|
32
28
|
readonly mdx?: boolean
|
|
33
29
|
/** Non-fatal subtree read errors. Root-level failures still error the walk. */
|
|
@@ -73,14 +69,12 @@ const tryLoadGitignore = async (dir: string): Promise<Ignore | null> => {
|
|
|
73
69
|
|
|
74
70
|
const sortEntries = <T extends { name: string; isDirectory: () => boolean }>(
|
|
75
71
|
entries: readonly T[],
|
|
76
|
-
order: SortOrder,
|
|
77
72
|
): T[] =>
|
|
78
73
|
[...entries].sort((a, b) => {
|
|
79
74
|
const aDir = a.isDirectory()
|
|
80
75
|
const bDir = b.isDirectory()
|
|
81
76
|
if (aDir !== bDir) {
|
|
82
|
-
|
|
83
|
-
return aDir ? -1 : 1
|
|
77
|
+
return aDir ? 1 : -1
|
|
84
78
|
}
|
|
85
79
|
return a.name.localeCompare(b.name)
|
|
86
80
|
})
|
|
@@ -88,7 +82,7 @@ const sortEntries = <T extends { name: string; isDirectory: () => boolean }>(
|
|
|
88
82
|
/**
|
|
89
83
|
* DFS generator. Yields each markdown FileEntry as it is discovered, before
|
|
90
84
|
* descending further. Per-directory sort still happens before yielding so
|
|
91
|
-
* arrival order
|
|
85
|
+
* arrival order is files-first and alphabetical within each group.
|
|
92
86
|
*
|
|
93
87
|
* Cancellation: `signal.aborted` is checked between syscalls. Node's
|
|
94
88
|
* `readdir` doesn't accept an AbortSignal, so a single in-flight `readdir`
|
|
@@ -99,7 +93,7 @@ async function* walkDirGen(
|
|
|
99
93
|
dirPath: string,
|
|
100
94
|
rootPath: string,
|
|
101
95
|
parentLevels: readonly IgnoreLevel[],
|
|
102
|
-
opts: { showHidden: boolean; showGitignored: boolean;
|
|
96
|
+
opts: { showHidden: boolean; showGitignored: boolean; mdx: boolean },
|
|
103
97
|
onWarning: ((warning: DiscoveryWarning) => void) | null,
|
|
104
98
|
signal: AbortSignal,
|
|
105
99
|
): AsyncGenerator<FileEntry, void, void> {
|
|
@@ -123,7 +117,7 @@ async function* walkDirGen(
|
|
|
123
117
|
}
|
|
124
118
|
if (signal.aborted) return
|
|
125
119
|
|
|
126
|
-
for (const entry of sortEntries(raw
|
|
120
|
+
for (const entry of sortEntries(raw)) {
|
|
127
121
|
if (signal.aborted) return
|
|
128
122
|
|
|
129
123
|
// Never follow symlinks — cycle hazard, and a markdown reader doesn't
|
|
@@ -166,8 +160,7 @@ async function* walkDirGen(
|
|
|
166
160
|
* - Hidden files/dirs (leading `.`) skipped unless `show` contains `"hidden"`.
|
|
167
161
|
* - `.gitignore` honored, including nested `.gitignore` files.
|
|
168
162
|
* - Symlinks not followed.
|
|
169
|
-
* - Sort: alphabetical within each group
|
|
170
|
-
* (`dirs-first`, default) or files before directories (`files-first`).
|
|
163
|
+
* - Sort: files before directories, alphabetical within each group.
|
|
171
164
|
*/
|
|
172
165
|
export const walk = (
|
|
173
166
|
root: string,
|
|
@@ -178,7 +171,6 @@ export const walk = (
|
|
|
178
171
|
const opts = {
|
|
179
172
|
showHidden: show.has("hidden"),
|
|
180
173
|
showGitignored: show.has("gitignored"),
|
|
181
|
-
sort: options.sort ?? ("dirs-first" as SortOrder),
|
|
182
174
|
mdx: options.mdx ?? true,
|
|
183
175
|
}
|
|
184
176
|
const onWarning = options.onWarning ?? null
|
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,7 +106,6 @@ 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 sort: SortOrder
|
|
110
109
|
readonly mdx: boolean
|
|
111
110
|
readonly maxWidth: number | null
|
|
112
111
|
readonly sidebarMode: SidebarMode
|
|
@@ -117,7 +116,6 @@ export const DiscoverShell = ({
|
|
|
117
116
|
target,
|
|
118
117
|
initialQuery,
|
|
119
118
|
initialShow,
|
|
120
|
-
sort,
|
|
121
119
|
mdx,
|
|
122
120
|
maxWidth,
|
|
123
121
|
sidebarMode,
|
|
@@ -147,7 +145,6 @@ export const DiscoverShell = ({
|
|
|
147
145
|
countRef.current = 0
|
|
148
146
|
const warnedProgram = walk(target, {
|
|
149
147
|
show,
|
|
150
|
-
sort,
|
|
151
148
|
mdx,
|
|
152
149
|
onWarning: ({ path }) => {
|
|
153
150
|
const relativePath = relative(resolve(target), path)
|
|
@@ -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, mdx])
|
|
186
183
|
|
|
187
184
|
const discoveryStatus =
|
|
188
185
|
scanError ??
|
|
@@ -348,14 +345,6 @@ if (import.meta.main) {
|
|
|
348
345
|
process.on("SIGTERM", shutdown)
|
|
349
346
|
// Bun.serve keeps the event loop alive until stop().
|
|
350
347
|
} 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
348
|
let sidebarMode: SidebarMode = "auto"
|
|
360
349
|
if (args.sidebar !== null) {
|
|
361
350
|
if (args.sidebar !== "auto" && args.sidebar !== "on" && args.sidebar !== "off") {
|
|
@@ -379,7 +368,6 @@ if (import.meta.main) {
|
|
|
379
368
|
tone,
|
|
380
369
|
maxWidth,
|
|
381
370
|
show,
|
|
382
|
-
sort,
|
|
383
371
|
mdx,
|
|
384
372
|
sidebarMode,
|
|
385
373
|
startupFocus,
|
|
@@ -395,7 +383,6 @@ interface TuiBootOptions {
|
|
|
395
383
|
readonly tone: "dark" | "light"
|
|
396
384
|
readonly maxWidth: number | null
|
|
397
385
|
readonly show: readonly ShowCategory[]
|
|
398
|
-
readonly sort: SortOrder
|
|
399
386
|
readonly mdx: boolean
|
|
400
387
|
readonly sidebarMode: SidebarMode
|
|
401
388
|
readonly startupFocus: StartupFocus
|
|
@@ -411,7 +398,6 @@ async function runTui({
|
|
|
411
398
|
tone,
|
|
412
399
|
maxWidth,
|
|
413
400
|
show,
|
|
414
|
-
sort,
|
|
415
401
|
mdx,
|
|
416
402
|
sidebarMode,
|
|
417
403
|
startupFocus,
|
|
@@ -455,7 +441,6 @@ async function runTui({
|
|
|
455
441
|
target={discoveryRoot}
|
|
456
442
|
initialQuery={initialQuery}
|
|
457
443
|
initialShow={show}
|
|
458
|
-
sort={sort}
|
|
459
444
|
mdx={mdx}
|
|
460
445
|
maxWidth={maxWidth}
|
|
461
446
|
sidebarMode={sidebarMode}
|
package/src/layout/sidebarRow.ts
CHANGED