@carlesandres/house 0.4.7 → 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.
@@ -2,9 +2,8 @@
2
2
  * Derive palette commands from `browserBindings` plus the annotation map.
3
3
  *
4
4
  * The annotation map is the *only* hand-written list keyed by binding id:
5
- * it carries (a) hide flags for bindings that are pure keystroke nav, and
6
- * (b) title rewrites for bindings whose `description` reads as a help-row
7
- * entry rather than a palette command. Every other binding is exposed
5
+ * it carries title rewrites and metadata for bindings whose raw
6
+ * `description` reads awkwardly as a palette command. Every enabled binding is exposed
8
7
  * verbatim — its `description` becomes the palette `title`, its first key
9
8
  * becomes the `shortcut`.
10
9
  *
@@ -30,49 +29,40 @@ interface Annotation {
30
29
  }
31
30
 
32
31
  /**
33
- * Pure-keystroke nav (j/k/space/b/[/]…) is intentionally hidden those
34
- * bindings have no command-shaped meaning. Reader prev/next file (`[`/`]`)
35
- * and sidebar `open` (Return/l) are the borderline cases from #70 Q6a;
36
- * hidden in v1, reconsider if users ask. Title rewrites convert
37
- * help-overlay phrasing ("Toggle sidebar visibility") into imperative
38
- * palette phrasing ("Toggle sidebar"). See #70 design log §6.
32
+ * Title rewrites convert keymap phrasing into imperative palette phrasing.
33
+ * With the help overlay removed (#139), the palette is the only in-app full
34
+ * action index, so all currently-enabled actions stay discoverable here.
39
35
  */
40
36
  const annotations: Record<string, Annotation> = {
41
37
  // --- Keep, with title rewrites where the binding description reads awkwardly as a command ---
42
38
  quit: { category: "App" },
43
39
  "focus.toggle": { title: "Toggle focus", category: "View" },
44
40
  "sidebar.toggle": { title: "Toggle sidebar", category: "View" },
45
- "help.toggle": { title: "Show help", category: "App" },
41
+ "sidebar.down": { category: "Navigation" },
42
+ "sidebar.up": { category: "Navigation" },
43
+ "sidebar.jumpDown": { category: "Navigation" },
44
+ "sidebar.jumpUp": { category: "Navigation" },
45
+ "sidebar.pageDown": { category: "Navigation" },
46
+ "sidebar.pageUp": { category: "Navigation" },
47
+ "sidebar.top": { category: "Navigation" },
48
+ "sidebar.bottom": { category: "Navigation" },
49
+ "sidebar.open": { title: "Open file", category: "Navigation" },
46
50
  "filter.open": { title: "Filter files…", category: "Navigation" },
51
+ "filter.clearOrOpen": { category: "Navigation" },
47
52
  "discovery.toggleAll": {
48
53
  title: "Toggle hidden / gitignored files",
49
54
  category: "Navigation",
50
55
  keywords: ["hidden", "gitignore", "dotfiles", "all"],
51
56
  },
57
+ "reader.back": { title: "Back to sidebar", category: "Navigation" },
58
+ "reader.prevFile": { title: "Previous file", category: "Navigation" },
59
+ "reader.nextFile": { title: "Next file", category: "Navigation" },
52
60
  "serve.current": { title: "Open in browser", category: "File" },
53
61
  "file.edit": { title: "Open in editor", category: "File", keywords: ["editor", "vim", "vscode"] },
54
62
  "theme.next": { category: "Appearance" },
55
63
  "theme.prev": { category: "Appearance" },
56
64
  "theme.toneToggle": { title: "Toggle dark/light tone", category: "Appearance" },
57
65
 
58
- // --- Hide: pure keystroke navigation (j/k/space/b/g/G…) ---
59
- "sidebar.down": { hidden: true },
60
- "sidebar.up": { hidden: true },
61
- "sidebar.jumpDown": { hidden: true },
62
- "sidebar.jumpUp": { hidden: true },
63
- "sidebar.pageDown": { hidden: true },
64
- "sidebar.pageUp": { hidden: true },
65
- "sidebar.top": { hidden: true },
66
- "sidebar.bottom": { hidden: true },
67
-
68
- // --- Hide: borderline reader nav. `[`/`]` and Return-to-open feel command-shaped
69
- // but are pure keystroke navigation under the hood. #70 Q6a — reconsider
70
- // if user feedback expects them in the palette.
71
- "sidebar.open": { hidden: true },
72
- "reader.back": { hidden: true },
73
- "reader.prevFile": { hidden: true },
74
- "reader.nextFile": { hidden: true },
75
-
76
66
  // --- Hide: the palette opener itself shouldn't appear in the palette ---
77
67
  "palette.open": { hidden: true },
78
68
  }
