@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.
- package/CHANGELOG.md +32 -2
- package/README.md +10 -8
- package/dist/bin.js +92 -0
- package/dist/index.js +10005 -0
- package/package.json +15 -3
- package/src/Browser.tsx +0 -1210
- package/src/CommandPalette.tsx +0 -214
- package/src/Footer.tsx +0 -258
- package/src/Header.tsx +0 -71
- package/src/PromptRow.tsx +0 -51
- package/src/Spinner.tsx +0 -39
- package/src/StatusIndicator.tsx +0 -55
- package/src/StatusPopover.tsx +0 -166
- package/src/brand.ts +0 -7
- package/src/cli/argv.ts +0 -179
- package/src/commands/buildCommands.ts +0 -98
- package/src/commands/paletteOnlyCommands.ts +0 -27
- package/src/commands/score.ts +0 -78
- package/src/commands/types.ts +0 -26
- package/src/config/load.ts +0 -381
- package/src/config/save.ts +0 -95
- package/src/discovery/filter.ts +0 -148
- package/src/discovery/show.ts +0 -48
- package/src/discovery/walk.ts +0 -209
- package/src/index.tsx +0 -464
- package/src/io/clipboard.ts +0 -53
- package/src/io/editor.ts +0 -162
- package/src/io/readFile.ts +0 -14
- package/src/keymap/browser.ts +0 -335
- package/src/keymap/displayKey.ts +0 -17
- package/src/keymap/keymap.ts +0 -95
- package/src/layout/resolve.ts +0 -52
- package/src/layout/sidebarEmptyState.ts +0 -7
- package/src/layout/sidebarRow.ts +0 -69
- package/src/markdown/frontmatter.ts +0 -62
- package/src/serve/css.ts +0 -120
- package/src/serve/openBrowser.ts +0 -19
- package/src/serve/render.ts +0 -56
- package/src/serve/server.ts +0 -166
- package/src/theme/atom.ts +0 -23
- package/src/theme/colors.ts +0 -86
- package/src/theme/loader.ts +0 -110
- package/src/theme/registry.ts +0 -12
- package/src/theme/resolve.ts +0 -168
- package/src/theme/themes/aura.json +0 -58
- package/src/theme/themes/ayu.json +0 -69
- package/src/theme/themes/carbonfox.json +0 -201
- package/src/theme/themes/catppuccin-frappe.json +0 -186
- package/src/theme/themes/catppuccin-macchiato.json +0 -186
- package/src/theme/themes/catppuccin.json +0 -212
- package/src/theme/themes/cobalt2.json +0 -181
- package/src/theme/themes/cursor.json +0 -202
- package/src/theme/themes/dracula.json +0 -172
- package/src/theme/themes/everforest.json +0 -194
- package/src/theme/themes/flexoki.json +0 -190
- package/src/theme/themes/github.json +0 -186
- package/src/theme/themes/gruvbox.json +0 -195
- package/src/theme/themes/kanagawa.json +0 -180
- package/src/theme/themes/lucent-orng.json +0 -186
- package/src/theme/themes/material.json +0 -188
- package/src/theme/themes/matrix.json +0 -180
- package/src/theme/themes/mercury.json +0 -198
- package/src/theme/themes/monokai.json +0 -174
- package/src/theme/themes/nightowl.json +0 -174
- package/src/theme/themes/nord.json +0 -176
- package/src/theme/themes/one-dark.json +0 -184
- package/src/theme/themes/opencode.json +0 -198
- package/src/theme/themes/orng.json +0 -202
- package/src/theme/themes/osaka-jade.json +0 -193
- package/src/theme/themes/palenight.json +0 -175
- package/src/theme/themes/rosepine.json +0 -187
- package/src/theme/themes/solarized.json +0 -180
- package/src/theme/themes/synthwave84.json +0 -179
- package/src/theme/themes/tokyonight.json +0 -196
- package/src/theme/themes/vercel.json +0 -198
- package/src/theme/themes/vesper.json +0 -171
- package/src/theme/themes/zenburn.json +0 -176
- package/src/theme/types.ts +0 -115
- package/src/tips.ts +0 -88
- package/src/ui/middleTruncate.ts +0 -27
- package/src/update/cache.ts +0 -77
- package/src/update/check.ts +0 -165
- package/src/update/compare.ts +0 -41
- package/src/update/notice.ts +0 -29
- package/src/update/runtime.ts +0 -48
- package/src/update/useUpdateNotice.ts +0 -24
package/src/config/load.ts
DELETED
|
@@ -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
|
-
}
|
package/src/config/save.ts
DELETED
|
@@ -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
|
-
}
|
package/src/discovery/filter.ts
DELETED
|
@@ -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
|
-
}
|
package/src/discovery/show.ts
DELETED
|
@@ -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
|
-
}
|