@carlesandres/house 0.4.7 → 0.4.8

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.
@@ -13,6 +13,11 @@ export interface FileEntry {
13
13
  readonly name: string
14
14
  }
15
15
 
16
+ export interface DiscoveryWarning {
17
+ readonly path: string
18
+ readonly cause: unknown
19
+ }
20
+
16
21
  export type SortOrder = "dirs-first" | "files-first"
17
22
 
18
23
  export interface WalkOptions {
@@ -25,6 +30,8 @@ export interface WalkOptions {
25
30
  readonly sort?: SortOrder
26
31
  /** Include `.mdx` files alongside `.md`/`.markdown`. Default `true`. */
27
32
  readonly mdx?: boolean
33
+ /** Non-fatal subtree read errors. Root-level failures still error the walk. */
34
+ readonly onWarning?: ((warning: DiscoveryWarning) => void) | null
28
35
  }
29
36
 
30
37
  export class DiscoveryError extends Data.TaggedError("DiscoveryError")<{
@@ -93,9 +100,11 @@ async function* walkDirGen(
93
100
  rootPath: string,
94
101
  parentLevels: readonly IgnoreLevel[],
95
102
  opts: { showHidden: boolean; showGitignored: boolean; sort: SortOrder; mdx: boolean },
103
+ onWarning: ((warning: DiscoveryWarning) => void) | null,
96
104
  signal: AbortSignal,
97
105
  ): AsyncGenerator<FileEntry, void, void> {
98
106
  if (signal.aborted) return
107
+ const isRoot = dirPath === rootPath
99
108
 
100
109
  let levels = parentLevels
101
110
  if (!opts.showGitignored) {
@@ -104,7 +113,14 @@ async function* walkDirGen(
104
113
  if (ig) levels = [...parentLevels, { dir: dirPath, ig }]
105
114
  }
106
115
 
107
- const raw = await readdir(dirPath, { withFileTypes: true })
116
+ let raw
117
+ try {
118
+ raw = await readdir(dirPath, { withFileTypes: true })
119
+ } catch (error) {
120
+ if (isRoot) throw error
121
+ onWarning?.({ path: dirPath, cause: error })
122
+ return
123
+ }
108
124
  if (signal.aborted) return
109
125
 
110
126
  for (const entry of sortEntries(raw, opts.sort)) {
@@ -120,7 +136,7 @@ async function* walkDirGen(
120
136
  if (HARD_SKIP_DIRS.has(entry.name)) continue
121
137
  if (!opts.showHidden && entry.name.startsWith(".")) continue
122
138
  if (!opts.showGitignored && isIgnored(entryPath, true, levels)) continue
123
- yield* walkDirGen(entryPath, rootPath, levels, opts, signal)
139
+ yield* walkDirGen(entryPath, rootPath, levels, opts, onWarning, signal)
124
140
  continue
125
141
  }
126
142
 
@@ -165,10 +181,11 @@ export const walk = (
165
181
  sort: options.sort ?? ("dirs-first" as SortOrder),
166
182
  mdx: options.mdx ?? true,
167
183
  }
184
+ const onWarning = options.onWarning ?? null
168
185
  const controller = new AbortController()
169
186
  const iterable: AsyncIterable<FileEntry> = {
170
187
  [Symbol.asyncIterator]() {
171
- const gen = walkDirGen(absRoot, absRoot, [], opts, controller.signal)
188
+ const gen = walkDirGen(absRoot, absRoot, [], opts, onWarning, controller.signal)
172
189
  return {
173
190
  next: () => gen.next(),
174
191
  return: async (value?: void) => {
package/src/index.tsx CHANGED
@@ -1,51 +1,30 @@
1
1
  #!/usr/bin/env bun
2
- /**
3
- * house — entry point.
4
- *
5
- * Reads a markdown file path from argv and renders it via opentui's built-in
6
- * <markdown> component inside a scrollbox. q / ctrl+c to quit.
7
- *
8
- * Discovery, sidebar, theming, and richer Effect wiring all land after this.
9
- */
2
+ /** house — entry point. Boots the browser TUI or `--serve` preview. */
10
3
 
11
4
  import { stat } from "node:fs/promises"
12
- import { dirname, resolve } from "node:path"
13
- import { createCliRenderer, SyntaxStyle } from "@opentui/core"
14
- import type { BorderSides } from "@opentui/core"
15
- import { createRoot, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
16
- import { RegistryProvider, useAtomSet, useAtomValue } from "@effect/atom-react"
5
+ import { dirname, isAbsolute, relative, resolve } from "node:path"
6
+ import { createCliRenderer } from "@opentui/core"
7
+ import { createRoot } from "@opentui/react"
8
+ import { RegistryProvider } from "@effect/atom-react"
17
9
  import { Cause, Duration, Effect, Fiber, Stream } from "effect"
18
- import { useEffect, useMemo, useRef, useState } from "react"
10
+ import { useEffect, useRef, useState } from "react"
19
11
  import pkg from "../package.json" with { type: "json" }
20
12
  import { Browser, type StartupFocus } from "./Browser.tsx"
21
13
  import { parseArgv, usage } from "./cli/argv.ts"
22
14
  import { defaultConfigPath, formatConfigError, loadConfig } from "./config/load.ts"
23
15
  import { parseShowList, SHOW_CATEGORIES, type ShowCategory } from "./discovery/show.ts"
24
16
  import { walk, type FileEntry, type SortOrder } from "./discovery/walk.ts"
25
- import { Header } from "./Header.tsx"
26
- import { readFileText } from "./io/readFile.ts"
27
17
  import { openInBrowser } from "./serve/openBrowser.ts"
28
18
  import { startServer } from "./serve/server.ts"
29
- import { colors, setActiveTheme } from "./theme/colors.ts"
19
+ import { setActiveTheme } from "./theme/colors.ts"
30
20
  import { themeAtom, type ThemeState } from "./theme/atom.ts"
31
- import { getThemeDefinition, themeDefinitions } from "./theme/registry.ts"
21
+ import { getThemeDefinition } from "./theme/registry.ts"
32
22
  import { formatQuitNotice } from "./update/notice.ts"
33
23
  import { currentUpdateInfo, startUpdateProbe } from "./update/runtime.ts"
34
24
  import { useUpdateNotice } from "./update/useUpdateNotice.ts"
35
25
 
36
- export interface AppProps {
37
- /** Markdown source to render. */
38
- readonly content: string
39
- /** Optional title shown in the header's current-file slot. Defaults to a generic label. */
40
- readonly title?: string
41
- /** Cap the rendered markdown's width at N columns (left-aligned). Null = fill the pane. */
42
- readonly maxWidth?: number | null
43
- /** Override quit behavior. Tests pass a spy; the binary uses the default. */
44
- readonly onQuit?: () => void
45
- }
46
-
47
26
  /**
48
- * DiscoverShell — owns the streaming walk for directory mode. Mounts Browser
27
+ * DiscoverShell — owns the streaming walk for the Browser. Mounts Browser
49
28
  * immediately with `files=[]` and pushes entries as the stream emits.
50
29
  *
51
30
  * Batching: `Stream.groupedWithin(64, 60ms)` coalesces bursts so we don't
@@ -92,8 +71,36 @@ export const resolveDiscoveryRoot = async ({
92
71
  return cwd
93
72
  }
94
73
 
74
+ export const validateDiscoveryRoot = async (root: string): Promise<void> => {
75
+ let stats: Awaited<ReturnType<typeof stat>>
76
+ try {
77
+ stats = await stat(root)
78
+ } catch (err) {
79
+ throw new Error(`cannot access discovery root ${root}: ${String(err)}`)
80
+ }
81
+ if (!stats.isDirectory()) {
82
+ throw new Error(`discovery root must be a directory, got file ${root}`)
83
+ }
84
+ }
85
+
86
+ export const formatPartialDiscoveryStatus = ({
87
+ skippedCount,
88
+ lastSkippedPath,
89
+ }: {
90
+ readonly skippedCount: number
91
+ readonly lastSkippedPath: string | null
92
+ }): string | null => {
93
+ if (skippedCount <= 0) return null
94
+ const noun = skippedCount === 1 ? "directory" : "directories"
95
+ const suffix = lastSkippedPath && skippedCount === 1 ? `: ${lastSkippedPath}` : ""
96
+ return `scan incomplete: skipped ${skippedCount} ${noun}${suffix}`
97
+ }
98
+
99
+ export const formatFatalDiscoveryStatus = (): string => "scan failed: unable to read discovery root"
100
+
95
101
  interface DiscoverShellProps {
96
102
  readonly target: string
103
+ readonly initialQuery: string
97
104
  /** Resolved discovery vocabulary from the config layer. The shift+a
98
105
  * toggle (#145) is session-only sugar that flips between this set
99
106
  * and the full vocabulary; the underlying categories remain
@@ -106,8 +113,9 @@ interface DiscoverShellProps {
106
113
  readonly startupFocus: StartupFocus
107
114
  }
108
115
 
109
- const DiscoverShell = ({
116
+ export const DiscoverShell = ({
110
117
  target,
118
+ initialQuery,
111
119
  initialShow,
112
120
  sort,
113
121
  mdx,
@@ -120,6 +128,8 @@ const DiscoverShell = ({
120
128
  const [files, setFiles] = useState<readonly FileEntry[]>([])
121
129
  const [scanning, setScanning] = useState<boolean>(true)
122
130
  const [scanError, setScanError] = useState<string | null>(null)
131
+ const [skippedDirCount, setSkippedDirCount] = useState<number>(0)
132
+ const [lastSkippedDir, setLastSkippedDir] = useState<string | null>(null)
123
133
  // Files arrive in a ref-tracked count so the status string can show
124
134
  // "indexing… N" even when React hasn't yet flushed the latest setFiles.
125
135
  const countRef = useRef(0)
@@ -132,8 +142,23 @@ const DiscoverShell = ({
132
142
  setFiles([])
133
143
  setScanning(true)
134
144
  setScanError(null)
145
+ setSkippedDirCount(0)
146
+ setLastSkippedDir(null)
135
147
  countRef.current = 0
136
- const program = walk(target, { show, sort, mdx }).pipe(
148
+ const warnedProgram = walk(target, {
149
+ show,
150
+ sort,
151
+ mdx,
152
+ onWarning: ({ path }) => {
153
+ const relativePath = relative(resolve(target), path)
154
+ setSkippedDirCount((prev) => prev + 1)
155
+ setLastSkippedDir(
156
+ relativePath.length > 0 && !relativePath.startsWith("..") && !isAbsolute(relativePath)
157
+ ? relativePath
158
+ : path,
159
+ )
160
+ },
161
+ }).pipe(
137
162
  Stream.groupedWithin(64, Duration.millis(60)),
138
163
  Stream.runForEach((chunk) =>
139
164
  Effect.sync(() => {
@@ -147,25 +172,33 @@ const DiscoverShell = ({
147
172
  onSuccess: () => Effect.sync(() => setScanning(false)),
148
173
  onFailure: (cause) =>
149
174
  Effect.sync(() => {
150
- // Interruption is the normal teardown path — don't surface it.
151
175
  if (Cause.hasInterrupts(cause)) return
152
- setScanError(`scan failed: ${Cause.pretty(cause)}`)
176
+ setScanError(formatFatalDiscoveryStatus())
153
177
  setScanning(false)
154
178
  }),
155
179
  }),
156
180
  )
157
- const fiber = Effect.runFork(program)
181
+ const fiber = Effect.runFork(warnedProgram)
158
182
  return () => {
159
183
  Effect.runFork(Fiber.interrupt(fiber))
160
184
  }
161
185
  }, [target, show, sort, mdx])
162
186
 
163
- const discoveryStatus = scanError ?? (scanning ? `indexing… ${countRef.current}` : null)
187
+ const discoveryStatus =
188
+ scanError ??
189
+ (scanning
190
+ ? `indexing… ${countRef.current}`
191
+ : formatPartialDiscoveryStatus({
192
+ skippedCount: skippedDirCount,
193
+ lastSkippedPath: lastSkippedDir,
194
+ }))
164
195
 
165
196
  return (
166
197
  <Browser
167
198
  files={files}
199
+ initialQuery={initialQuery}
168
200
  maxWidth={maxWidth}
201
+ emptyRootLabel={target}
169
202
  discoveryStatus={discoveryStatus}
170
203
  sidebarMode={sidebarMode}
171
204
  startupFocus={startupFocus}
@@ -183,79 +216,22 @@ const DiscoverShell = ({
183
216
  )
184
217
  }
185
218
 
186
- export const App = ({ content, title = "house", maxWidth = null, onQuit }: AppProps) => {
187
- const renderer = useRenderer()
188
- const { width, height } = useTerminalDimensions()
189
- const theme = useAtomValue(themeAtom)
190
- const setTheme = useAtomSet(themeAtom)
191
- const syntaxStyle = useMemo(() => SyntaxStyle.fromStyles(colors.syntax), [theme])
192
-
193
- const cycleTheme = (delta: 1 | -1) => {
194
- const idx = themeDefinitions.findIndex((d) => d.id === theme.id)
195
- const next = themeDefinitions[(idx + delta + themeDefinitions.length) % themeDefinitions.length]
196
- if (!next) return
197
- setActiveTheme(next, theme.tone)
198
- setTheme({ id: next.id, tone: theme.tone })
199
- }
200
-
201
- const toggleTone = () => {
202
- const nextTone = theme.tone === "dark" ? "light" : "dark"
203
- const def = getThemeDefinition(theme.id)
204
- if (def) setActiveTheme(def, nextTone)
205
- setTheme({ id: theme.id, tone: nextTone })
206
- }
207
-
208
- useKeyboard((key) => {
209
- if (key.name === "q" || (key.ctrl && key.name === "c")) {
210
- if (onQuit) {
211
- onQuit()
212
- return
213
- }
214
- renderer?.destroy()
215
- process.exit(0)
216
- }
217
- if (key.name === "t" && !key.shift) cycleTheme(1)
218
- if (key.name === "t" && key.shift) cycleTheme(-1)
219
- if (key.name === "l" && key.shift) toggleTone()
220
- })
221
-
222
- const paneBorderSides: BorderSides[] = ["top", "bottom"]
223
-
224
- return (
225
- <box style={{ width, height, flexDirection: "column", backgroundColor: colors.background }}>
226
- <Header width={width} currentFile={title} />
227
- <box
228
- style={{
229
- border: paneBorderSides,
230
- borderColor: colors.border,
231
- padding: 1,
232
- flexGrow: 1,
233
- flexShrink: 1,
234
- backgroundColor: colors.background,
235
- }}
236
- >
237
- <scrollbox
238
- style={{
239
- scrollY: true,
240
- scrollX: false,
241
- flexGrow: 1,
242
- flexShrink: 1,
243
- backgroundColor: colors.background,
244
- }}
245
- focused
246
- >
247
- <markdown
248
- content={content}
249
- syntaxStyle={syntaxStyle}
250
- fg={colors.text}
251
- bg={colors.background}
252
- conceal
253
- style={{ width: maxWidth ?? "100%" }}
254
- />
255
- </scrollbox>
256
- </box>
257
- </box>
258
- )
219
+ export const resolveInitialQuery = ({
220
+ pathArg,
221
+ discoveryRoot,
222
+ cwd,
223
+ }: {
224
+ readonly pathArg: string | null
225
+ readonly discoveryRoot: string
226
+ readonly cwd: string
227
+ }): string => {
228
+ if (pathArg === null) return ""
229
+ const resolvedPath = resolve(cwd, pathArg)
230
+ const rel = relative(discoveryRoot, resolvedPath)
231
+ if (rel.length === 0) return ""
232
+ if (!rel.startsWith("..") && !isAbsolute(rel)) return rel
233
+ if (pathArg.startsWith("./")) return pathArg.slice(2)
234
+ return pathArg
259
235
  }
260
236
 
261
237
  let updateExitHookRegistered = false
@@ -331,10 +307,15 @@ if (import.meta.main) {
331
307
  }
332
308
 
333
309
  const cwd = process.cwd()
334
- const target = args.path ?? "."
335
310
  const discoveryRoot = await resolveDiscoveryRoot({ cliRoot: args.root, defaultRoot, cwd })
311
+ const initialQuery = resolveInitialQuery({ pathArg: args.path, discoveryRoot, cwd })
336
312
 
337
313
  if (args.serve) {
314
+ const target = args.path
315
+ if (target === null) {
316
+ console.error("house: --serve requires a file path")
317
+ process.exit(2)
318
+ }
338
319
  let stats: Awaited<ReturnType<typeof stat>>
339
320
  try {
340
321
  stats = await stat(target)
@@ -392,8 +373,8 @@ if (import.meta.main) {
392
373
  }
393
374
  }
394
375
  await runTui({
395
- target,
396
376
  discoveryRoot,
377
+ initialQuery,
397
378
  themeId,
398
379
  tone,
399
380
  maxWidth,
@@ -408,8 +389,8 @@ if (import.meta.main) {
408
389
  }
409
390
 
410
391
  interface TuiBootOptions {
411
- readonly target: string
412
392
  readonly discoveryRoot: string
393
+ readonly initialQuery: string
413
394
  readonly themeId: string
414
395
  readonly tone: "dark" | "light"
415
396
  readonly maxWidth: number | null
@@ -424,8 +405,8 @@ interface TuiBootOptions {
424
405
  }
425
406
 
426
407
  async function runTui({
427
- target,
428
408
  discoveryRoot,
409
+ initialQuery,
429
410
  themeId,
430
411
  tone,
431
412
  maxWidth,
@@ -436,14 +417,6 @@ async function runTui({
436
417
  startupFocus,
437
418
  updateCheck,
438
419
  }: TuiBootOptions): Promise<void> {
439
- let stats: Awaited<ReturnType<typeof stat>>
440
- try {
441
- stats = await stat(target)
442
- } catch (err) {
443
- console.error(`house: cannot access ${target}: ${String(err)}`)
444
- process.exit(1)
445
- }
446
-
447
420
  if (updateCheck) {
448
421
  // Fire the npm-registry probe in the background. Result lands in a
449
422
  // module singleton; the React tree picks it up via `useUpdateNotice`
@@ -462,40 +435,32 @@ async function runTui({
462
435
  }
463
436
  }
464
437
 
465
- const renderer = await createCliRenderer({ exitOnCtrlC: false })
438
+ const renderer = await createCliRenderer({
439
+ exitOnCtrlC: false,
440
+ useMouse: true,
441
+ enableMouseMovement: true,
442
+ screenMode: "alternate-screen",
443
+ })
444
+ renderer.useMouse = true
466
445
  const initialTheme: ThemeState = { id: themeId, tone }
467
-
468
- if (stats.isDirectory()) {
469
- createRoot(renderer).render(
470
- <RegistryProvider initialValues={[[themeAtom, initialTheme]]}>
471
- <DiscoverShell
472
- target={discoveryRoot}
473
- initialShow={show}
474
- sort={sort}
475
- mdx={mdx}
476
- maxWidth={maxWidth}
477
- sidebarMode={sidebarMode}
478
- startupFocus={startupFocus}
479
- />
480
- </RegistryProvider>,
481
- )
482
- } else {
483
- const content = await Effect.runPromise(
484
- readFileText(target).pipe(
485
- Effect.tapError((err) =>
486
- Effect.sync(() => {
487
- console.error(`house: cannot read ${err.path}: ${String(err.cause)}`)
488
- }),
489
- ),
490
- ),
491
- ).catch(() => {
492
- process.exit(1)
493
- })
494
- if (typeof content !== "string") process.exit(1)
495
- createRoot(renderer).render(
496
- <RegistryProvider initialValues={[[themeAtom, initialTheme]]}>
497
- <App content={content} title={target} maxWidth={maxWidth} />
498
- </RegistryProvider>,
499
- )
446
+ try {
447
+ await validateDiscoveryRoot(discoveryRoot)
448
+ } catch (err) {
449
+ console.error(`house: ${err instanceof Error ? err.message : String(err)}`)
450
+ process.exit(1)
500
451
  }
452
+ createRoot(renderer).render(
453
+ <RegistryProvider initialValues={[[themeAtom, initialTheme]]}>
454
+ <DiscoverShell
455
+ target={discoveryRoot}
456
+ initialQuery={initialQuery}
457
+ initialShow={show}
458
+ sort={sort}
459
+ mdx={mdx}
460
+ maxWidth={maxWidth}
461
+ sidebarMode={sidebarMode}
462
+ startupFocus={startupFocus}
463
+ />
464
+ </RegistryProvider>,
465
+ )
501
466
  }
@@ -1,6 +1,5 @@
1
1
  /**
2
- * Browser keymap — the data backing `Browser.tsx`'s `useKeyboard` handler
3
- * and (next iteration) the `?` help overlay.
2
+ * Browser keymap — the data backing `Browser.tsx`'s `useKeyboard` handler.
4
3
  */
5
4
 
6
5
  import type { FileEntry } from "../discovery/walk.ts"
@@ -18,7 +17,6 @@ export interface BrowserCtx {
18
17
  readonly focus: BrowserFocus
19
18
  /** User's sticky sidebar preference. Visibility is `shown || focus==="sidebar"`. */
20
19
  readonly sidebarShown: boolean
21
- readonly helpVisible: boolean
22
20
  readonly filterOpen: boolean
23
21
  readonly restoreFilterOnSidebarFocus: boolean
24
22
  /** Current applied/edited filter query. Used by `filter.clearOrOpen`'s
@@ -29,7 +27,6 @@ export interface BrowserCtx {
29
27
  readonly setSelectedIndex: (updater: (prev: number) => number) => void
30
28
  /** Toggle `shown` and adjust focus per DESIGN.md §7.1 (see s-behavior table). */
31
29
  readonly toggleShown: () => void
32
- readonly setHelpVisible: (updater: (prev: boolean) => boolean) => void
33
30
  readonly openFilter: () => void
34
31
  /** Clear the current filter query and open the filter modal in a single
35
32
  * action. Bound to `\` so users can reset a stranded zero-match filter
@@ -65,6 +62,7 @@ const stepBy = (c: BrowserCtx, delta: number) =>
65
62
  const inSidebar = (c: BrowserCtx) => c.focus === "sidebar"
66
63
  const filterClosed = (c: BrowserCtx) => !c.filterOpen
67
64
  const paletteClosed = (c: BrowserCtx) => !c.paletteOpen
65
+ const inputClosed = (c: BrowserCtx) => filterClosed(c) && paletteClosed(c)
68
66
  const inReader = (c: BrowserCtx) => c.focus === "reader"
69
67
  const inSidebarWithFiles = (c: BrowserCtx) => inSidebar(c) && haveFiles(c)
70
68
  /** Reader-only sibling-step gate: needs a current selection plus a sibling
@@ -80,6 +78,7 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
80
78
  description: "Quit",
81
79
  hint: "quit",
82
80
  keys: ["q", "ctrl+c"],
81
+ hintWhen: inputClosed,
83
82
  run: (c) => c.quit(),
84
83
  },
85
84
  {
@@ -102,16 +101,9 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
102
101
  description: "Toggle sidebar visibility",
103
102
  hint: "sidebar",
104
103
  keys: ["s"],
104
+ hintWhen: inputClosed,
105
105
  run: (c) => c.toggleShown(),
106
106
  },
107
- {
108
- id: "help.toggle",
109
- group: "Global",
110
- description: "Show / dismiss help",
111
- hint: "help",
112
- keys: ["?"],
113
- run: (c) => c.setHelpVisible((v) => !v),
114
- },
115
107
  {
116
108
  id: "filter.open",
117
109
  group: "Sidebar",
@@ -133,17 +125,16 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
133
125
  // Fires from anywhere outside the filter modal via the keymap.
134
126
  // Inside the filter modal it's intercepted directly in Browser.tsx
135
127
  // (the filter mode owns key handling), but the action is the same —
136
- // clear input, keep modal open. Palette/help branches short-circuit
128
+ // clear input, keep modal open. Palette branches short-circuit
137
129
  // dispatch in Browser.tsx, so we don't need to gate on them for
138
- // behavior; the `hintWhen` gate keeps the footer chip from showing
130
+ // behavior; the `hintWhen` gate keeps the footer hint from showing
139
131
  // when there's nothing to clear or when a modal owns the input.
140
132
  // Chord chosen over single `\` so the binding works inside the
141
133
  // filter input without colliding with the typed character; ctrl+u
142
134
  // is deliberately left to its reader/sidebar half-page-up role to
143
135
  // avoid overload.
144
136
  when: filterClosed,
145
- hintWhen: (c) =>
146
- filterClosed(c) && !c.paletteOpen && !c.helpVisible && c.filterQuery.length > 0,
137
+ hintWhen: (c) => filterClosed(c) && !c.paletteOpen && c.filterQuery.length > 0,
147
138
  run: (c) => c.clearAndOpenFilter(),
148
139
  },
149
140
  {
@@ -152,10 +143,9 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
152
143
  description: "Command palette",
153
144
  hint: "palette",
154
145
  keys: ["ctrl+p"],
155
- // Filter swallows ctrl+p as a typed character in its own branch, so this
156
- // `when` only matters when the palette is already open (which it
157
- // shouldn't re-open). #70 Q2 — fires from everywhere except the filter,
158
- // closes help on its way in (handled in Browser.tsx).
146
+ // The filter modal handles this chord directly so the palette can open
147
+ // while filter input owns the rest of the keyboard. This gate keeps the
148
+ // normal dispatcher from reopening an already-open palette.
159
149
  when: paletteClosed,
160
150
  run: (c) => c.openPalette(),
161
151
  },
@@ -178,6 +168,7 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
178
168
  description: "Next theme",
179
169
  hint: "theme",
180
170
  keys: ["t"],
171
+ hintWhen: inputClosed,
181
172
  run: (c) => c.cycleTheme(1),
182
173
  },
183
174
  {
@@ -267,6 +258,7 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
267
258
  hint: "open",
268
259
  keys: ["return", "right", "l"],
269
260
  when: inSidebar,
261
+ hintWhen: (c) => inSidebar(c) && hasSelected(c),
270
262
  run: (c) => c.setFocus("reader"),
271
263
  },
272
264
 
@@ -280,6 +272,7 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
280
272
  hint: "html",
281
273
  keys: ["shift+o"],
282
274
  when: hasSelected,
275
+ hintWhen: (c) => inputClosed(c) && hasSelected(c),
283
276
  run: (c) => c.serveCurrent(),
284
277
  },
285
278
  {
@@ -289,6 +282,7 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
289
282
  hint: "edit",
290
283
  keys: ["shift+e"],
291
284
  when: hasSelected,
285
+ hintWhen: (c) => inputClosed(c) && hasSelected(c),
292
286
  run: (c) => c.editCurrent(),
293
287
  },
294
288
  {
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Bindings are values: `{ id, description, keys, when?, run }`. A pure
5
5
  * `dispatch` looks up the first matching, enabled binding for a key event
6
- * and runs it. The same array drives the help overlay, so there is one
7
- * source of truth.
6
+ * and runs it. The same array also drives footer hints and command-palette
7
+ * derivation, so there is one source of truth.
8
8
  *
9
9
  * Deliberately *not* a port of ghui's `@ghui/keymap`: no chord sequences,
10
10
  * no count prefixes, no scope contramaps. See DESIGN.md §12 for the full
@@ -22,11 +22,11 @@ export interface KeyMatch {
22
22
  export interface KeyBinding<C> {
23
23
  /** Stable id; used for tests and (later) command-palette routing. */
24
24
  readonly id: string
25
- /** Human-readable summary, shown in the help overlay. */
25
+ /** Human-readable summary used by docs and palette command titles. */
26
26
  readonly description: string
27
27
  /** Key chords that trigger this binding, e.g. ["j", "down"], ["shift+k"], ["ctrl+c"]. */
28
28
  readonly keys: readonly string[]
29
- /** Optional grouping label for the help overlay. Bindings without a group are excluded from help. */
29
+ /** Optional grouping label for derived UI/documentation surfaces. */
30
30
  readonly group?: string
31
31
  /** Optional compact label for the footer hint row, e.g. "help" for `?:help`.
32
32
  * Bindings without a hint are excluded from the footer. Order in the bindings
@@ -0,0 +1,7 @@
1
+ const HEAD_ELISION_PREFIX = "…"
2
+
3
+ export const fitSidebarEmptyValue = (value: string, width: number): string => {
4
+ if (value.length <= width) return value
5
+ if (width <= 1) return value.slice(value.length - 1)
6
+ return HEAD_ELISION_PREFIX + value.slice(value.length - width + 1)
7
+ }
@@ -0,0 +1,62 @@
1
+ export interface FrontmatterField {
2
+ readonly key: string
3
+ readonly value: string
4
+ }
5
+
6
+ export interface FrontmatterRenderModel {
7
+ readonly body: string
8
+ readonly fields: readonly FrontmatterField[]
9
+ }
10
+
11
+ const FRONTMATTER_OPEN = "---"
12
+ const FRONTMATTER_CLOSE = "---"
13
+
14
+ const normalizeValue = (raw: string): string => {
15
+ const value = raw.trim()
16
+ if (
17
+ (value.startsWith('"') && value.endsWith('"')) ||
18
+ (value.startsWith("'") && value.endsWith("'"))
19
+ ) {
20
+ return value.slice(1, -1)
21
+ }
22
+ return value
23
+ }
24
+
25
+ const formatKey = (key: string): string => key.replace(/[-_]+/g, " ")
26
+
27
+ const toDisplayField = (key: string, value: string): FrontmatterField => ({
28
+ key: formatKey(key),
29
+ value: normalizeValue(value),
30
+ })
31
+
32
+ export const parseFrontmatter = (content: string): FrontmatterRenderModel => {
33
+ if (
34
+ !content.startsWith(`${FRONTMATTER_OPEN}\n`) &&
35
+ !content.startsWith(`${FRONTMATTER_OPEN}\r\n`)
36
+ ) {
37
+ return { body: content, fields: [] }
38
+ }
39
+
40
+ const lines = content.split(/\r?\n/)
41
+ if (lines[0] !== FRONTMATTER_OPEN) return { body: content, fields: [] }
42
+
43
+ const closeIndex = lines.indexOf(FRONTMATTER_CLOSE, 1)
44
+ if (closeIndex <= 0) return { body: content, fields: [] }
45
+
46
+ const fields: FrontmatterField[] = []
47
+ for (const line of lines.slice(1, closeIndex)) {
48
+ if (line.trim().length === 0) continue
49
+ const colon = line.indexOf(":")
50
+ if (colon <= 0) return { body: content, fields: [] }
51
+ const key = line.slice(0, colon).trim()
52
+ const value = line.slice(colon + 1)
53
+ if (key.length === 0) return { body: content, fields: [] }
54
+ fields.push(toDisplayField(key, value))
55
+ }
56
+
57
+ const body = lines
58
+ .slice(closeIndex + 1)
59
+ .join("\n")
60
+ .replace(/^\n+/, "")
61
+ return { body, fields }
62
+ }