@carlesandres/house 0.4.13 → 0.5.0

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.
Files changed (86) hide show
  1. package/CHANGELOG.md +32 -2
  2. package/README.md +10 -8
  3. package/dist/bin.js +92 -0
  4. package/dist/index.js +10005 -0
  5. package/package.json +15 -3
  6. package/src/Browser.tsx +0 -1210
  7. package/src/CommandPalette.tsx +0 -214
  8. package/src/Footer.tsx +0 -258
  9. package/src/Header.tsx +0 -71
  10. package/src/PromptRow.tsx +0 -51
  11. package/src/Spinner.tsx +0 -39
  12. package/src/StatusIndicator.tsx +0 -55
  13. package/src/StatusPopover.tsx +0 -166
  14. package/src/brand.ts +0 -7
  15. package/src/cli/argv.ts +0 -179
  16. package/src/commands/buildCommands.ts +0 -98
  17. package/src/commands/paletteOnlyCommands.ts +0 -27
  18. package/src/commands/score.ts +0 -78
  19. package/src/commands/types.ts +0 -26
  20. package/src/config/load.ts +0 -381
  21. package/src/config/save.ts +0 -95
  22. package/src/discovery/filter.ts +0 -148
  23. package/src/discovery/show.ts +0 -48
  24. package/src/discovery/walk.ts +0 -209
  25. package/src/index.tsx +0 -464
  26. package/src/io/clipboard.ts +0 -53
  27. package/src/io/editor.ts +0 -162
  28. package/src/io/readFile.ts +0 -14
  29. package/src/keymap/browser.ts +0 -335
  30. package/src/keymap/displayKey.ts +0 -17
  31. package/src/keymap/keymap.ts +0 -95
  32. package/src/layout/resolve.ts +0 -52
  33. package/src/layout/sidebarEmptyState.ts +0 -7
  34. package/src/layout/sidebarRow.ts +0 -69
  35. package/src/markdown/frontmatter.ts +0 -62
  36. package/src/serve/css.ts +0 -120
  37. package/src/serve/openBrowser.ts +0 -19
  38. package/src/serve/render.ts +0 -56
  39. package/src/serve/server.ts +0 -166
  40. package/src/theme/atom.ts +0 -23
  41. package/src/theme/colors.ts +0 -86
  42. package/src/theme/loader.ts +0 -110
  43. package/src/theme/registry.ts +0 -12
  44. package/src/theme/resolve.ts +0 -168
  45. package/src/theme/themes/aura.json +0 -58
  46. package/src/theme/themes/ayu.json +0 -69
  47. package/src/theme/themes/carbonfox.json +0 -201
  48. package/src/theme/themes/catppuccin-frappe.json +0 -186
  49. package/src/theme/themes/catppuccin-macchiato.json +0 -186
  50. package/src/theme/themes/catppuccin.json +0 -212
  51. package/src/theme/themes/cobalt2.json +0 -181
  52. package/src/theme/themes/cursor.json +0 -202
  53. package/src/theme/themes/dracula.json +0 -172
  54. package/src/theme/themes/everforest.json +0 -194
  55. package/src/theme/themes/flexoki.json +0 -190
  56. package/src/theme/themes/github.json +0 -186
  57. package/src/theme/themes/gruvbox.json +0 -195
  58. package/src/theme/themes/kanagawa.json +0 -180
  59. package/src/theme/themes/lucent-orng.json +0 -186
  60. package/src/theme/themes/material.json +0 -188
  61. package/src/theme/themes/matrix.json +0 -180
  62. package/src/theme/themes/mercury.json +0 -198
  63. package/src/theme/themes/monokai.json +0 -174
  64. package/src/theme/themes/nightowl.json +0 -174
  65. package/src/theme/themes/nord.json +0 -176
  66. package/src/theme/themes/one-dark.json +0 -184
  67. package/src/theme/themes/opencode.json +0 -198
  68. package/src/theme/themes/orng.json +0 -202
  69. package/src/theme/themes/osaka-jade.json +0 -193
  70. package/src/theme/themes/palenight.json +0 -175
  71. package/src/theme/themes/rosepine.json +0 -187
  72. package/src/theme/themes/solarized.json +0 -180
  73. package/src/theme/themes/synthwave84.json +0 -179
  74. package/src/theme/themes/tokyonight.json +0 -196
  75. package/src/theme/themes/vercel.json +0 -198
  76. package/src/theme/themes/vesper.json +0 -171
  77. package/src/theme/themes/zenburn.json +0 -176
  78. package/src/theme/types.ts +0 -115
  79. package/src/tips.ts +0 -88
  80. package/src/ui/middleTruncate.ts +0 -27
  81. package/src/update/cache.ts +0 -77
  82. package/src/update/check.ts +0 -165
  83. package/src/update/compare.ts +0 -41
  84. package/src/update/notice.ts +0 -29
  85. package/src/update/runtime.ts +0 -48
  86. package/src/update/useUpdateNotice.ts +0 -24
