@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.
@@ -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
+ }
@@ -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
- const walkDir = async (
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
- ): Promise<void> => {
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
- await walkDir(entryPath, rootPath, levels, results, opts)
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
- results.push({
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
- * Walk a directory tree and return markdown files in a stable order.
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
- ): Effect.Effect<readonly FileEntry[], DiscoveryError> =>
132
- Effect.tryPromise({
133
- try: async () => {
134
- const absRoot = resolve(root)
135
- const results: FileEntry[] = []
136
- await walkDir(absRoot, absRoot, [], results, {
137
- all: options.all ?? false,
138
- sort: options.sort ?? "dirs-first",
139
- })
140
- return results
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
- catch: (cause) => new DiscoveryError({ root, cause }),
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 { walk, type SortOrder } from "./discovery/walk.ts"
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, isThemeId, themeDefinitions } from "./theme/registry.ts"
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
- const themeId = args.theme ?? "opencode"
126
- if (!isThemeId(themeId)) {
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
- const tone = args.tone ?? "dark"
132
- if (tone !== "dark" && tone !== "light") {
133
- console.error(`house: --tone must be "dark" or "light", got "${tone}"`)
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
- await runTui({ target, themeId, tone, maxWidth, all: args.all, sort })
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
- <Browser files={files} title={target} maxWidth={maxWidth} />
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 {
@@ -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
- readonly sidebarVisible: boolean
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
- readonly setSidebarVisible: (updater: (prev: boolean) => boolean) => void
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 inSidebarFilterClosed = (c: BrowserCtx) => inSidebar(c) && !c.filterOpen
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
- when: inSidebarFilterClosed,
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