@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 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 state, and demo tapes after the single Browser model landed.
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 a VHS-generated demo gif; `tape/` holds the source scripts.
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.8...HEAD
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
- ![house demo](tape/house.gif)
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 | 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
- | `--sort <mode>` | `dirs-first` | Sidebar order: `dirs-first` or `files-first` |
71
- | `--sidebar <mode>` | `auto` | Initial sidebar visibility: `auto`, `on`, or `off` |
72
- | `--focus <mode>` | `filter` | Startup focus: `sidebar`, `reader`, or `filter`. `filter` opens the sidebar filter prompt immediately. |
73
- | `--serve` | off | Serve the positional path as HTML in the browser (skips TUI) |
74
- | `--port <N>` | OS-assigned | Port for `--serve` |
75
- | `--no-mdx` | off | Exclude `.mdx` files from discovery |
76
- | `--no-update-check` | off | Suppress the "newer version available" check (also via `NO_UPDATE_NOTIFIER=1`) |
77
- | `--config-path` | — | Print the resolved config-file path and exit |
78
- | `-h`, `--help` | — | Show help and exit |
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 | Action |
121
- |-----|--------|
122
- | `q` / `ctrl+c` | Quit |
123
- | `tab` | Toggle focus (sidebar ↔ reader) |
124
- | `s` | Toggle sidebar visibility |
125
- | `ctrl+p` | Command palette |
126
- | `O` | Open current file in browser as HTML |
127
- | `E` | Open current file in `$EDITOR` (`$VISUAL` takes precedence) |
128
- | `t` | Next theme |
129
- | `T` | Previous theme |
130
- | `L` | Toggle dark / light tone |
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 | Action |
135
- |-----|--------|
136
- | `j` / `↓` | Move selection down |
137
- | `k` / `↑` | Move selection up |
138
- | `J` | Jump down 8 |
139
- | `K` | Jump up 8 |
140
- | `space` / `pagedown` / `ctrl+d` | Page down |
141
- | `b` / `pageup` / `ctrl+u` | Page up |
142
- | `g` | First file |
143
- | `G` | Last file |
144
- | `/` | Filter files (fuzzy match on path) |
145
- | `A` | Toggle hidden + gitignored entries (session-only; round-trips with the configured `show`) |
146
- | `↵` / `→` / `l` | Open file (focus reader) |
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 | Action |
151
- |-----|--------|
149
+ | Key | Action |
150
+ | ----------------- | --------------- |
152
151
  | `esc` / `←` / `h` | Back to sidebar |
153
- | `[` | Previous file |
154
- | `]` | Next file |
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.8",
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 assumes hint labels are ASCII plus a small set of single-cell
19
- * BMP glyphs (see `displayKey`). `fitHints` and notice clipping use string
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.length + 1 + h.label.length // key + " " + label
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)
@@ -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
- * - files in the current folder above equally good nested matches
9
- * - shallower paths above deeper ones as a soft tie-break
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
- let qi = 0
21
- let score = 0
22
- let lastMatch = -2
23
- for (let i = 0; i < t.length && qi < q.length; i++) {
24
- if (t[i] !== q[qi]) continue
25
- const isWordStart = i === 0 || t[i - 1] === "/"
26
- score += isWordStart ? 10 : 1
27
- if (lastMatch === i - 1) score += 5
28
- lastMatch = i
29
- qi++
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
- if (qi < q.length) return null
32
- return score
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 * 10
59
- score += nameScore * 100
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
- if (depth === 0) score += 300
66
- score -= depth * 10
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
- scored.push({ file, score, index: i })
140
+ const depth = (file.relativePath.match(/\//g) || []).length
141
+ scored.push({ file, score, depth, index: i })
85
142
  }
86
- scored.sort((a, b) => b.score - a.score || a.index - b.index)
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
  }
@@ -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
- if (order === "files-first") return aDir ? 1 : -1
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 within a directory matches the configured sort.
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; sort: SortOrder; mdx: 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, opts.sort)) {
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; directories before files
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, type SortOrder } from "./discovery/walk.ts"
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, sort, mdx])
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}
@@ -22,7 +22,7 @@
22
22
  * and the most universally meaningful one when context shrinks.
23
23
  */
24
24
 
25
- export const SIDEBAR_ROW_SEPARATOR = " · "
25
+ export const SIDEBAR_ROW_SEPARATOR = " "
26
26
  const ELISION_PREFIX = "…/"
27
27
  const MIN_PARENT_BUDGET = 3
28
28