@carlesandres/house 0.3.1 → 0.4.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 +22 -1
- package/README.md +28 -0
- package/package.json +3 -2
- package/src/Browser.tsx +348 -53
- package/src/CommandPalette.tsx +126 -0
- package/src/Footer.tsx +61 -34
- package/src/cli/argv.ts +30 -2
- package/src/commands/buildCommands.ts +102 -0
- package/src/commands/paletteOnlyCommands.ts +16 -0
- package/src/commands/score.ts +78 -0
- package/src/commands/types.ts +26 -0
- package/src/config/load.ts +166 -0
- package/src/discovery/walk.ts +59 -20
- package/src/index.tsx +106 -28
- package/src/keymap/browser.ts +26 -11
- package/src/layout/resolve.ts +60 -0
|
@@ -0,0 +1,166 @@
|
|
|
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 { themeDefinitions } from "../theme/registry.ts"
|
|
17
|
+
|
|
18
|
+
export interface HouseConfig {
|
|
19
|
+
readonly theme: string
|
|
20
|
+
readonly tone: "dark" | "light"
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CliOverrides {
|
|
24
|
+
readonly theme: string | null
|
|
25
|
+
readonly tone: string | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const DEFAULT_THEME = "opencode"
|
|
29
|
+
const DEFAULT_TONE: "dark" | "light" = "dark"
|
|
30
|
+
|
|
31
|
+
const themeIds = themeDefinitions.map((t) => t.id)
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Top-level keys the config file is allowed to set. Kept in sync by hand
|
|
35
|
+
* with `schema` below — when adding a key, add it both places.
|
|
36
|
+
* Used by `fileProvider` to reject typo'd keys (e.g. `them = "..."`) loudly
|
|
37
|
+
* rather than silently falling back to defaults.
|
|
38
|
+
*/
|
|
39
|
+
const KNOWN_FILE_KEYS: ReadonlySet<string> = new Set(["theme", "tone"])
|
|
40
|
+
|
|
41
|
+
const schema = Config.all({
|
|
42
|
+
theme: Config.schema(Schema.Literals(themeIds), "theme"),
|
|
43
|
+
tone: Config.schema(Schema.Literals(["dark", "light"] as const), "tone"),
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const defaultsProvider = (): ConfigProvider.ConfigProvider =>
|
|
47
|
+
ConfigProvider.fromUnknown({ theme: DEFAULT_THEME, tone: DEFAULT_TONE })
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Reads a TOML file at `path`. Missing file → `undefined` for every key
|
|
51
|
+
* (per-key fallthrough). Malformed TOML → `SourceError` (hard fail upstream).
|
|
52
|
+
*/
|
|
53
|
+
const fileProvider = (path: string): ConfigProvider.ConfigProvider => {
|
|
54
|
+
let cache: { data: Record<string, unknown> | null } | null = null
|
|
55
|
+
const load = Effect.gen(function* () {
|
|
56
|
+
if (cache !== null) return cache.data
|
|
57
|
+
const file = Bun.file(path)
|
|
58
|
+
const exists = yield* Effect.promise(() => file.exists())
|
|
59
|
+
if (!exists) {
|
|
60
|
+
cache = { data: null }
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
const text = yield* Effect.promise(() => file.text())
|
|
64
|
+
const parsed = yield* Effect.try({
|
|
65
|
+
try: () => Bun.TOML.parse(text) as Record<string, unknown>,
|
|
66
|
+
catch: (cause) =>
|
|
67
|
+
new ConfigProvider.SourceError({
|
|
68
|
+
message: `invalid TOML in ${path}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
69
|
+
cause,
|
|
70
|
+
}),
|
|
71
|
+
})
|
|
72
|
+
const unknown = Object.keys(parsed).filter((k) => !KNOWN_FILE_KEYS.has(k))
|
|
73
|
+
if (unknown.length > 0) {
|
|
74
|
+
const known = [...KNOWN_FILE_KEYS].join(", ")
|
|
75
|
+
return yield* Effect.fail(
|
|
76
|
+
new ConfigProvider.SourceError({
|
|
77
|
+
message: `unknown key${unknown.length > 1 ? "s" : ""} in ${path}: ${unknown.map((k) => `"${k}"`).join(", ")} (known: ${known})`,
|
|
78
|
+
}),
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
cache = { data: parsed }
|
|
82
|
+
return parsed
|
|
83
|
+
})
|
|
84
|
+
return ConfigProvider.make((path) =>
|
|
85
|
+
Effect.gen(function* () {
|
|
86
|
+
const data = yield* load
|
|
87
|
+
if (data === null) return undefined
|
|
88
|
+
if (path.length === 0) {
|
|
89
|
+
return ConfigProvider.makeRecord(new Set(Object.keys(data)))
|
|
90
|
+
}
|
|
91
|
+
const head = path[0]
|
|
92
|
+
if (typeof head !== "string") return undefined
|
|
93
|
+
const value = data[head]
|
|
94
|
+
if (value === undefined) return undefined
|
|
95
|
+
if (typeof value === "string") return ConfigProvider.makeValue(value)
|
|
96
|
+
// Numbers/booleans coerced to their string form so Schema.Literals matches.
|
|
97
|
+
return ConfigProvider.makeValue(String(value))
|
|
98
|
+
}),
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Reads `HOUSE_THEME` / `HOUSE_TONE` directly into a `fromUnknown` provider.
|
|
104
|
+
*
|
|
105
|
+
* We don't use `fromEnv().pipe(nested("HOUSE"), constantCase)` here because
|
|
106
|
+
* `ConfigProvider.orElse` composes providers via `.get(path)` (raw store
|
|
107
|
+
* access), which bypasses `mapInput`/`prefix`. That means an env provider
|
|
108
|
+
* built with `nested` + `constantCase` silently returns `undefined` once it
|
|
109
|
+
* sits behind an `orElse`. Reading env vars eagerly sidesteps the issue.
|
|
110
|
+
*/
|
|
111
|
+
const envProvider = (env: Record<string, string | undefined>): ConfigProvider.ConfigProvider => {
|
|
112
|
+
const entries: Array<[string, string]> = []
|
|
113
|
+
const theme = env["HOUSE_THEME"]
|
|
114
|
+
const tone = env["HOUSE_TONE"]
|
|
115
|
+
if (theme !== undefined) entries.push(["theme", theme])
|
|
116
|
+
if (tone !== undefined) entries.push(["tone", tone])
|
|
117
|
+
return ConfigProvider.fromUnknown(Object.fromEntries(entries))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const cliProvider = (overrides: CliOverrides): ConfigProvider.ConfigProvider => {
|
|
121
|
+
const entries: Array<[string, string]> = []
|
|
122
|
+
if (overrides.theme !== null) entries.push(["theme", overrides.theme])
|
|
123
|
+
if (overrides.tone !== null) entries.push(["tone", overrides.tone])
|
|
124
|
+
return ConfigProvider.fromUnknown(Object.fromEntries(entries))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface LoadOptions {
|
|
128
|
+
readonly cli?: CliOverrides
|
|
129
|
+
/** Override the TOML path (tests). Defaults to `$XDG_CONFIG_HOME/house/config.toml`. */
|
|
130
|
+
readonly filePath?: string
|
|
131
|
+
/** Override env (tests). Defaults to `process.env`. */
|
|
132
|
+
readonly env?: Record<string, string>
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export const defaultConfigPath = (): string =>
|
|
136
|
+
join(process.env["XDG_CONFIG_HOME"] ?? join(homedir(), ".config"), "house", "config.toml")
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Renders a `ConfigError` (or any error) as a short single-line message
|
|
140
|
+
* suitable for `console.error("house: " + ...)`. Strips Effect's
|
|
141
|
+
* `ConfigError(SchemaError(...))` wrapping when present.
|
|
142
|
+
*/
|
|
143
|
+
export const formatConfigError = (err: unknown): string => {
|
|
144
|
+
if (err instanceof Config.ConfigError) {
|
|
145
|
+
const cause = err.cause
|
|
146
|
+
const raw = "message" in cause ? cause.message : String(cause)
|
|
147
|
+
return raw
|
|
148
|
+
.replace(/\s+at \[[^\]]+\]\s*$/, "")
|
|
149
|
+
.replace(/\s+/g, " ")
|
|
150
|
+
.trim()
|
|
151
|
+
}
|
|
152
|
+
if (err instanceof Error) return err.message
|
|
153
|
+
return String(err)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export const loadConfig = (
|
|
157
|
+
options: LoadOptions = {},
|
|
158
|
+
): Effect.Effect<HouseConfig, Config.ConfigError> => {
|
|
159
|
+
const cli = options.cli ?? { theme: null, tone: null }
|
|
160
|
+
const provider = cliProvider(cli).pipe(
|
|
161
|
+
ConfigProvider.orElse(envProvider(options.env ?? process.env)),
|
|
162
|
+
ConfigProvider.orElse(fileProvider(options.filePath ?? defaultConfigPath())),
|
|
163
|
+
ConfigProvider.orElse(defaultsProvider()),
|
|
164
|
+
)
|
|
165
|
+
return schema.parse(provider)
|
|
166
|
+
}
|
package/src/discovery/walk.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readdir, readFile } from "node:fs/promises"
|
|
2
2
|
import { extname, join, relative, resolve } from "node:path"
|
|
3
|
-
import { Data, Effect } from "effect"
|
|
3
|
+
import { Data, Effect, Stream } from "effect"
|
|
4
4
|
import ignore, { type Ignore } from "ignore"
|
|
5
5
|
|
|
6
6
|
export interface FileEntry {
|
|
@@ -71,21 +71,38 @@ const sortEntries = <T extends { name: string; isDirectory: () => boolean }>(
|
|
|
71
71
|
return a.name.localeCompare(b.name)
|
|
72
72
|
})
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
/**
|
|
75
|
+
* DFS generator. Yields each markdown FileEntry as it is discovered, before
|
|
76
|
+
* descending further. Per-directory sort still happens before yielding so
|
|
77
|
+
* arrival order within a directory matches the configured sort.
|
|
78
|
+
*
|
|
79
|
+
* Cancellation: `signal.aborted` is checked between syscalls. Node's
|
|
80
|
+
* `readdir` doesn't accept an AbortSignal, so a single in-flight `readdir`
|
|
81
|
+
* on a slow filesystem still runs to completion before we notice — the
|
|
82
|
+
* generator only exits at the next checkpoint.
|
|
83
|
+
*/
|
|
84
|
+
async function* walkDirGen(
|
|
75
85
|
dirPath: string,
|
|
76
86
|
rootPath: string,
|
|
77
87
|
parentLevels: readonly IgnoreLevel[],
|
|
78
|
-
results: FileEntry[],
|
|
79
88
|
opts: { all: boolean; sort: SortOrder },
|
|
80
|
-
|
|
89
|
+
signal: AbortSignal,
|
|
90
|
+
): AsyncGenerator<FileEntry, void, void> {
|
|
91
|
+
if (signal.aborted) return
|
|
92
|
+
|
|
81
93
|
let levels = parentLevels
|
|
82
94
|
if (!opts.all) {
|
|
83
95
|
const ig = await tryLoadGitignore(dirPath)
|
|
96
|
+
if (signal.aborted) return
|
|
84
97
|
if (ig) levels = [...parentLevels, { dir: dirPath, ig }]
|
|
85
98
|
}
|
|
86
99
|
|
|
87
100
|
const raw = await readdir(dirPath, { withFileTypes: true })
|
|
101
|
+
if (signal.aborted) return
|
|
102
|
+
|
|
88
103
|
for (const entry of sortEntries(raw, opts.sort)) {
|
|
104
|
+
if (signal.aborted) return
|
|
105
|
+
|
|
89
106
|
// Never follow symlinks — cycle hazard, and a markdown reader doesn't
|
|
90
107
|
// need them. May be relaxed (files only) in a later iteration.
|
|
91
108
|
if (entry.isSymbolicLink()) continue
|
|
@@ -96,7 +113,7 @@ const walkDir = async (
|
|
|
96
113
|
if (HARD_SKIP_DIRS.has(entry.name)) continue
|
|
97
114
|
if (!opts.all && entry.name.startsWith(".")) continue
|
|
98
115
|
if (!opts.all && isIgnored(entryPath, true, levels)) continue
|
|
99
|
-
|
|
116
|
+
yield* walkDirGen(entryPath, rootPath, levels, opts, signal)
|
|
100
117
|
continue
|
|
101
118
|
}
|
|
102
119
|
|
|
@@ -105,16 +122,19 @@ const walkDir = async (
|
|
|
105
122
|
if (!MARKDOWN_EXTENSIONS.has(extname(entry.name).toLowerCase())) continue
|
|
106
123
|
if (!opts.all && isIgnored(entryPath, false, levels)) continue
|
|
107
124
|
|
|
108
|
-
|
|
125
|
+
yield {
|
|
109
126
|
path: entryPath,
|
|
110
127
|
relativePath: relative(rootPath, entryPath),
|
|
111
128
|
name: entry.name,
|
|
112
|
-
}
|
|
129
|
+
}
|
|
113
130
|
}
|
|
114
131
|
}
|
|
115
132
|
|
|
116
133
|
/**
|
|
117
|
-
*
|
|
134
|
+
* Stream markdown files under `root`. Entries arrive in DFS order respecting
|
|
135
|
+
* the per-directory sort. The stream is interruptible at syscall boundaries:
|
|
136
|
+
* the consumer's teardown trips an AbortController, and the generator exits
|
|
137
|
+
* at its next `signal.aborted` check.
|
|
118
138
|
*
|
|
119
139
|
* Rules (see DESIGN.md §6):
|
|
120
140
|
* - Extensions: `.md`, `.markdown`, `.mdx`.
|
|
@@ -128,16 +148,35 @@ const walkDir = async (
|
|
|
128
148
|
export const walk = (
|
|
129
149
|
root: string,
|
|
130
150
|
options: WalkOptions = {},
|
|
131
|
-
):
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
151
|
+
): Stream.Stream<FileEntry, DiscoveryError> => {
|
|
152
|
+
const absRoot = resolve(root)
|
|
153
|
+
const opts = {
|
|
154
|
+
all: options.all ?? false,
|
|
155
|
+
sort: options.sort ?? ("dirs-first" as SortOrder),
|
|
156
|
+
}
|
|
157
|
+
const controller = new AbortController()
|
|
158
|
+
const iterable: AsyncIterable<FileEntry> = {
|
|
159
|
+
[Symbol.asyncIterator]() {
|
|
160
|
+
const gen = walkDirGen(absRoot, absRoot, [], opts, controller.signal)
|
|
161
|
+
return {
|
|
162
|
+
next: () => gen.next(),
|
|
163
|
+
return: async (value?: void) => {
|
|
164
|
+
controller.abort()
|
|
165
|
+
return gen.return(value as void)
|
|
166
|
+
},
|
|
167
|
+
}
|
|
141
168
|
},
|
|
142
|
-
|
|
143
|
-
})
|
|
169
|
+
}
|
|
170
|
+
return Stream.fromAsyncIterable(iterable, (cause) => new DiscoveryError({ root, cause }))
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Test/convenience helper: collect the full walk into an array. Mirrors the
|
|
175
|
+
* pre-streaming `walk()` signature so call sites that don't need streaming
|
|
176
|
+
* (notably tests) stay terse.
|
|
177
|
+
*/
|
|
178
|
+
export const walkToArray = (
|
|
179
|
+
root: string,
|
|
180
|
+
options: WalkOptions = {},
|
|
181
|
+
): Effect.Effect<readonly FileEntry[], DiscoveryError> =>
|
|
182
|
+
Stream.runCollect(walk(root, options)).pipe(Effect.map((chunk) => Array.from(chunk)))
|
package/src/index.tsx
CHANGED
|
@@ -12,18 +12,19 @@ import { stat } from "node:fs/promises"
|
|
|
12
12
|
import { createCliRenderer, SyntaxStyle } from "@opentui/core"
|
|
13
13
|
import { createRoot, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
|
|
14
14
|
import { RegistryProvider, useAtomSet, useAtomValue } from "@effect/atom-react"
|
|
15
|
-
import { Effect } from "effect"
|
|
16
|
-
import { useMemo } from "react"
|
|
15
|
+
import { Cause, Duration, Effect, Fiber, Stream } from "effect"
|
|
16
|
+
import { useEffect, useMemo, useRef, useState } from "react"
|
|
17
17
|
import pkg from "../package.json" with { type: "json" }
|
|
18
18
|
import { Browser } from "./Browser.tsx"
|
|
19
19
|
import { parseArgv, usage } from "./cli/argv.ts"
|
|
20
|
-
import {
|
|
20
|
+
import { defaultConfigPath, formatConfigError, loadConfig } from "./config/load.ts"
|
|
21
|
+
import { walk, type FileEntry, type SortOrder } from "./discovery/walk.ts"
|
|
21
22
|
import { readFileText } from "./io/readFile.ts"
|
|
22
23
|
import { openInBrowser } from "./serve/openBrowser.ts"
|
|
23
24
|
import { startServer } from "./serve/server.ts"
|
|
24
25
|
import { colors, setActiveTheme } from "./theme/colors.ts"
|
|
25
26
|
import { themeAtom, type ThemeState } from "./theme/atom.ts"
|
|
26
|
-
import { getThemeDefinition,
|
|
27
|
+
import { getThemeDefinition, themeDefinitions } from "./theme/registry.ts"
|
|
27
28
|
|
|
28
29
|
export interface AppProps {
|
|
29
30
|
/** Markdown source to render. */
|
|
@@ -36,6 +37,78 @@ export interface AppProps {
|
|
|
36
37
|
readonly onQuit?: () => void
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
/**
|
|
41
|
+
* DiscoverShell — owns the streaming walk for directory mode. Mounts Browser
|
|
42
|
+
* immediately with `files=[]` and pushes entries as the stream emits.
|
|
43
|
+
*
|
|
44
|
+
* Batching: `Stream.groupedWithin(64, 60ms)` coalesces bursts so we don't
|
|
45
|
+
* trigger one React render per file. Tuned by feel — small enough that
|
|
46
|
+
* results still feel live on tiny trees, large enough to keep render
|
|
47
|
+
* frequency sane on big ones. Revisit if profiling says otherwise.
|
|
48
|
+
*
|
|
49
|
+
* Cancellation: the walk runs on a forked fiber; unmount interrupts it.
|
|
50
|
+
* `Quit` in Browser tears down the renderer and exits, which propagates
|
|
51
|
+
* naturally — the cleanup effect still fires before process.exit completes.
|
|
52
|
+
*/
|
|
53
|
+
export type SidebarMode = "auto" | "on" | "off"
|
|
54
|
+
|
|
55
|
+
interface DiscoverShellProps {
|
|
56
|
+
readonly target: string
|
|
57
|
+
readonly all: boolean
|
|
58
|
+
readonly sort: SortOrder
|
|
59
|
+
readonly maxWidth: number | null
|
|
60
|
+
readonly sidebarMode: SidebarMode
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const DiscoverShell = ({ target, all, sort, maxWidth, sidebarMode }: DiscoverShellProps) => {
|
|
64
|
+
const [files, setFiles] = useState<readonly FileEntry[]>([])
|
|
65
|
+
const [scanning, setScanning] = useState<boolean>(true)
|
|
66
|
+
const [scanError, setScanError] = useState<string | null>(null)
|
|
67
|
+
// Files arrive in a ref-tracked count so the status string can show
|
|
68
|
+
// "indexing… N" even when React hasn't yet flushed the latest setFiles.
|
|
69
|
+
const countRef = useRef(0)
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
const program = walk(target, { all, sort }).pipe(
|
|
73
|
+
Stream.groupedWithin(64, Duration.millis(60)),
|
|
74
|
+
Stream.runForEach((chunk) =>
|
|
75
|
+
Effect.sync(() => {
|
|
76
|
+
const arr = Array.from(chunk)
|
|
77
|
+
if (arr.length === 0) return
|
|
78
|
+
countRef.current += arr.length
|
|
79
|
+
setFiles((prev) => [...prev, ...arr])
|
|
80
|
+
}),
|
|
81
|
+
),
|
|
82
|
+
Effect.matchCauseEffect({
|
|
83
|
+
onSuccess: () => Effect.sync(() => setScanning(false)),
|
|
84
|
+
onFailure: (cause) =>
|
|
85
|
+
Effect.sync(() => {
|
|
86
|
+
// Interruption is the normal teardown path — don't surface it.
|
|
87
|
+
if (Cause.hasInterrupts(cause)) return
|
|
88
|
+
setScanError(`scan failed: ${Cause.pretty(cause)}`)
|
|
89
|
+
setScanning(false)
|
|
90
|
+
}),
|
|
91
|
+
}),
|
|
92
|
+
)
|
|
93
|
+
const fiber = Effect.runFork(program)
|
|
94
|
+
return () => {
|
|
95
|
+
Effect.runFork(Fiber.interrupt(fiber))
|
|
96
|
+
}
|
|
97
|
+
}, [target, all, sort])
|
|
98
|
+
|
|
99
|
+
const discoveryStatus = scanError ?? (scanning ? `indexing… ${countRef.current}` : null)
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<Browser
|
|
103
|
+
files={files}
|
|
104
|
+
title={target}
|
|
105
|
+
maxWidth={maxWidth}
|
|
106
|
+
discoveryStatus={discoveryStatus}
|
|
107
|
+
sidebarMode={sidebarMode}
|
|
108
|
+
/>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
39
112
|
export const App = ({ content, title = "house", maxWidth = null, onQuit }: AppProps) => {
|
|
40
113
|
const renderer = useRenderer()
|
|
41
114
|
const { width, height } = useTerminalDimensions()
|
|
@@ -121,20 +194,21 @@ if (import.meta.main) {
|
|
|
121
194
|
console.log(pkg.version)
|
|
122
195
|
process.exit(0)
|
|
123
196
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const known = themeDefinitions.map((t) => t.id).join(", ")
|
|
128
|
-
console.error(`house: unknown theme "${themeId}". Known: ${known}`)
|
|
129
|
-
process.exit(2)
|
|
197
|
+
if (args.configPath) {
|
|
198
|
+
console.log(defaultConfigPath())
|
|
199
|
+
process.exit(0)
|
|
130
200
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
201
|
+
|
|
202
|
+
const config = await Effect.runPromise(
|
|
203
|
+
loadConfig({ cli: { theme: args.theme, tone: args.tone } }),
|
|
204
|
+
).catch((err: unknown) => {
|
|
205
|
+
console.error(`house: ${formatConfigError(err)}`)
|
|
134
206
|
process.exit(2)
|
|
135
|
-
}
|
|
207
|
+
})
|
|
208
|
+
const { theme: themeId, tone } = config
|
|
136
209
|
const themeDef = getThemeDefinition(themeId)
|
|
137
210
|
if (themeDef === undefined) {
|
|
211
|
+
// Unreachable: Config.schema validated themeId against themeDefinitions.
|
|
138
212
|
console.error(`house: unknown theme "${themeId}"`)
|
|
139
213
|
process.exit(2)
|
|
140
214
|
}
|
|
@@ -193,7 +267,15 @@ if (import.meta.main) {
|
|
|
193
267
|
}
|
|
194
268
|
sort = args.sort
|
|
195
269
|
}
|
|
196
|
-
|
|
270
|
+
let sidebarMode: SidebarMode = "auto"
|
|
271
|
+
if (args.sidebar !== null) {
|
|
272
|
+
if (args.sidebar !== "auto" && args.sidebar !== "on" && args.sidebar !== "off") {
|
|
273
|
+
console.error(`house: --sidebar must be "auto", "on", or "off", got "${args.sidebar}"`)
|
|
274
|
+
process.exit(2)
|
|
275
|
+
}
|
|
276
|
+
sidebarMode = args.sidebar
|
|
277
|
+
}
|
|
278
|
+
await runTui({ target, themeId, tone, maxWidth, all: args.all, sort, sidebarMode })
|
|
197
279
|
}
|
|
198
280
|
}
|
|
199
281
|
|
|
@@ -204,6 +286,7 @@ interface TuiBootOptions {
|
|
|
204
286
|
readonly maxWidth: number | null
|
|
205
287
|
readonly all: boolean
|
|
206
288
|
readonly sort: SortOrder
|
|
289
|
+
readonly sidebarMode: SidebarMode
|
|
207
290
|
}
|
|
208
291
|
|
|
209
292
|
async function runTui({
|
|
@@ -213,6 +296,7 @@ async function runTui({
|
|
|
213
296
|
maxWidth,
|
|
214
297
|
all,
|
|
215
298
|
sort,
|
|
299
|
+
sidebarMode,
|
|
216
300
|
}: TuiBootOptions): Promise<void> {
|
|
217
301
|
let stats: Awaited<ReturnType<typeof stat>>
|
|
218
302
|
try {
|
|
@@ -226,21 +310,15 @@ async function runTui({
|
|
|
226
310
|
const initialTheme: ThemeState = { id: themeId, tone }
|
|
227
311
|
|
|
228
312
|
if (stats.isDirectory()) {
|
|
229
|
-
const files = await Effect.runPromise(
|
|
230
|
-
walk(target, { all, sort }).pipe(
|
|
231
|
-
Effect.tapError((err) =>
|
|
232
|
-
Effect.sync(() => {
|
|
233
|
-
console.error(`house: cannot walk ${target}: ${String(err.cause)}`)
|
|
234
|
-
}),
|
|
235
|
-
),
|
|
236
|
-
),
|
|
237
|
-
).catch(() => {
|
|
238
|
-
process.exit(1)
|
|
239
|
-
})
|
|
240
|
-
if (!Array.isArray(files)) process.exit(1)
|
|
241
313
|
createRoot(renderer).render(
|
|
242
314
|
<RegistryProvider initialValues={[[themeAtom, initialTheme]]}>
|
|
243
|
-
<
|
|
315
|
+
<DiscoverShell
|
|
316
|
+
target={target}
|
|
317
|
+
all={all}
|
|
318
|
+
sort={sort}
|
|
319
|
+
maxWidth={maxWidth}
|
|
320
|
+
sidebarMode={sidebarMode}
|
|
321
|
+
/>
|
|
244
322
|
</RegistryProvider>,
|
|
245
323
|
)
|
|
246
324
|
} else {
|
package/src/keymap/browser.ts
CHANGED
|
@@ -11,14 +11,18 @@ export type BrowserFocus = "sidebar" | "reader"
|
|
|
11
11
|
export interface BrowserCtx {
|
|
12
12
|
readonly files: readonly FileEntry[]
|
|
13
13
|
readonly focus: BrowserFocus
|
|
14
|
-
|
|
14
|
+
/** User's sticky sidebar preference. Visibility is `shown || focus==="sidebar"`. */
|
|
15
|
+
readonly sidebarShown: boolean
|
|
15
16
|
readonly helpVisible: boolean
|
|
16
17
|
readonly filterOpen: boolean
|
|
18
|
+
readonly paletteOpen: boolean
|
|
17
19
|
readonly setFocus: (next: BrowserFocus | ((prev: BrowserFocus) => BrowserFocus)) => void
|
|
18
20
|
readonly setSelectedIndex: (updater: (prev: number) => number) => void
|
|
19
|
-
|
|
21
|
+
/** Toggle `shown` and adjust focus per DESIGN.md §7.1 (see s-behavior table). */
|
|
22
|
+
readonly toggleShown: () => void
|
|
20
23
|
readonly setHelpVisible: (updater: (prev: boolean) => boolean) => void
|
|
21
24
|
readonly openFilter: () => void
|
|
25
|
+
readonly openPalette: () => void
|
|
22
26
|
readonly cycleTheme: (delta: 1 | -1) => void
|
|
23
27
|
readonly toggleTone: () => void
|
|
24
28
|
readonly quit: () => void
|
|
@@ -37,7 +41,8 @@ const stepBy = (c: BrowserCtx, delta: number) =>
|
|
|
37
41
|
c.setSelectedIndex((i) => clamp(i + delta, 0, lastIndex(c)))
|
|
38
42
|
|
|
39
43
|
const inSidebar = (c: BrowserCtx) => c.focus === "sidebar"
|
|
40
|
-
const
|
|
44
|
+
const filterClosed = (c: BrowserCtx) => !c.filterOpen
|
|
45
|
+
const paletteClosed = (c: BrowserCtx) => !c.paletteOpen
|
|
41
46
|
const inReader = (c: BrowserCtx) => c.focus === "reader"
|
|
42
47
|
const inSidebarWithFiles = (c: BrowserCtx) => inSidebar(c) && haveFiles(c)
|
|
43
48
|
const inReaderWithFiles = (c: BrowserCtx) => inReader(c) && haveFiles(c)
|
|
@@ -66,13 +71,7 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
|
|
|
66
71
|
description: "Toggle sidebar visibility",
|
|
67
72
|
hint: "sidebar",
|
|
68
73
|
keys: ["s"],
|
|
69
|
-
run: (c) =>
|
|
70
|
-
const willHide = c.sidebarVisible
|
|
71
|
-
c.setSidebarVisible((v) => !v)
|
|
72
|
-
// When hiding the sidebar, move focus to the reader so input has a
|
|
73
|
-
// target. When revealing it, move focus back to the sidebar.
|
|
74
|
-
c.setFocus(willHide ? "reader" : "sidebar")
|
|
75
|
-
},
|
|
74
|
+
run: (c) => c.toggleShown(),
|
|
76
75
|
},
|
|
77
76
|
{
|
|
78
77
|
id: "help.toggle",
|
|
@@ -88,9 +87,25 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
|
|
|
88
87
|
description: "Filter files (fuzzy match on path)",
|
|
89
88
|
hint: "filter",
|
|
90
89
|
keys: ["/"],
|
|
91
|
-
|
|
90
|
+
// Fires from anywhere except inside an already-open filter. `openFilter`
|
|
91
|
+
// itself force-opens the sidebar and moves focus there, so the binding
|
|
92
|
+
// no longer needs to gate on focus or sidebar visibility.
|
|
93
|
+
when: filterClosed,
|
|
92
94
|
run: (c) => c.openFilter(),
|
|
93
95
|
},
|
|
96
|
+
{
|
|
97
|
+
id: "palette.open",
|
|
98
|
+
group: "Global",
|
|
99
|
+
description: "Command palette",
|
|
100
|
+
hint: "palette",
|
|
101
|
+
keys: ["ctrl+p"],
|
|
102
|
+
// Filter swallows ctrl+p as a typed character in its own branch, so this
|
|
103
|
+
// `when` only matters when the palette is already open (which it
|
|
104
|
+
// shouldn't re-open). #70 Q2 — fires from everywhere except the filter,
|
|
105
|
+
// closes help on its way in (handled in Browser.tsx).
|
|
106
|
+
when: paletteClosed,
|
|
107
|
+
run: (c) => c.openPalette(),
|
|
108
|
+
},
|
|
94
109
|
{
|
|
95
110
|
id: "serve.current",
|
|
96
111
|
group: "Global",
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layout primitives for the two-pane shape (sidebar + reader).
|
|
3
|
+
*
|
|
4
|
+
* Visibility and width are decoupled (see DESIGN.md §7.1):
|
|
5
|
+
* visible = shown || focus === "sidebar"
|
|
6
|
+
* Width is a pure function of viewport, independent of visibility:
|
|
7
|
+
* resolveSidebarWidth(viewport, preferred)
|
|
8
|
+
*
|
|
9
|
+
* Both render code and key handlers consume these primitives — there is no
|
|
10
|
+
* parallel implementation living in JSX.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Minimum useful sidebar width; below this, file rows truncate too aggressively. */
|
|
14
|
+
export const SIDEBAR_MIN_WIDTH = 28
|
|
15
|
+
/** Maximum sidebar width; beyond this, wasted whitespace on wide terminals. */
|
|
16
|
+
export const SIDEBAR_MAX_WIDTH = 60
|
|
17
|
+
/** Reader pane minimum; below this, prose wraps unpleasantly. */
|
|
18
|
+
export const READER_MIN_WIDTH = 40
|
|
19
|
+
/** Column gap painted between the two panes when both are inline. */
|
|
20
|
+
export const DIVIDER_WIDTH = 1
|
|
21
|
+
/** Launch-bucket threshold for `--sidebar=auto`. < this → start hidden. */
|
|
22
|
+
export const TIGHT_VIEWPORT_THRESHOLD = 80
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Continuous clamp. `preferred` is the user's desired width (until #13 lands,
|
|
26
|
+
* derived from viewport). The reader-min ceiling means the sidebar yields
|
|
27
|
+
* space rather than squeezing the reader below readability.
|
|
28
|
+
*
|
|
29
|
+
* Result is not clamped *up* to SIDEBAR_MIN when the viewport itself is too
|
|
30
|
+
* narrow to hold both panes — the caller decides whether to render at all
|
|
31
|
+
* (e.g. drawer instead of inline). See `canFitInline`.
|
|
32
|
+
*/
|
|
33
|
+
export const resolveSidebarWidth = (viewport: number, preferred: number): number => {
|
|
34
|
+
const ceiling = viewport - DIVIDER_WIDTH - READER_MIN_WIDTH
|
|
35
|
+
const lower = Math.min(SIDEBAR_MIN_WIDTH, Math.max(0, ceiling))
|
|
36
|
+
const upper = Math.max(lower, ceiling)
|
|
37
|
+
return Math.max(lower, Math.min(upper, preferred))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Default preferred sidebar width, derived from viewport until persistent
|
|
42
|
+
* config (#13) provides a user-set value. Matches the previous inline math.
|
|
43
|
+
*/
|
|
44
|
+
export const defaultPreferredWidth = (viewport: number): number =>
|
|
45
|
+
Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, Math.floor(viewport * 0.25)))
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* True when an inline (side-by-side) layout still gives the reader at least
|
|
49
|
+
* READER_MIN_WIDTH. When false, the caller should render the sidebar as a
|
|
50
|
+
* drawer instead — see DESIGN.md §7.1 Q2.
|
|
51
|
+
*/
|
|
52
|
+
export const canFitInline = (viewport: number): boolean =>
|
|
53
|
+
viewport >= SIDEBAR_MIN_WIDTH + DIVIDER_WIDTH + READER_MIN_WIDTH
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Launch bucket decision for `--sidebar=auto`. Buckets are consulted once at
|
|
57
|
+
* launch only; subsequent visibility changes go through `shown` + focus.
|
|
58
|
+
*/
|
|
59
|
+
export const initialShownForAuto = (viewport: number): boolean =>
|
|
60
|
+
viewport >= TIGHT_VIEWPORT_THRESHOLD
|