@carlesandres/house 0.4.9 → 0.4.11
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 +21 -2
- package/README.md +12 -12
- package/package.json +1 -4
- package/src/Browser.tsx +20 -1
- package/src/cli/argv.ts +8 -8
- package/src/config/load.ts +18 -14
- package/src/config/save.ts +95 -0
- package/src/discovery/walk.ts +15 -8
- package/src/index.tsx +16 -12
- package/src/layout/sidebarRow.ts +7 -23
- package/src/ui/middleTruncate.ts +27 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,23 @@ The publish workflow (`.github/workflows/publish.yml`) runs on the `release: pub
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.4.11] — 2026-06-14
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Removed the package-level Node engine requirement so npm installs do not warn about `house` itself; Bun remains the supported runtime and installer path until standalone binaries land.
|
|
14
|
+
|
|
15
|
+
## [0.4.10] — 2026-06-14
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- Active theme selection is now persisted globally, with hardened config saving that preserves symlinked config files.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Sidebar parent paths now middle-truncate in deep trees to keep filenames scannable.
|
|
24
|
+
- Markdown discovery now uses an extension allow-list for supported markdown file types.
|
|
25
|
+
|
|
9
26
|
## [0.4.9] — 2026-06-09
|
|
10
27
|
|
|
11
28
|
### Fixed
|
|
@@ -259,7 +276,7 @@ The v1 MVP, published as `@carlesandres/openmdr` on npm.
|
|
|
259
276
|
|
|
260
277
|
### Added — discovery
|
|
261
278
|
|
|
262
|
-
- Recursive walk from the path argument (or cwd),
|
|
279
|
+
- Recursive walk from the path argument (or cwd), limited to markdown extensions.
|
|
263
280
|
- Honors `.gitignore` (root + nested).
|
|
264
281
|
- Hard-skips `node_modules`, `.git`, `.venv` (always, even with `--all`).
|
|
265
282
|
- Does not follow symlinks.
|
|
@@ -294,7 +311,9 @@ The v1 MVP, published as `@carlesandres/openmdr` on npm.
|
|
|
294
311
|
|
|
295
312
|
Search, stdin, URL fetching, cross-file link following, `$EDITOR` hand-off, syntax highlighting, persistent config, OS-appearance auto-detect, single-binary distribution (issue [#2](https://github.com/carlesandres/openmdr/issues/2)), Homebrew tap. All tracked.
|
|
296
313
|
|
|
297
|
-
[Unreleased]: https://github.com/carlesandres/house/compare/v0.4.
|
|
314
|
+
[Unreleased]: https://github.com/carlesandres/house/compare/v0.4.11...HEAD
|
|
315
|
+
[0.4.11]: https://github.com/carlesandres/house/compare/v0.4.10...v0.4.11
|
|
316
|
+
[0.4.10]: https://github.com/carlesandres/house/compare/v0.4.9...v0.4.10
|
|
298
317
|
[0.4.9]: https://github.com/carlesandres/house/compare/v0.4.8...v0.4.9
|
|
299
318
|
[0.4.8]: https://github.com/carlesandres/house/compare/v0.4.7...v0.4.8
|
|
300
319
|
[0.4.7]: https://github.com/carlesandres/house/compare/v0.4.6...v0.4.7
|
package/README.md
CHANGED
|
@@ -18,25 +18,25 @@ Requires [Bun](https://bun.sh) on `PATH`.
|
|
|
18
18
|
|
|
19
19
|
## Runtime requirements
|
|
20
20
|
|
|
21
|
-
- Bun: required to run `house
|
|
22
|
-
- npm install
|
|
21
|
+
- Bun: required to install and run `house` today.
|
|
22
|
+
- npm can install the package, but npm evaluates Node engine metadata from transitive dependencies during install. Use `bun add -g` for the supported no-Node install path.
|
|
23
23
|
|
|
24
24
|
Supported on macOS and Linux; Windows is unsupported and unvalidated (see [#129](https://github.com/carlesandres/house/issues/129)).
|
|
25
25
|
|
|
26
26
|
## Install
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
|
-
npm install -g @carlesandres/house
|
|
30
|
-
# or
|
|
31
29
|
bun add -g @carlesandres/house
|
|
30
|
+
# npm also works when your local Node/npm combination accepts the dependency graph:
|
|
31
|
+
npm install -g @carlesandres/house
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
## Upgrade
|
|
35
35
|
|
|
36
36
|
```bash
|
|
37
|
-
npm i -g @carlesandres/house
|
|
38
|
-
# or
|
|
39
37
|
bun add -g @carlesandres/house
|
|
38
|
+
# npm also works when your local Node/npm combination accepts the dependency graph:
|
|
39
|
+
npm i -g @carlesandres/house
|
|
40
40
|
```
|
|
41
41
|
|
|
42
42
|
## Usage
|
|
@@ -71,7 +71,7 @@ house --serve README.md
|
|
|
71
71
|
| `--focus <mode>` | `filter` | Startup focus: `sidebar`, `reader`, or `filter`. `filter` opens the sidebar filter prompt immediately. |
|
|
72
72
|
| `--serve` | off | Serve the positional path as HTML in the browser (skips TUI) |
|
|
73
73
|
| `--port <N>` | OS-assigned | Port for `--serve` |
|
|
74
|
-
| `--
|
|
74
|
+
| `--ext <list>` | none | Include extra file extensions (comma-separated) |
|
|
75
75
|
| `--no-update-check` | off | Suppress the "newer version available" check (also via `NO_UPDATE_NOTIFIER=1`) |
|
|
76
76
|
| `--config-path` | — | Print the resolved config-file path and exit |
|
|
77
77
|
| `-h`, `--help` | — | Show help and exit |
|
|
@@ -91,22 +91,22 @@ Run `house --config-path` to print the exact location.
|
|
|
91
91
|
# ~/.config/house/config.toml
|
|
92
92
|
theme = "tokyonight"
|
|
93
93
|
tone = "dark"
|
|
94
|
-
|
|
94
|
+
extensions = []
|
|
95
95
|
show = ["hidden", "gitignored"]
|
|
96
96
|
focus = "filter"
|
|
97
97
|
defaultRoot = "cwd" # or "git"
|
|
98
98
|
```
|
|
99
99
|
|
|
100
|
-
Supported keys: `theme`, `tone`, `
|
|
100
|
+
Supported keys: `theme`, `tone`, `extensions`, `show`, `focus`, `defaultRoot`.
|
|
101
101
|
|
|
102
102
|
`show` is a list of normally-skipped categories to opt into. Known categories: `hidden` (dot-prefixed entries), `gitignored` (entries matched by a `.gitignore`). Default is the empty list. Hard skips (`node_modules`, `.git`, `.venv`) always apply.
|
|
103
103
|
|
|
104
104
|
Precedence, highest to lowest:
|
|
105
105
|
|
|
106
|
-
1. CLI flags (`--theme`, `--tone`, `--
|
|
107
|
-
2. Env vars (`HOUSE_THEME`, `HOUSE_TONE`, `
|
|
106
|
+
1. CLI flags (`--theme`, `--tone`, `--ext`, `--show`, `--focus`, `--root`)
|
|
107
|
+
2. Env vars (`HOUSE_THEME`, `HOUSE_TONE`, `HOUSE_EXTENSIONS`, `HOUSE_SHOW`, `HOUSE_FOCUS`, `HOUSE_DEFAULT_ROOT`)
|
|
108
108
|
3. Config file
|
|
109
|
-
4. Built-in defaults (`opencode` / `dark` / `
|
|
109
|
+
4. Built-in defaults (`opencode` / `dark` / `extensions = []` / `show = []` / `focus = "filter"` / `defaultRoot = "cwd"`)
|
|
110
110
|
|
|
111
111
|
`HOUSE_SHOW` takes a comma-separated list (`HOUSE_SHOW=hidden,gitignored`). For `show` specifically, each source completely replaces the next — categories don't merge across layers. Press `shift+a` in the TUI to round-trip between the configured set and the full vocabulary without editing config.
|
|
112
112
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@carlesandres/house",
|
|
3
|
-
|
|
3
|
+
"version": "0.4.11",
|
|
4
4
|
"description": "TUI-first markdown reader on opentui",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -12,9 +12,6 @@
|
|
|
12
12
|
"url": "https://github.com/carlesandres/house/issues"
|
|
13
13
|
},
|
|
14
14
|
"homepage": "https://github.com/carlesandres/house#readme",
|
|
15
|
-
"engines": {
|
|
16
|
-
"node": ">=22.22.2"
|
|
17
|
-
},
|
|
18
15
|
"keywords": [
|
|
19
16
|
"markdown",
|
|
20
17
|
"reader",
|
package/src/Browser.tsx
CHANGED
|
@@ -45,6 +45,8 @@ import { startServer, type ServerHandle } from "./serve/server.ts"
|
|
|
45
45
|
import { colors, setActiveTheme } from "./theme/colors.ts"
|
|
46
46
|
import { themeAtom } from "./theme/atom.ts"
|
|
47
47
|
import { themeDefinitions, getThemeDefinition } from "./theme/registry.ts"
|
|
48
|
+
import { saveThemePreference } from "./config/save.ts"
|
|
49
|
+
import { middleTruncate } from "./ui/middleTruncate.ts"
|
|
48
50
|
|
|
49
51
|
export type SidebarMode = "auto" | "on" | "off"
|
|
50
52
|
export type StartupFocus = "sidebar" | "reader" | "filter"
|
|
@@ -362,6 +364,12 @@ export const Browser = ({
|
|
|
362
364
|
if (!next) return
|
|
363
365
|
setActiveTheme(next, theme.tone)
|
|
364
366
|
setTheme({ id: next.id, tone: theme.tone })
|
|
367
|
+
void saveThemePreference({ theme: next.id, tone: theme.tone }).catch((err) => {
|
|
368
|
+
pushFooterNotice("theme not saved")
|
|
369
|
+
process.stderr.write(
|
|
370
|
+
`house: failed to save theme preference: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
371
|
+
)
|
|
372
|
+
})
|
|
365
373
|
pushFooterNotice(`theme: ${next.name}`)
|
|
366
374
|
}
|
|
367
375
|
|
|
@@ -370,6 +378,12 @@ export const Browser = ({
|
|
|
370
378
|
const def = getThemeDefinition(theme.id)
|
|
371
379
|
if (def) setActiveTheme(def, nextTone)
|
|
372
380
|
setTheme({ id: theme.id, tone: nextTone })
|
|
381
|
+
void saveThemePreference({ theme: theme.id, tone: nextTone }).catch((err) => {
|
|
382
|
+
pushFooterNotice("theme not saved")
|
|
383
|
+
process.stderr.write(
|
|
384
|
+
`house: failed to save theme preference: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
385
|
+
)
|
|
386
|
+
})
|
|
373
387
|
pushFooterNotice(`tone: ${nextTone}`)
|
|
374
388
|
}
|
|
375
389
|
|
|
@@ -967,7 +981,12 @@ export const Browser = ({
|
|
|
967
981
|
<text key={file.path} wrapMode="none" style={rowStyle}>
|
|
968
982
|
<span style={{ fg: basenameFg }}>{basename}</span>
|
|
969
983
|
{parent !== "" && (
|
|
970
|
-
<span style={{ fg: colors.textMuted }}>
|
|
984
|
+
<span style={{ fg: colors.textMuted }}>
|
|
985
|
+
{middleTruncate(
|
|
986
|
+
`${separator}${parent}`,
|
|
987
|
+
Math.max(0, sidebarTextWidth - basename.length),
|
|
988
|
+
)}
|
|
989
|
+
</span>
|
|
971
990
|
)}
|
|
972
991
|
</text>
|
|
973
992
|
)
|
package/src/cli/argv.ts
CHANGED
|
@@ -28,8 +28,8 @@ export interface ParsedArgs {
|
|
|
28
28
|
* probe and the "update available" notice. Mirrors the
|
|
29
29
|
* `NO_UPDATE_NOTIFIER` env var so opt-out is reachable without env state. */
|
|
30
30
|
readonly noUpdateCheck: boolean
|
|
31
|
-
/**
|
|
32
|
-
readonly
|
|
31
|
+
/** Raw value of `--ext [list]`, or null if absent. Comma-separated list. */
|
|
32
|
+
readonly extensions: string | null
|
|
33
33
|
/** Value of `--focus <mode>` (`sidebar`, `reader`, `filter`), or null.
|
|
34
34
|
* Validated by the boot layer. */
|
|
35
35
|
readonly focus: string | null
|
|
@@ -54,7 +54,7 @@ const createProgram = () =>
|
|
|
54
54
|
.option("--config-path")
|
|
55
55
|
.option("--sidebar [mode]")
|
|
56
56
|
.option("--no-update-check")
|
|
57
|
-
.option("--
|
|
57
|
+
.option("--ext [list]")
|
|
58
58
|
.option("--focus [mode]")
|
|
59
59
|
.option("--show [list]")
|
|
60
60
|
.option("--root [dir]")
|
|
@@ -71,6 +71,7 @@ const VALUE_FLAGS: ReadonlySet<string> = new Set([
|
|
|
71
71
|
"--focus",
|
|
72
72
|
"--show",
|
|
73
73
|
"--root",
|
|
74
|
+
"--ext",
|
|
74
75
|
])
|
|
75
76
|
|
|
76
77
|
const REMOVED_VALUE_FLAGS: ReadonlySet<string> = new Set(["--sort"])
|
|
@@ -79,7 +80,6 @@ const BOOLEAN_FLAGS: ReadonlySet<string> = new Set([
|
|
|
79
80
|
"--serve",
|
|
80
81
|
"--config-path",
|
|
81
82
|
"--no-update-check",
|
|
82
|
-
"--no-mdx",
|
|
83
83
|
"--help",
|
|
84
84
|
"-h",
|
|
85
85
|
"--version",
|
|
@@ -128,7 +128,7 @@ export const parseArgv = (argv: readonly string[]): ParsedArgs => {
|
|
|
128
128
|
configPath: opts["configPath"] === true,
|
|
129
129
|
sidebar: stringOrNull(opts["sidebar"]),
|
|
130
130
|
noUpdateCheck: opts["noUpdateCheck"] === true,
|
|
131
|
-
|
|
131
|
+
extensions: stringOrNull(opts["ext"]),
|
|
132
132
|
show: stringOrNull(opts["show"]),
|
|
133
133
|
focus: stringOrNull(opts["focus"]),
|
|
134
134
|
}
|
|
@@ -157,7 +157,7 @@ options:
|
|
|
157
157
|
-v, --version print version and exit
|
|
158
158
|
--config-path print path to the config file and exit
|
|
159
159
|
--no-update-check suppress the "newer version available" check (also via NO_UPDATE_NOTIFIER=1)
|
|
160
|
-
|
|
160
|
+
--ext <list> include extra file extensions (comma-separated)
|
|
161
161
|
|
|
162
162
|
examples:
|
|
163
163
|
house README.md
|
|
@@ -166,6 +166,6 @@ examples:
|
|
|
166
166
|
|
|
167
167
|
configuration:
|
|
168
168
|
file: $XDG_CONFIG_HOME/house/config.toml (default ~/.config/house/config.toml)
|
|
169
|
-
|
|
170
|
-
|
|
169
|
+
keys: theme, tone, extensions, show, focus, defaultRoot
|
|
170
|
+
env: HOUSE_THEME, HOUSE_TONE, HOUSE_EXTENSIONS, HOUSE_SHOW, HOUSE_FOCUS, HOUSE_DEFAULT_ROOT
|
|
171
171
|
precedence (high → low): flags → env → file → defaults`
|
package/src/config/load.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { themeDefinitions } from "../theme/registry.ts"
|
|
|
19
19
|
export interface HouseConfig {
|
|
20
20
|
readonly theme: string
|
|
21
21
|
readonly tone: "dark" | "light"
|
|
22
|
-
readonly
|
|
22
|
+
readonly extensions: readonly string[]
|
|
23
23
|
/** Default discovery-root strategy when no explicit `--root` flag is passed. */
|
|
24
24
|
readonly defaultRoot: "cwd" | "git"
|
|
25
25
|
/** Categories of normally-skipped entries to opt into. See
|
|
@@ -34,7 +34,7 @@ export interface HouseConfig {
|
|
|
34
34
|
export interface CliOverrides {
|
|
35
35
|
readonly theme: string | null
|
|
36
36
|
readonly tone: string | null
|
|
37
|
-
readonly
|
|
37
|
+
readonly extensions: readonly string[] | null
|
|
38
38
|
/** When non-null, the parsed `--show` list completely replaces env/file
|
|
39
39
|
* (no per-category merging — sets compose by replacement, like every
|
|
40
40
|
* other CLI override here). `--show ""` sets the empty set. */
|
|
@@ -44,7 +44,7 @@ export interface CliOverrides {
|
|
|
44
44
|
|
|
45
45
|
const DEFAULT_THEME = "opencode"
|
|
46
46
|
const DEFAULT_TONE: "dark" | "light" = "dark"
|
|
47
|
-
const
|
|
47
|
+
const DEFAULT_EXTENSIONS: readonly string[] = []
|
|
48
48
|
const DEFAULT_ROOT: "cwd" | "git" = "cwd"
|
|
49
49
|
const DEFAULT_SHOW = ""
|
|
50
50
|
const DEFAULT_FOCUS: "sidebar" | "reader" | "filter" = "filter"
|
|
@@ -60,7 +60,7 @@ const themeIds = themeDefinitions.map((t) => t.id)
|
|
|
60
60
|
const KNOWN_FILE_KEYS: ReadonlySet<string> = new Set([
|
|
61
61
|
"theme",
|
|
62
62
|
"tone",
|
|
63
|
-
"
|
|
63
|
+
"extensions",
|
|
64
64
|
"show",
|
|
65
65
|
"focus",
|
|
66
66
|
"defaultRoot",
|
|
@@ -70,10 +70,8 @@ const schema = Config.all({
|
|
|
70
70
|
theme: Config.schema(Schema.Literals(themeIds), "theme"),
|
|
71
71
|
tone: Config.schema(Schema.Literals(["dark", "light"] as const), "tone"),
|
|
72
72
|
defaultRoot: Config.schema(Schema.String, "defaultRoot"),
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
// a real boolean in `loadConfig` below.
|
|
76
|
-
mdx: Config.schema(Schema.Literals(["true", "false"] as const), "mdx"),
|
|
73
|
+
// Comma-separated extension list. Empty string means no extra extensions.
|
|
74
|
+
extensions: Config.schema(Schema.String, "extensions"),
|
|
77
75
|
// `show` arrives as a comma-separated string from every provider
|
|
78
76
|
// (`fileProvider` coerces TOML arrays via `String()`, which produces
|
|
79
77
|
// `"hidden,gitignored"`). Token-level validation happens in `loadConfig`
|
|
@@ -87,7 +85,7 @@ const defaultsProvider = (): ConfigProvider.ConfigProvider =>
|
|
|
87
85
|
theme: DEFAULT_THEME,
|
|
88
86
|
tone: DEFAULT_TONE,
|
|
89
87
|
defaultRoot: DEFAULT_ROOT,
|
|
90
|
-
|
|
88
|
+
extensions: DEFAULT_EXTENSIONS.join(","),
|
|
91
89
|
show: DEFAULT_SHOW,
|
|
92
90
|
focus: DEFAULT_FOCUS,
|
|
93
91
|
})
|
|
@@ -205,13 +203,13 @@ const envProvider = (env: Record<string, string | undefined>): ConfigProvider.Co
|
|
|
205
203
|
const theme = env["HOUSE_THEME"]
|
|
206
204
|
const tone = env["HOUSE_TONE"]
|
|
207
205
|
const defaultRoot = env["HOUSE_DEFAULT_ROOT"]
|
|
208
|
-
const
|
|
206
|
+
const extensions = env["HOUSE_EXTENSIONS"]
|
|
209
207
|
const show = env["HOUSE_SHOW"]
|
|
210
208
|
const focus = env["HOUSE_FOCUS"]
|
|
211
209
|
if (theme !== undefined) entries.push(["theme", theme])
|
|
212
210
|
if (tone !== undefined) entries.push(["tone", tone])
|
|
213
211
|
if (defaultRoot !== undefined) entries.push(["defaultRoot", defaultRoot])
|
|
214
|
-
if (
|
|
212
|
+
if (extensions !== undefined) entries.push(["extensions", extensions])
|
|
215
213
|
if (show !== undefined) entries.push(["show", show])
|
|
216
214
|
if (focus !== undefined) entries.push(["focus", focus])
|
|
217
215
|
return ConfigProvider.fromUnknown(Object.fromEntries(entries))
|
|
@@ -221,7 +219,7 @@ const cliProvider = (overrides: CliOverrides): ConfigProvider.ConfigProvider =>
|
|
|
221
219
|
const entries: Array<[string, string]> = []
|
|
222
220
|
if (overrides.theme !== null) entries.push(["theme", overrides.theme])
|
|
223
221
|
if (overrides.tone !== null) entries.push(["tone", overrides.tone])
|
|
224
|
-
if (overrides.
|
|
222
|
+
if (overrides.extensions !== null) entries.push(["extensions", overrides.extensions.join(",")])
|
|
225
223
|
if (overrides.show !== null) entries.push(["show", overrides.show.join(",")])
|
|
226
224
|
if (overrides.focus !== null) entries.push(["focus", overrides.focus])
|
|
227
225
|
return ConfigProvider.fromUnknown(Object.fromEntries(entries))
|
|
@@ -264,7 +262,7 @@ export const loadConfig = (
|
|
|
264
262
|
const cli = options.cli ?? {
|
|
265
263
|
theme: null,
|
|
266
264
|
tone: null,
|
|
267
|
-
|
|
265
|
+
extensions: null,
|
|
268
266
|
show: null,
|
|
269
267
|
focus: null,
|
|
270
268
|
}
|
|
@@ -300,7 +298,13 @@ export const loadConfig = (
|
|
|
300
298
|
theme: raw.theme,
|
|
301
299
|
tone: raw.tone,
|
|
302
300
|
defaultRoot,
|
|
303
|
-
|
|
301
|
+
extensions:
|
|
302
|
+
raw.extensions === ""
|
|
303
|
+
? []
|
|
304
|
+
: raw.extensions
|
|
305
|
+
.split(",")
|
|
306
|
+
.map((s) => s.trim())
|
|
307
|
+
.filter(Boolean),
|
|
304
308
|
show: parsed.value,
|
|
305
309
|
focus: raw.focus,
|
|
306
310
|
})
|
|
@@ -0,0 +1,95 @@
|
|
|
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/walk.ts
CHANGED
|
@@ -24,8 +24,9 @@ export interface WalkOptions {
|
|
|
24
24
|
* entries. Order is irrelevant — semantics are set membership. Hard
|
|
25
25
|
* skips (`node_modules`, `.git`, `.venv`) always apply. */
|
|
26
26
|
readonly show?: Iterable<ShowCategory>
|
|
27
|
-
/**
|
|
28
|
-
|
|
27
|
+
/** Additional file extensions to include alongside `.md` / `.markdown`.
|
|
28
|
+
* Values may include or omit the leading dot. */
|
|
29
|
+
readonly extensions?: Iterable<string>
|
|
29
30
|
/** Non-fatal subtree read errors. Root-level failures still error the walk. */
|
|
30
31
|
readonly onWarning?: ((warning: DiscoveryWarning) => void) | null
|
|
31
32
|
}
|
|
@@ -35,10 +36,14 @@ export class DiscoveryError extends Data.TaggedError("DiscoveryError")<{
|
|
|
35
36
|
readonly cause: unknown
|
|
36
37
|
}> {}
|
|
37
38
|
|
|
38
|
-
const
|
|
39
|
-
const MARKDOWN_EXTENSIONS_NO_MDX = new Set([".md", ".markdown"])
|
|
39
|
+
const BASE_EXTENSIONS = new Set([".md", ".markdown"])
|
|
40
40
|
const HARD_SKIP_DIRS = new Set(["node_modules", ".git", ".venv"])
|
|
41
41
|
|
|
42
|
+
const normalizeExtension = (ext: string): string => {
|
|
43
|
+
const trimmed = ext.trim().toLowerCase()
|
|
44
|
+
return trimmed.startsWith(".") ? trimmed : `.${trimmed}`
|
|
45
|
+
}
|
|
46
|
+
|
|
42
47
|
interface IgnoreLevel {
|
|
43
48
|
readonly dir: string
|
|
44
49
|
readonly ig: Ignore
|
|
@@ -93,7 +98,7 @@ async function* walkDirGen(
|
|
|
93
98
|
dirPath: string,
|
|
94
99
|
rootPath: string,
|
|
95
100
|
parentLevels: readonly IgnoreLevel[],
|
|
96
|
-
opts: { showHidden: boolean; showGitignored: boolean;
|
|
101
|
+
opts: { showHidden: boolean; showGitignored: boolean; extensions: ReadonlySet<string> },
|
|
97
102
|
onWarning: ((warning: DiscoveryWarning) => void) | null,
|
|
98
103
|
signal: AbortSignal,
|
|
99
104
|
): AsyncGenerator<FileEntry, void, void> {
|
|
@@ -136,7 +141,7 @@ async function* walkDirGen(
|
|
|
136
141
|
|
|
137
142
|
if (!entry.isFile()) continue
|
|
138
143
|
if (!opts.showHidden && entry.name.startsWith(".")) continue
|
|
139
|
-
const allowed = opts.
|
|
144
|
+
const allowed = opts.extensions
|
|
140
145
|
if (!allowed.has(extname(entry.name).toLowerCase())) continue
|
|
141
146
|
if (!opts.showGitignored && isIgnored(entryPath, false, levels)) continue
|
|
142
147
|
|
|
@@ -155,7 +160,7 @@ async function* walkDirGen(
|
|
|
155
160
|
* at its next `signal.aborted` check.
|
|
156
161
|
*
|
|
157
162
|
* Rules (see DESIGN.md §6):
|
|
158
|
-
* - Extensions: `.md`, `.markdown`,
|
|
163
|
+
* - Extensions: `.md`, `.markdown`, plus configured extras.
|
|
159
164
|
* - Hard skips (always): `node_modules`, `.git`, `.venv`.
|
|
160
165
|
* - Hidden files/dirs (leading `.`) skipped unless `show` contains `"hidden"`.
|
|
161
166
|
* - `.gitignore` honored, including nested `.gitignore` files.
|
|
@@ -168,10 +173,12 @@ export const walk = (
|
|
|
168
173
|
): Stream.Stream<FileEntry, DiscoveryError> => {
|
|
169
174
|
const absRoot = resolve(root)
|
|
170
175
|
const show = new Set<ShowCategory>(options.show ?? [])
|
|
176
|
+
const extensions = new Set(BASE_EXTENSIONS)
|
|
177
|
+
for (const ext of options.extensions ?? []) extensions.add(normalizeExtension(ext))
|
|
171
178
|
const opts = {
|
|
172
179
|
showHidden: show.has("hidden"),
|
|
173
180
|
showGitignored: show.has("gitignored"),
|
|
174
|
-
|
|
181
|
+
extensions,
|
|
175
182
|
}
|
|
176
183
|
const onWarning = options.onWarning ?? null
|
|
177
184
|
const controller = new AbortController()
|
package/src/index.tsx
CHANGED
|
@@ -106,7 +106,7 @@ interface DiscoverShellProps {
|
|
|
106
106
|
* and the full vocabulary; the underlying categories remain
|
|
107
107
|
* independent everywhere else. */
|
|
108
108
|
readonly initialShow: readonly ShowCategory[]
|
|
109
|
-
readonly
|
|
109
|
+
readonly extensions: readonly string[]
|
|
110
110
|
readonly maxWidth: number | null
|
|
111
111
|
readonly sidebarMode: SidebarMode
|
|
112
112
|
readonly startupFocus: StartupFocus
|
|
@@ -116,7 +116,7 @@ export const DiscoverShell = ({
|
|
|
116
116
|
target,
|
|
117
117
|
initialQuery,
|
|
118
118
|
initialShow,
|
|
119
|
-
|
|
119
|
+
extensions,
|
|
120
120
|
maxWidth,
|
|
121
121
|
sidebarMode,
|
|
122
122
|
startupFocus,
|
|
@@ -145,7 +145,7 @@ export const DiscoverShell = ({
|
|
|
145
145
|
countRef.current = 0
|
|
146
146
|
const warnedProgram = walk(target, {
|
|
147
147
|
show,
|
|
148
|
-
|
|
148
|
+
extensions,
|
|
149
149
|
onWarning: ({ path }) => {
|
|
150
150
|
const relativePath = relative(resolve(target), path)
|
|
151
151
|
setSkippedDirCount((prev) => prev + 1)
|
|
@@ -179,7 +179,7 @@ export const DiscoverShell = ({
|
|
|
179
179
|
return () => {
|
|
180
180
|
Effect.runFork(Fiber.interrupt(fiber))
|
|
181
181
|
}
|
|
182
|
-
}, [target, show,
|
|
182
|
+
}, [target, show, extensions])
|
|
183
183
|
|
|
184
184
|
const discoveryStatus =
|
|
185
185
|
scanError ??
|
|
@@ -268,9 +268,13 @@ if (import.meta.main) {
|
|
|
268
268
|
cli: {
|
|
269
269
|
theme: args.theme,
|
|
270
270
|
tone: args.tone,
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
271
|
+
extensions:
|
|
272
|
+
args.extensions === null
|
|
273
|
+
? null
|
|
274
|
+
: args.extensions
|
|
275
|
+
.split(",")
|
|
276
|
+
.map((s) => s.trim())
|
|
277
|
+
.filter(Boolean),
|
|
274
278
|
// `--show` replaces env/file when present (set semantics —
|
|
275
279
|
// no per-category merge across sources). `null` falls through.
|
|
276
280
|
show: cliShow,
|
|
@@ -284,7 +288,7 @@ if (import.meta.main) {
|
|
|
284
288
|
console.error(`house: ${formatConfigError(err)}`)
|
|
285
289
|
process.exit(2)
|
|
286
290
|
})
|
|
287
|
-
const { theme: themeId, tone,
|
|
291
|
+
const { theme: themeId, tone, extensions, show, focus: startupFocus, defaultRoot } = config
|
|
288
292
|
const themeDef = getThemeDefinition(themeId)
|
|
289
293
|
if (themeDef === undefined) {
|
|
290
294
|
// Unreachable: Config.schema validated themeId against themeDefinitions.
|
|
@@ -368,7 +372,7 @@ if (import.meta.main) {
|
|
|
368
372
|
tone,
|
|
369
373
|
maxWidth,
|
|
370
374
|
show,
|
|
371
|
-
|
|
375
|
+
extensions,
|
|
372
376
|
sidebarMode,
|
|
373
377
|
startupFocus,
|
|
374
378
|
updateCheck: !args.noUpdateCheck,
|
|
@@ -383,7 +387,7 @@ interface TuiBootOptions {
|
|
|
383
387
|
readonly tone: "dark" | "light"
|
|
384
388
|
readonly maxWidth: number | null
|
|
385
389
|
readonly show: readonly ShowCategory[]
|
|
386
|
-
readonly
|
|
390
|
+
readonly extensions: readonly string[]
|
|
387
391
|
readonly sidebarMode: SidebarMode
|
|
388
392
|
readonly startupFocus: StartupFocus
|
|
389
393
|
/** Run the npm-registry probe and surface the "update available" notice.
|
|
@@ -398,7 +402,7 @@ async function runTui({
|
|
|
398
402
|
tone,
|
|
399
403
|
maxWidth,
|
|
400
404
|
show,
|
|
401
|
-
|
|
405
|
+
extensions,
|
|
402
406
|
sidebarMode,
|
|
403
407
|
startupFocus,
|
|
404
408
|
updateCheck,
|
|
@@ -441,7 +445,7 @@ async function runTui({
|
|
|
441
445
|
target={discoveryRoot}
|
|
442
446
|
initialQuery={initialQuery}
|
|
443
447
|
initialShow={show}
|
|
444
|
-
|
|
448
|
+
extensions={extensions}
|
|
445
449
|
maxWidth={maxWidth}
|
|
446
450
|
sidebarMode={sidebarMode}
|
|
447
451
|
startupFocus={startupFocus}
|
package/src/layout/sidebarRow.ts
CHANGED
|
@@ -10,20 +10,15 @@
|
|
|
10
10
|
* selected row); a future auto-scroll on the selected sidebar row can carry
|
|
11
11
|
* the same information without altering layout for the rest.
|
|
12
12
|
*
|
|
13
|
-
* Truncation policy
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* - When even the tail segment with `…/` overflows, drop the marker.
|
|
18
|
-
* - When the tail segment alone overflows, hard-truncate it from its head
|
|
19
|
-
* (leading `…`) as a last resort.
|
|
20
|
-
*
|
|
21
|
-
* Why head-elide: the immediate parent is the segment closest to the file
|
|
22
|
-
* and the most universally meaningful one when context shrinks.
|
|
13
|
+
* Truncation policy: the basename stays whole when possible; the parent path
|
|
14
|
+
* middle-truncates into `a/…/z`-style output once it exceeds the remaining
|
|
15
|
+
* width. This preserves both the start and the end of the path, which is
|
|
16
|
+
* usually what users need to disambiguate nested docs.
|
|
23
17
|
*/
|
|
24
18
|
|
|
19
|
+
import { middleTruncate } from "../ui/middleTruncate.ts"
|
|
20
|
+
|
|
25
21
|
export const SIDEBAR_ROW_SEPARATOR = " "
|
|
26
|
-
const ELISION_PREFIX = "…/"
|
|
27
22
|
const MIN_PARENT_BUDGET = 3
|
|
28
23
|
|
|
29
24
|
export interface SidebarRowParts {
|
|
@@ -54,18 +49,7 @@ export const formatSidebarRow = (relativePath: string, totalWidth: number): Side
|
|
|
54
49
|
return row(basename, parentFull)
|
|
55
50
|
}
|
|
56
51
|
|
|
57
|
-
|
|
58
|
-
for (let k = segments.length - 1; k >= 1; k--) {
|
|
59
|
-
const candidate = ELISION_PREFIX + segments.slice(segments.length - k).join("/")
|
|
60
|
-
if (candidate.length <= remaining) return row(basename, candidate)
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Even one segment with the `…/` marker doesn't fit. Try without the marker.
|
|
64
|
-
const tail = segments[segments.length - 1]!
|
|
65
|
-
if (tail.length <= remaining) return row(basename, tail)
|
|
66
|
-
|
|
67
|
-
// Hard-chop the tail segment from its head as a last resort.
|
|
68
|
-
return row(basename, "…" + tail.slice(tail.length - remaining + 1))
|
|
52
|
+
return row(basename, middleTruncate(parentFull, remaining))
|
|
69
53
|
}
|
|
70
54
|
|
|
71
55
|
const row = (basename: string, parent: string): SidebarRowParts => ({
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Middle truncation for technical strings.
|
|
3
|
+
*
|
|
4
|
+
* Keeps the start and end of a string visible while compressing the middle
|
|
5
|
+
* to a single ellipsis glyph (`…`). Use for paths, IDs, branch names, and
|
|
6
|
+
* other values whose head and tail both matter.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface MiddleTruncateOptions {
|
|
10
|
+
readonly ellipsis?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const middleTruncate = (
|
|
14
|
+
value: string,
|
|
15
|
+
width: number,
|
|
16
|
+
options: MiddleTruncateOptions = {},
|
|
17
|
+
): string => {
|
|
18
|
+
const ellipsis = options.ellipsis ?? "…"
|
|
19
|
+
if (width <= 0) return ""
|
|
20
|
+
if (value.length <= width) return value
|
|
21
|
+
if (width <= ellipsis.length) return value.slice(0, width)
|
|
22
|
+
|
|
23
|
+
const available = width - ellipsis.length
|
|
24
|
+
const left = Math.ceil(available / 2)
|
|
25
|
+
const right = Math.floor(available / 2)
|
|
26
|
+
return value.slice(0, left) + ellipsis + value.slice(value.length - right)
|
|
27
|
+
}
|