@carlesandres/house 0.4.6 → 0.4.8

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
  )
@@ -20,6 +20,8 @@ export interface HouseConfig {
20
20
  readonly theme: string
21
21
  readonly tone: "dark" | "light"
22
22
  readonly mdx: boolean
23
+ /** Default discovery-root strategy when no explicit `--root` flag is passed. */
24
+ readonly defaultRoot: "cwd" | "git"
23
25
  /** Categories of normally-skipped entries to opt into. See
24
26
  * `src/discovery/show.ts` for the vocabulary. Empty array (the
25
27
  * default) yields the conservative discovery set. */
@@ -43,6 +45,7 @@ export interface CliOverrides {
43
45
  const DEFAULT_THEME = "opencode"
44
46
  const DEFAULT_TONE: "dark" | "light" = "dark"
45
47
  const DEFAULT_MDX = true
48
+ const DEFAULT_ROOT: "cwd" | "git" = "cwd"
46
49
  const DEFAULT_SHOW = ""
47
50
  const DEFAULT_FOCUS: "sidebar" | "reader" | "filter" = "filter"
48
51
 
@@ -54,11 +57,19 @@ const themeIds = themeDefinitions.map((t) => t.id)
54
57
  * Used by `fileProvider` to warn about unrecognized keys (with a
55
58
  * did-you-mean hint when one is close) while still loading the rest.
56
59
  */
57
- const KNOWN_FILE_KEYS: ReadonlySet<string> = new Set(["theme", "tone", "mdx", "show", "focus"])
60
+ const KNOWN_FILE_KEYS: ReadonlySet<string> = new Set([
61
+ "theme",
62
+ "tone",
63
+ "mdx",
64
+ "show",
65
+ "focus",
66
+ "defaultRoot",
67
+ ])
58
68
 
59
69
  const schema = Config.all({
60
70
  theme: Config.schema(Schema.Literals(themeIds), "theme"),
61
71
  tone: Config.schema(Schema.Literals(["dark", "light"] as const), "tone"),
72
+ defaultRoot: Config.schema(Schema.String, "defaultRoot"),
62
73
  // Boolean stored as string literal because providers stringify values
63
74
  // (TOML bools, env vars, CLI flags all flow through as text). Mapped to
64
75
  // a real boolean in `loadConfig` below.
@@ -75,6 +86,7 @@ const defaultsProvider = (): ConfigProvider.ConfigProvider =>
75
86
  ConfigProvider.fromUnknown({
76
87
  theme: DEFAULT_THEME,
77
88
  tone: DEFAULT_TONE,
89
+ defaultRoot: DEFAULT_ROOT,
78
90
  mdx: String(DEFAULT_MDX),
79
91
  show: DEFAULT_SHOW,
80
92
  focus: DEFAULT_FOCUS,
@@ -192,11 +204,13 @@ const envProvider = (env: Record<string, string | undefined>): ConfigProvider.Co
192
204
  const entries: Array<[string, string]> = []
193
205
  const theme = env["HOUSE_THEME"]
194
206
  const tone = env["HOUSE_TONE"]
207
+ const defaultRoot = env["HOUSE_DEFAULT_ROOT"]
195
208
  const mdx = env["HOUSE_MDX"]
196
209
  const show = env["HOUSE_SHOW"]
197
210
  const focus = env["HOUSE_FOCUS"]
198
211
  if (theme !== undefined) entries.push(["theme", theme])
199
212
  if (tone !== undefined) entries.push(["tone", tone])
213
+ if (defaultRoot !== undefined) entries.push(["defaultRoot", defaultRoot])
200
214
  if (mdx !== undefined) entries.push(["mdx", mdx])
201
215
  if (show !== undefined) entries.push(["show", show])
202
216
  if (focus !== undefined) entries.push(["focus", focus])
@@ -262,6 +276,13 @@ export const loadConfig = (
262
276
  )
263
277
  return schema.parse(provider).pipe(
264
278
  Effect.flatMap((raw) => {
279
+ const defaultRoot =
280
+ raw.defaultRoot === "cwd" || raw.defaultRoot === "git" ? raw.defaultRoot : DEFAULT_ROOT
281
+ if (raw.defaultRoot !== defaultRoot) {
282
+ onWarning(
283
+ `house: ignoring invalid value ${JSON.stringify(raw.defaultRoot)} for defaultRoot in config/env; using "${DEFAULT_ROOT}"`,
284
+ )
285
+ }
265
286
  const parsed = parseShowList(raw.show)
266
287
  if (!parsed.ok) {
267
288
  // Effect's `Config.ConfigError` requires a `SchemaError` or
@@ -278,6 +299,7 @@ export const loadConfig = (
278
299
  return Effect.succeed({
279
300
  theme: raw.theme,
280
301
  tone: raw.tone,
302
+ defaultRoot,
281
303
  mdx: raw.mdx === "true",
282
304
  show: parsed.value,
283
305
  focus: raw.focus,
@@ -13,6 +13,11 @@ export interface FileEntry {
13
13
  readonly name: string
14
14
  }
15
15
 
16
+ export interface DiscoveryWarning {
17
+ readonly path: string
18
+ readonly cause: unknown
19
+ }
20
+
16
21
  export type SortOrder = "dirs-first" | "files-first"
17
22
 
18
23
  export interface WalkOptions {
@@ -25,6 +30,8 @@ export interface WalkOptions {
25
30
  readonly sort?: SortOrder
26
31
  /** Include `.mdx` files alongside `.md`/`.markdown`. Default `true`. */
27
32
  readonly mdx?: boolean
33
+ /** Non-fatal subtree read errors. Root-level failures still error the walk. */
34
+ readonly onWarning?: ((warning: DiscoveryWarning) => void) | null
28
35
  }
29
36
 
30
37
  export class DiscoveryError extends Data.TaggedError("DiscoveryError")<{
@@ -93,9 +100,11 @@ async function* walkDirGen(
93
100
  rootPath: string,
94
101
  parentLevels: readonly IgnoreLevel[],
95
102
  opts: { showHidden: boolean; showGitignored: boolean; sort: SortOrder; mdx: boolean },
103
+ onWarning: ((warning: DiscoveryWarning) => void) | null,
96
104
  signal: AbortSignal,
97
105
  ): AsyncGenerator<FileEntry, void, void> {
98
106
  if (signal.aborted) return
107
+ const isRoot = dirPath === rootPath
99
108
 
100
109
  let levels = parentLevels
101
110
  if (!opts.showGitignored) {
@@ -104,7 +113,14 @@ async function* walkDirGen(
104
113
  if (ig) levels = [...parentLevels, { dir: dirPath, ig }]
105
114
  }
106
115
 
107
- const raw = await readdir(dirPath, { withFileTypes: true })
116
+ let raw
117
+ try {
118
+ raw = await readdir(dirPath, { withFileTypes: true })
119
+ } catch (error) {
120
+ if (isRoot) throw error
121
+ onWarning?.({ path: dirPath, cause: error })
122
+ return
123
+ }
108
124
  if (signal.aborted) return
109
125
 
110
126
  for (const entry of sortEntries(raw, opts.sort)) {
@@ -120,7 +136,7 @@ async function* walkDirGen(
120
136
  if (HARD_SKIP_DIRS.has(entry.name)) continue
121
137
  if (!opts.showHidden && entry.name.startsWith(".")) continue
122
138
  if (!opts.showGitignored && isIgnored(entryPath, true, levels)) continue
123
- yield* walkDirGen(entryPath, rootPath, levels, opts, signal)
139
+ yield* walkDirGen(entryPath, rootPath, levels, opts, onWarning, signal)
124
140
  continue
125
141
  }
126
142
 
@@ -165,10 +181,11 @@ export const walk = (
165
181
  sort: options.sort ?? ("dirs-first" as SortOrder),
166
182
  mdx: options.mdx ?? true,
167
183
  }
184
+ const onWarning = options.onWarning ?? null
168
185
  const controller = new AbortController()
169
186
  const iterable: AsyncIterable<FileEntry> = {
170
187
  [Symbol.asyncIterator]() {
171
- const gen = walkDirGen(absRoot, absRoot, [], opts, controller.signal)
188
+ const gen = walkDirGen(absRoot, absRoot, [], opts, onWarning, controller.signal)
172
189
  return {
173
190
  next: () => gen.next(),
174
191
  return: async (value?: void) => {