@@ -1,381 +0,0 @@
1
- /**
2
- * Layered configuration loader.
3
- *
4
- * Precedence (high to low): CLI args → env vars → user TOML file → built-in defaults.
5
- * Each source is wrapped as a `ConfigProvider` and composed via `orElse`,
6
- * which falls through per-key when the upstream source returns `undefined`.
7
- *
8
- * The schema (`Config.schema` + `Schema.Literals`) validates `theme` against
9
- * the registered theme ids and `tone` against `"dark" | "light"`. Validation
10
- * failures and TOML parse errors both surface as `ConfigError` from `loadConfig`.
11
- */
12
-
13
- import { homedir } from "node:os"
14
- import { join } from "node:path"
15
- import { Config, ConfigProvider, Effect, Schema } from "effect"
16
- import { parseShowList, SHOW_CATEGORIES, type ShowCategory } from "../discovery/show.ts"
17
- import { themeDefinitions } from "../theme/registry.ts"
18
-
19
- export interface HouseConfig {
20
- readonly theme: string
21
- readonly tone: "dark" | "light"
22
- readonly extensions: readonly string[]
23
- /** Reader wrap width used when wrapping is enabled. */
24
- readonly width: number
25
- /** Whether the reader starts with fixed-width wrapping enabled. */
26
- readonly wrap: boolean
27
- /** Default discovery-root strategy when no explicit `--root` flag is passed. */
28
- readonly defaultRoot: "cwd" | "git"
29
- /** Categories of normally-skipped entries to opt into. See
30
- * `src/discovery/show.ts` for the vocabulary. Empty array (the
31
- * default) yields the conservative discovery set. */
32
- readonly show: readonly ShowCategory[]
33
- /** Startup pane/input target. `filter` opens the sidebar filter prompt and
34
- * focuses it immediately. */
35
- readonly focus: "sidebar" | "reader" | "filter"
36
- }
37
-
38
- export interface CliOverrides {
39
- readonly theme: string | null
40
- readonly tone: string | null
41
- readonly extensions: readonly string[] | null
42
- /** When non-null, the parsed `--show` list completely replaces env/file
43
- * (no per-category merging — sets compose by replacement, like every
44
- * other CLI override here). `--show ""` sets the empty set. */
45
- readonly show: readonly ShowCategory[] | null
46
- readonly focus: "sidebar" | "reader" | "filter" | null
47
- readonly width: number | null
48
- readonly wrap: boolean | null
49
- }
50
-
51
- const DEFAULT_THEME = "opencode"
52
- const DEFAULT_TONE: "dark" | "light" = "dark"
53
- const DEFAULT_EXTENSIONS: readonly string[] = []
54
- const DEFAULT_ROOT: "cwd" | "git" = "cwd"
55
- const DEFAULT_SHOW = ""
56
- const DEFAULT_FOCUS: "sidebar" | "reader" | "filter" = "sidebar"
57
- const DEFAULT_WIDTH = 80
58
- const DEFAULT_WRAP = false
59
-
60
- const themeIds = themeDefinitions.map((t) => t.id)
61
-
62
- /**
63
- * Top-level keys the config file is allowed to set. Kept in sync by hand
64
- * with `schema` below — when adding a key, add it both places.
65
- * Used by `fileProvider` to warn about unrecognized keys (with a
66
- * did-you-mean hint when one is close) while still loading the rest.
67
- */
68
- const KNOWN_FILE_KEYS: ReadonlySet<string> = new Set([
69
- "theme",
70
- "tone",
71
- "extensions",
72
- "show",
73
- "focus",
74
- "width",
75
- "wrap",
76
- "defaultRoot",
77
- ])
78
-
79
- const schema = Config.all({
80
- theme: Config.schema(Schema.Literals(themeIds), "theme"),
81
- tone: Config.schema(Schema.Literals(["dark", "light"] as const), "tone"),
82
- defaultRoot: Config.schema(Schema.String, "defaultRoot"),
83
- // Comma-separated extension list. Empty string means no extra extensions.
84
- extensions: Config.schema(Schema.String, "extensions"),
85
- // `show` arrives as a comma-separated string from every provider
86
- // (`fileProvider` coerces TOML arrays via `String()`, which produces
87
- // `"hidden,gitignored"`). Token-level validation happens in `loadConfig`
88
- // so the error message can list valid categories at the field's path.
89
- show: Config.schema(Schema.String, "show"),
90
- focus: Config.schema(Schema.Literals(["sidebar", "reader", "filter"] as const), "focus"),
91
- width: Config.schema(Schema.String, "width"),
92
- wrap: Config.schema(Schema.String, "wrap"),
93
- })
94
-
95
- const defaultsProvider = (): ConfigProvider.ConfigProvider =>
96
- ConfigProvider.fromUnknown({
97
- theme: DEFAULT_THEME,
98
- tone: DEFAULT_TONE,
99
- defaultRoot: DEFAULT_ROOT,
100
- extensions: DEFAULT_EXTENSIONS.join(","),
101
- show: DEFAULT_SHOW,
102
- focus: DEFAULT_FOCUS,
103
- width: String(DEFAULT_WIDTH),
104
- wrap: String(DEFAULT_WRAP),
105
- })
106
-
107
- const sourceError = (message: string, cause?: unknown): ConfigProvider.SourceError =>
108
- new ConfigProvider.SourceError({ message, cause })
109
-
110
- const validateFileValue = (path: string, key: string, value: unknown): void => {
111
- if (key === "width") {
112
- if (!Number.isSafeInteger(value) || (value as number) <= 0) {
113
- throw sourceError(`invalid value for width in ${path}: expected a positive integer`)
114
- }
115
- }
116
- if (key === "wrap") {
117
- if (typeof value !== "boolean") {
118
- throw sourceError(`invalid value for wrap in ${path}: expected true or false`)
119
- }
120
- }
121
- }
122
-
123
- /**
124
- * Levenshtein edit distance, capped at `cap` for early exit.
125
- * Used only to suggest "did you mean X?" when a config key looks like a
126
- * typo of a known one. Tiny inputs (≤ ~20 chars), so the naive O(n·m)
127
- * fill is fine.
128
- */
129
- const editDistance = (a: string, b: string, cap: number): number => {
130
- if (Math.abs(a.length - b.length) > cap) return cap + 1
131
- const prev: number[] = Array.from({ length: b.length + 1 })
132
- const curr: number[] = Array.from({ length: b.length + 1 })
133
- for (let j = 0; j <= b.length; j++) prev[j] = j
134
- for (let i = 1; i <= a.length; i++) {
135
- curr[0] = i
136
- let rowMin = curr[0]!
137
- for (let j = 1; j <= b.length; j++) {
138
- const cost = a[i - 1] === b[j - 1] ? 0 : 1
139
- curr[j] = Math.min(prev[j]! + 1, curr[j - 1]! + 1, prev[j - 1]! + cost)
140
- if (curr[j]! < rowMin) rowMin = curr[j]!
141
- }
142
- if (rowMin > cap) return cap + 1
143
- for (let j = 0; j <= b.length; j++) prev[j] = curr[j]!
144
- }
145
- return prev[b.length]!
146
- }
147
-
148
- const suggestKey = (unknown: string, known: readonly string[]): string | null => {
149
- let best: { key: string; dist: number } | null = null
150
- for (const k of known) {
151
- const d = editDistance(unknown, k, 2)
152
- if (d <= 2 && (best === null || d < best.dist)) best = { key: k, dist: d }
153
- }
154
- return best?.key ?? null
155
- }
156
-
157
- const formatUnknownKeyWarning = (path: string, key: string, known: readonly string[]): string => {
158
- const suggestion = suggestKey(key, known)
159
- const hint = suggestion ? ` — did you mean "${suggestion}"?` : ""
160
- return `house: ignoring unknown key "${key}" in ${path}${hint}`
161
- }
162
-
163
- /**
164
- * Reads a TOML file at `path`. Missing file → `undefined` for every key
165
- * (per-key fallthrough). Malformed TOML → `SourceError` (hard fail
166
- * upstream). Unknown top-level keys are warned about via `onWarning` and
167
- * dropped — this preserves forward-compat with newer config schemas while
168
- * still flagging typos like `them = "..."`.
169
- */
170
- const fileProvider = (
171
- path: string,
172
- onWarning: (message: string) => void,
173
- ): ConfigProvider.ConfigProvider => {
174
- let cache: { data: Record<string, unknown> | null } | null = null
175
- const load = Effect.gen(function* () {
176
- if (cache !== null) return cache.data
177
- const file = Bun.file(path)
178
- const exists = yield* Effect.promise(() => file.exists())
179
- if (!exists) {
180
- cache = { data: null }
181
- return null
182
- }
183
- const text = yield* Effect.promise(() => file.text())
184
- const parsed = yield* Effect.try({
185
- try: () => Bun.TOML.parse(text) as Record<string, unknown>,
186
- catch: (cause) =>
187
- sourceError(
188
- `invalid TOML in ${path}: ${cause instanceof Error ? cause.message : String(cause)}`,
189
- cause,
190
- ),
191
- })
192
- const known = [...KNOWN_FILE_KEYS]
193
- const filtered: Record<string, unknown> = {}
194
- for (const [k, v] of Object.entries(parsed)) {
195
- if (KNOWN_FILE_KEYS.has(k)) {
196
- yield* Effect.try({
197
- try: () => validateFileValue(path, k, v),
198
- catch: (cause) =>
199
- cause instanceof ConfigProvider.SourceError
200
- ? cause
201
- : sourceError(`invalid value for ${k} in ${path}`, cause),
202
- })
203
- filtered[k] = v
204
- } else {
205
- onWarning(formatUnknownKeyWarning(path, k, known))
206
- }
207
- }
208
- cache = { data: filtered }
209
- return filtered
210
- })
211
- return ConfigProvider.make((path) =>
212
- Effect.gen(function* () {
213
- const data = yield* load
214
- if (data === null) return undefined
215
- if (path.length === 0) {
216
- return ConfigProvider.makeRecord(new Set(Object.keys(data)))
217
- }
218
- const head = path[0]
219
- if (typeof head !== "string") return undefined
220
- const value = data[head]
221
- if (value === undefined) return undefined
222
- if (typeof value === "string") return ConfigProvider.makeValue(value)
223
- // Numbers/booleans coerced to their string form so Schema.Literals matches.
224
- return ConfigProvider.makeValue(String(value))
225
- }),
226
- )
227
- }
228
-
229
- /**
230
- * Reads `HOUSE_THEME` / `HOUSE_TONE` directly into a `fromUnknown` provider.
231
- *
232
- * We don't use `fromEnv().pipe(nested("HOUSE"), constantCase)` here because
233
- * `ConfigProvider.orElse` composes providers via `.get(path)` (raw store
234
- * access), which bypasses `mapInput`/`prefix`. That means an env provider
235
- * built with `nested` + `constantCase` silently returns `undefined` once it
236
- * sits behind an `orElse`. Reading env vars eagerly sidesteps the issue.
237
- */
238
- const envProvider = (env: Record<string, string | undefined>): ConfigProvider.ConfigProvider => {
239
- const entries: Array<[string, string]> = []
240
- const theme = env["HOUSE_THEME"]
241
- const tone = env["HOUSE_TONE"]
242
- const defaultRoot = env["HOUSE_DEFAULT_ROOT"]
243
- const extensions = env["HOUSE_EXTENSIONS"]
244
- const show = env["HOUSE_SHOW"]
245
- const focus = env["HOUSE_FOCUS"]
246
- const width = env["HOUSE_WIDTH"]
247
- const wrap = env["HOUSE_WRAP"]
248
- if (theme !== undefined) entries.push(["theme", theme])
249
- if (tone !== undefined) entries.push(["tone", tone])
250
- if (defaultRoot !== undefined) entries.push(["defaultRoot", defaultRoot])
251
- if (extensions !== undefined) entries.push(["extensions", extensions])
252
- if (show !== undefined) entries.push(["show", show])
253
- if (focus !== undefined) entries.push(["focus", focus])
254
- if (width !== undefined) entries.push(["width", width])
255
- if (wrap !== undefined) entries.push(["wrap", wrap])
256
- return ConfigProvider.fromUnknown(Object.fromEntries(entries))
257
- }
258
-
259
- const cliProvider = (overrides: CliOverrides): ConfigProvider.ConfigProvider => {
260
- const entries: Array<[string, string]> = []
261
- if (overrides.theme !== null) entries.push(["theme", overrides.theme])
262
- if (overrides.tone !== null) entries.push(["tone", overrides.tone])
263
- if (overrides.extensions !== null) entries.push(["extensions", overrides.extensions.join(",")])
264
- if (overrides.show !== null) entries.push(["show", overrides.show.join(",")])
265
- if (overrides.focus !== null) entries.push(["focus", overrides.focus])
266
- if (overrides.width !== null) entries.push(["width", String(overrides.width)])
267
- if (overrides.wrap !== null) entries.push(["wrap", String(overrides.wrap)])
268
- return ConfigProvider.fromUnknown(Object.fromEntries(entries))
269
- }
270
-
271
- const parsePositiveInteger = (key: string, raw: string): Effect.Effect<number, Error> => {
272
- if (!/^\d+$/.test(raw)) {
273
- return Effect.fail(new Error(`${key}: expected a positive integer, got ${JSON.stringify(raw)}`))
274
- }
275
- const value = Number.parseInt(raw, 10)
276
- if (!Number.isSafeInteger(value) || value <= 0) {
277
- return Effect.fail(new Error(`${key}: expected a positive integer, got ${JSON.stringify(raw)}`))
278
- }
279
- return Effect.succeed(value)
280
- }
281
-
282
- const parseBoolean = (key: string, raw: string): Effect.Effect<boolean, Error> => {
283
- if (raw === "true") return Effect.succeed(true)
284
- if (raw === "false") return Effect.succeed(false)
285
- return Effect.fail(new Error(`${key}: expected true or false, got ${JSON.stringify(raw)}`))
286
- }
287
-
288
- export interface LoadOptions {
289
- readonly cli?: CliOverrides
290
- /** Override the TOML path (tests). Defaults to `$XDG_CONFIG_HOME/house/config.toml`. */
291
- readonly filePath?: string
292
- /** Override env (tests). Defaults to `process.env`. */
293
- readonly env?: Record<string, string>
294
- /** Sink for non-fatal warnings (unknown keys). Defaults to stderr. */
295
- readonly onWarning?: (message: string) => void
296
- }
297
-
298
- export const defaultConfigPath = (): string =>
299
- join(process.env["XDG_CONFIG_HOME"] ?? join(homedir(), ".config"), "house", "config.toml")
300
-
301
- /**
302
- * Renders a `ConfigError` (or any error) as a short single-line message
303
- * suitable for `console.error("house: " + ...)`. Strips Effect's
304
- * `ConfigError(SchemaError(...))` wrapping when present.
305
- */
306
- export const formatConfigError = (err: unknown): string => {
307
- if (err instanceof Config.ConfigError) {
308
- const cause = err.cause
309
- const raw = "message" in cause ? cause.message : String(cause)
310
- return raw
311
- .replace(/\s+at \[[^\]]+\]\s*$/, "")
312
- .replace(/\s+/g, " ")
313
- .trim()
314
- }
315
- if (err instanceof Error) return err.message
316
- return String(err)
317
- }
318
-
319
- export const loadConfig = (
320
- options: LoadOptions = {},
321
- ): Effect.Effect<HouseConfig, Config.ConfigError | Error> => {
322
- const cli = options.cli ?? {
323
- theme: null,
324
- tone: null,
325
- extensions: null,
326
- show: null,
327
- focus: null,
328
- width: null,
329
- wrap: null,
330
- }
331
- const onWarning = options.onWarning ?? ((msg) => process.stderr.write(`${msg}\n`))
332
- const provider = cliProvider(cli).pipe(
333
- ConfigProvider.orElse(envProvider(options.env ?? process.env)),
334
- ConfigProvider.orElse(fileProvider(options.filePath ?? defaultConfigPath(), onWarning)),
335
- ConfigProvider.orElse(defaultsProvider()),
336
- )
337
- return schema.parse(provider).pipe(
338
- Effect.flatMap((raw) =>
339
- Effect.gen(function* () {
340
- const defaultRoot =
341
- raw.defaultRoot === "cwd" || raw.defaultRoot === "git" ? raw.defaultRoot : DEFAULT_ROOT
342
- if (raw.defaultRoot !== defaultRoot) {
343
- onWarning(
344
- `house: ignoring invalid value ${JSON.stringify(raw.defaultRoot)} for defaultRoot in config/env; using "${DEFAULT_ROOT}"`,
345
- )
346
- }
347
- const parsed = parseShowList(raw.show)
348
- if (!parsed.ok) {
349
- // Effect's `Config.ConfigError` requires a `SchemaError` or
350
- // `SourceError` cause that we don't have a clean constructor
351
- // for here — surface as a plain Error and let the boot
352
- // layer's existing `formatConfigError` (which already handles
353
- // `instanceof Error`) render it.
354
- return yield* Effect.fail(
355
- new Error(
356
- `show: unknown category "${parsed.invalid.join('", "')}" (valid: ${SHOW_CATEGORIES.join(", ")})`,
357
- ),
358
- )
359
- }
360
- const width = yield* parsePositiveInteger("width", raw.width)
361
- const wrap = yield* parseBoolean("wrap", raw.wrap)
362
- return {
363
- theme: raw.theme,
364
- tone: raw.tone,
365
- defaultRoot,
366
- width,
367
- wrap,
368
- extensions:
369
- raw.extensions === ""
370
- ? []
371
- : raw.extensions
372
- .split(",")
373
- .map((s) => s.trim())
374
- .filter(Boolean),
375
- show: parsed.value,
376
- focus: raw.focus,
377
- }
378
- }),
379
- ),
380
- )
381
- }
@@ -1,95 +0,0 @@
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
- }
@@ -1,148 +0,0 @@
1
- /**
2
- * Fuzzy filter for the sidebar.
3
- *
4
- * Matching stays intentionally small and pure: case-insensitive subsequence on
5
- * the filename and full relative path. Ranking prefers what users usually mean
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
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.
26
- */
27
-
28
- import type { FileEntry } from "./walk.ts"
29
-
30
- export const fuzzyScore = (query: string, target: string): number | null => {
31
- if (query.length === 0) return 0
32
- const q = query.toLowerCase()
33
- const t = target.toLowerCase()
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
- }
66
- }
67
-
68
- return best
69
- }
70
-
71
- const splitPath = (relativePath: string): { fileName: string; depth: number } => {
72
- const slash = relativePath.lastIndexOf("/")
73
- return {
74
- fileName: slash >= 0 ? relativePath.slice(slash + 1) : relativePath,
75
- depth: slash >= 0 ? relativePath.split("/").length - 1 : 0,
76
- }
77
- }
78
-
79
- const fileStem = (fileName: string): string => {
80
- const dot = fileName.lastIndexOf(".")
81
- return dot > 0 ? fileName.slice(0, dot) : fileName
82
- }
83
-
84
- const rankFile = (query: string, file: FileEntry): number | null => {
85
- const pathScore = fuzzyScore(query, file.relativePath)
86
- if (pathScore === null) return null
87
-
88
- const { fileName, depth } = splitPath(file.relativePath)
89
- const q = query.toLowerCase()
90
- const name = fileName.toLowerCase()
91
- const stem = fileStem(fileName).toLowerCase()
92
- const nameScore = fuzzyScore(query, fileName) ?? 0
93
-
94
- let score = pathScore * 4
95
- score += nameScore * 150
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".
100
- if (name === q || stem === q) score += 5_000
101
- else if (name.startsWith(q) || stem.startsWith(q)) score += 2_000
102
- else if (name.includes(q)) score += 1_000
103
-
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
- }
119
-
120
- return score
121
- }
122
-
123
- /**
124
- * Filter and re-rank a file list by a query. Empty query returns the input
125
- * unchanged (preserves the discovery sort order). Non-empty query keeps
126
- * matches only, sorted by score desc; ties fall back to the input order so
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.
132
- */
133
- export const filterFiles = (files: readonly FileEntry[], query: string): readonly FileEntry[] => {
134
- if (query.length === 0) return files
135
- const scored: { file: FileEntry; score: number; depth: number; index: number }[] = []
136
- for (let i = 0; i < files.length; i++) {
137
- const file = files[i]!
138
- const score = rankFile(query, file)
139
- if (score === null) continue
140
- const depth = (file.relativePath.match(/\//g) || []).length
141
- scored.push({ file, score, depth, index: i })
142
- }
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)
147
- return scored.map((s) => s.file)
148
- }
@@ -1,48 +0,0 @@
1
- /**
2
- * Discovery visibility categories.
3
- *
4
- * Each name identifies a class of entries that the walker normally skips and
5
- * that the user can opt into showing. The set is intentionally an open vocab:
6
- * adding a future category (e.g. `"build-artifacts"`) means one entry here,
7
- * one branch in `walk`, and no churn in the CLI/config surface — `--show`,
8
- * `HOUSE_SHOW`, and TOML `show = [...]` all carry whatever names live in this
9
- * tuple.
10
- */
11
- export const SHOW_CATEGORIES = ["hidden", "gitignored"] as const
12
-
13
- export type ShowCategory = (typeof SHOW_CATEGORIES)[number]
14
-
15
- export const isShowCategory = (value: string): value is ShowCategory =>
16
- (SHOW_CATEGORIES as readonly string[]).includes(value)
17
-
18
- /**
19
- * Parse a comma-separated list of category names. Whitespace around tokens
20
- * is trimmed; empty tokens (trailing comma, double comma) are dropped.
21
- * Returns `{ ok, value }` on success or `{ ok: false, invalid }` listing
22
- * tokens that aren't known categories — callers shape the error message
23
- * for their surface (CLI vs config).
24
- */
25
- export type ParseShowResult =
26
- | { readonly ok: true; readonly value: readonly ShowCategory[] }
27
- | { readonly ok: false; readonly invalid: readonly string[] }
28
-
29
- export const parseShowList = (raw: string): ParseShowResult => {
30
- const tokens = raw
31
- .split(",")
32
- .map((t) => t.trim())
33
- .filter((t) => t.length > 0)
34
- const invalid = tokens.filter((t) => !isShowCategory(t))
35
- if (invalid.length > 0) return { ok: false, invalid }
36
- // De-dupe while preserving the configured order — callers that care
37
- // about presence use Set membership; emitting a unique sequence keeps
38
- // the round-trip (encode/decode) stable.
39
- const seen = new Set<ShowCategory>()
40
- const out: ShowCategory[] = []
41
- for (const t of tokens as ShowCategory[]) {
42
- if (!seen.has(t)) {
43
- seen.add(t)
44
- out.push(t)
45
- }
46
- }
47
- return { ok: true, value: out }
48
- }