@@ -80,8 +70,8 @@ const annotations: Record<string, Annotation> = {
80
70
  /**
81
71
  * Build the AppCommand list for a given render. Iterates `browserBindings`
82
72
  * in array order (the empty-query palette renders in this order, by design
83
- * — see #70 design log §empty-state ordering), drops hidden entries and
84
- * those whose `when` predicate currently returns false, and resolves
73
+ * — see #70 design log §empty-state ordering), drops explicitly-hidden entries
74
+ * and those whose `when` predicate currently returns false, and resolves
85
75
  * annotations to populate title / category / keywords.
86
76
  */
87
77
  export const buildCommands = (ctx: BrowserCtx): readonly AppCommand[] => {
@@ -89,9 +79,8 @@ export const buildCommands = (ctx: BrowserCtx): readonly AppCommand[] => {
89
79
  for (const binding of browserBindings) {
90
80
  const ann = annotations[binding.id]
91
81
  if (ann?.hidden) continue
92
- // Same gating the keymap dispatcher uses. Disabled bindings get
93
- // hidden from the palette (per #70 Q5b see #96 for the show-with-
94
- // reason follow-up after the atom-driven migration).
82
+ // Same gating the keymap dispatcher uses. Disabled bindings stay out of
83
+ // the palette; #96 tracks a future show-with-reason mode.
95
84
  if (binding.when && !binding.when(ctx)) continue
96
85
  const cmd: AppCommand = {
97
86
  id: binding.id,
@@ -34,7 +34,7 @@ const fuzzyIncludes = (text: string, query: string): boolean => {
34
34
 
35
35
  const searchText = (command: AppCommand): string =>
36
36
  normalize(
37
- [command.title, command.category, command.shortcut, ...(command.keywords ?? [])]
37
+ [command.title, command.category, ...(command.keywords ?? [])]
38
38
  .filter((s): s is string => Boolean(s))
39
39
  .join(" "),
40
40
  )
@@ -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
  }
@@ -13,7 +13,10 @@ export interface FileEntry {
13
13
  readonly name: string
14
14
  }
15
15
 
16
- export type SortOrder = "dirs-first" | "files-first"
16
+ export interface DiscoveryWarning {
17
+ readonly path: string
18
+ readonly cause: unknown
19
+ }
17
20
 
18
21
  export interface WalkOptions {
19
22
  /** Categories of normally-skipped entries to opt into. Empty (the
@@ -21,10 +24,10 @@ export interface WalkOptions {
21
24
  * entries. Order is irrelevant — semantics are set membership. Hard
22
25
  * skips (`node_modules`, `.git`, `.venv`) always apply. */
23
26
  readonly show?: Iterable<ShowCategory>
24
- /** Group order within each directory. Default `dirs-first`. */
25
- readonly sort?: SortOrder
26
27
  /** Include `.mdx` files alongside `.md`/`.markdown`. Default `true`. */
27
28
  readonly mdx?: boolean
29
+ /** Non-fatal subtree read errors. Root-level failures still error the walk. */
30
+ readonly onWarning?: ((warning: DiscoveryWarning) => void) | null
28
31
  }
29
32
 
30
33
  export class DiscoveryError extends Data.TaggedError("DiscoveryError")<{
@@ -66,14 +69,12 @@ const tryLoadGitignore = async (dir: string): Promise<Ignore | null> => {
66
69
 
67
70
  const sortEntries = <T extends { name: string; isDirectory: () => boolean }>(
68
71
  entries: readonly T[],
69
- order: SortOrder,
70
72
  ): T[] =>
71
73
  [...entries].sort((a, b) => {
72
74
  const aDir = a.isDirectory()
73
75
  const bDir = b.isDirectory()
74
76
  if (aDir !== bDir) {
75
- if (order === "files-first") return aDir ? 1 : -1
76
- return aDir ? -1 : 1
77
+ return aDir ? 1 : -1
77
78
  }
78
79
  return a.name.localeCompare(b.name)
79
80
  })
@@ -81,7 +82,7 @@ const sortEntries = <T extends { name: string; isDirectory: () => boolean }>(
81
82
  /**
82
83
  * DFS generator. Yields each markdown FileEntry as it is discovered, before
83
84
  * descending further. Per-directory sort still happens before yielding so
84
- * arrival order within a directory matches the configured sort.
85
+ * arrival order is files-first and alphabetical within each group.
85
86
  *
86
87
  * Cancellation: `signal.aborted` is checked between syscalls. Node's
87
88
  * `readdir` doesn't accept an AbortSignal, so a single in-flight `readdir`
@@ -92,10 +93,12 @@ async function* walkDirGen(
92
93
  dirPath: string,
93
94
  rootPath: string,
94
95
  parentLevels: readonly IgnoreLevel[],
95
- opts: { showHidden: boolean; showGitignored: boolean; sort: SortOrder; mdx: boolean },
96
+ opts: { showHidden: boolean; showGitignored: boolean; mdx: boolean },
97
+ onWarning: ((warning: DiscoveryWarning) => void) | null,
96
98
  signal: AbortSignal,
97
99
  ): AsyncGenerator<FileEntry, void, void> {
98
100
  if (signal.aborted) return
101
+ const isRoot = dirPath === rootPath
99
102
 
100
103
  let levels = parentLevels
101
104
  if (!opts.showGitignored) {
@@ -104,10 +107,17 @@ async function* walkDirGen(
104
107
  if (ig) levels = [...parentLevels, { dir: dirPath, ig }]
105
108
  }
106
109
 
107
- const raw = await readdir(dirPath, { withFileTypes: true })
110
+ let raw
111
+ try {
112
+ raw = await readdir(dirPath, { withFileTypes: true })
113
+ } catch (error) {
114
+ if (isRoot) throw error
115
+ onWarning?.({ path: dirPath, cause: error })
116
+ return
117
+ }
108
118
  if (signal.aborted) return
109
119
 
110
- for (const entry of sortEntries(raw, opts.sort)) {
120
+ for (const entry of sortEntries(raw)) {
111
121
  if (signal.aborted) return
112
122
 
113
123
  // Never follow symlinks — cycle hazard, and a markdown reader doesn't
@@ -120,7 +130,7 @@ async function* walkDirGen(
120
130
  if (HARD_SKIP_DIRS.has(entry.name)) continue
121
131
  if (!opts.showHidden && entry.name.startsWith(".")) continue
122
132
  if (!opts.showGitignored && isIgnored(entryPath, true, levels)) continue
123
- yield* walkDirGen(entryPath, rootPath, levels, opts, signal)
133
+ yield* walkDirGen(entryPath, rootPath, levels, opts, onWarning, signal)
124
134
  continue
125
135
  }
126
136
 
@@ -150,8 +160,7 @@ async function* walkDirGen(
150
160
  * - Hidden files/dirs (leading `.`) skipped unless `show` contains `"hidden"`.
151
161
  * - `.gitignore` honored, including nested `.gitignore` files.
152
162
  * - Symlinks not followed.
153
- * - Sort: alphabetical within each group; directories before files
154
- * (`dirs-first`, default) or files before directories (`files-first`).
163
+ * - Sort: files before directories, alphabetical within each group.
155
164
  */
156
165
  export const walk = (
157
166
  root: string,
@@ -162,13 +171,13 @@ export const walk = (
162
171
  const opts = {
163
172
  showHidden: show.has("hidden"),
164
173
  showGitignored: show.has("gitignored"),
165
- sort: options.sort ?? ("dirs-first" as SortOrder),
166
174
  mdx: options.mdx ?? true,
167
175
  }
176
+ const onWarning = options.onWarning ?? null
168
177
  const controller = new AbortController()
169
178
  const iterable: AsyncIterable<FileEntry> = {
170
179
  [Symbol.asyncIterator]() {
171
- const gen = walkDirGen(absRoot, absRoot, [], opts, controller.signal)
180
+ const gen = walkDirGen(absRoot, absRoot, [], opts, onWarning, controller.signal)
172
181
  return {
173
182
  next: () => gen.next(),
174
183
  return: async (value?: void) => {