@carlesandres/house 0.4.13 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/CHANGELOG.md +32 -2
  2. package/README.md +10 -8
  3. package/dist/bin.js +92 -0
  4. package/dist/index.js +10005 -0
  5. package/package.json +15 -3
  6. package/src/Browser.tsx +0 -1210
  7. package/src/CommandPalette.tsx +0 -214
  8. package/src/Footer.tsx +0 -258
  9. package/src/Header.tsx +0 -71
  10. package/src/PromptRow.tsx +0 -51
  11. package/src/Spinner.tsx +0 -39
  12. package/src/StatusIndicator.tsx +0 -55
  13. package/src/StatusPopover.tsx +0 -166
  14. package/src/brand.ts +0 -7
  15. package/src/cli/argv.ts +0 -179
  16. package/src/commands/buildCommands.ts +0 -98
  17. package/src/commands/paletteOnlyCommands.ts +0 -27
  18. package/src/commands/score.ts +0 -78
  19. package/src/commands/types.ts +0 -26
  20. package/src/config/load.ts +0 -381
  21. package/src/config/save.ts +0 -95
  22. package/src/discovery/filter.ts +0 -148
  23. package/src/discovery/show.ts +0 -48
  24. package/src/discovery/walk.ts +0 -209
  25. package/src/index.tsx +0 -464
  26. package/src/io/clipboard.ts +0 -53
  27. package/src/io/editor.ts +0 -162
  28. package/src/io/readFile.ts +0 -14
  29. package/src/keymap/browser.ts +0 -335
  30. package/src/keymap/displayKey.ts +0 -17
  31. package/src/keymap/keymap.ts +0 -95
  32. package/src/layout/resolve.ts +0 -52
  33. package/src/layout/sidebarEmptyState.ts +0 -7
  34. package/src/layout/sidebarRow.ts +0 -69
  35. package/src/markdown/frontmatter.ts +0 -62
  36. package/src/serve/css.ts +0 -120
  37. package/src/serve/openBrowser.ts +0 -19
  38. package/src/serve/render.ts +0 -56
  39. package/src/serve/server.ts +0 -166
  40. package/src/theme/atom.ts +0 -23
  41. package/src/theme/colors.ts +0 -86
  42. package/src/theme/loader.ts +0 -110
  43. package/src/theme/registry.ts +0 -12
  44. package/src/theme/resolve.ts +0 -168
  45. package/src/theme/themes/aura.json +0 -58
  46. package/src/theme/themes/ayu.json +0 -69
  47. package/src/theme/themes/carbonfox.json +0 -201
  48. package/src/theme/themes/catppuccin-frappe.json +0 -186
  49. package/src/theme/themes/catppuccin-macchiato.json +0 -186
  50. package/src/theme/themes/catppuccin.json +0 -212
  51. package/src/theme/themes/cobalt2.json +0 -181
  52. package/src/theme/themes/cursor.json +0 -202
  53. package/src/theme/themes/dracula.json +0 -172
  54. package/src/theme/themes/everforest.json +0 -194
  55. package/src/theme/themes/flexoki.json +0 -190
  56. package/src/theme/themes/github.json +0 -186
  57. package/src/theme/themes/gruvbox.json +0 -195
  58. package/src/theme/themes/kanagawa.json +0 -180
  59. package/src/theme/themes/lucent-orng.json +0 -186
  60. package/src/theme/themes/material.json +0 -188
  61. package/src/theme/themes/matrix.json +0 -180
  62. package/src/theme/themes/mercury.json +0 -198
  63. package/src/theme/themes/monokai.json +0 -174
  64. package/src/theme/themes/nightowl.json +0 -174
  65. package/src/theme/themes/nord.json +0 -176
  66. package/src/theme/themes/one-dark.json +0 -184
  67. package/src/theme/themes/opencode.json +0 -198
  68. package/src/theme/themes/orng.json +0 -202
  69. package/src/theme/themes/osaka-jade.json +0 -193
  70. package/src/theme/themes/palenight.json +0 -175
  71. package/src/theme/themes/rosepine.json +0 -187
  72. package/src/theme/themes/solarized.json +0 -180
  73. package/src/theme/themes/synthwave84.json +0 -179
  74. package/src/theme/themes/tokyonight.json +0 -196
  75. package/src/theme/themes/vercel.json +0 -198
  76. package/src/theme/themes/vesper.json +0 -171
  77. package/src/theme/themes/zenburn.json +0 -176
  78. package/src/theme/types.ts +0 -115
  79. package/src/tips.ts +0 -88
  80. package/src/ui/middleTruncate.ts +0 -27
  81. package/src/update/cache.ts +0 -77
  82. package/src/update/check.ts +0 -165
  83. package/src/update/compare.ts +0 -41
  84. package/src/update/notice.ts +0 -29
  85. package/src/update/runtime.ts +0 -48
  86. package/src/update/useUpdateNotice.ts +0 -24
package/src/Browser.tsx DELETED
@@ -1,1210 +0,0 @@
1
- /**
2
- * Browser — two-pane mode: file sidebar (left) + reader (right).
3
- *
4
- * Minimum-viable iteration:
5
- * - j/k or arrow keys move selection in the sidebar.
6
- * - The reader always shows the currently selected file's contents.
7
- * - q / ctrl+c quit.
8
- *
9
- * Deferred to next iteration: focus model, reader scrolling via j/k,
10
- * sidebar collapse with `\`.
11
- */
12
-
13
- import { SyntaxStyle } from "@opentui/core"
14
- import type { BorderSides } from "@opentui/core"
15
- import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
16
- import { useAtomValue, useAtomSet } from "@effect/atom-react"
17
- import { Effect } from "effect"
18
- import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"
19
- import { buildCommands } from "./commands/buildCommands.ts"
20
- import { clampSelectedIndex, filterCommands } from "./commands/score.ts"
21
- import { CommandPalette, orderCommandsForPalette } from "./CommandPalette.tsx"
22
- import { filterFiles } from "./discovery/filter.ts"
23
- import { type FileEntry } from "./discovery/walk.ts"
24
- import { parseFrontmatter } from "./markdown/frontmatter.ts"
25
- import { BRAND, BRAND_NAME } from "./brand.ts"
26
- import { Footer, FOOTER_HEIGHT, type FooterProps } from "./Footer.tsx"
27
- import { Header, HEADER_HEIGHT } from "./Header.tsx"
28
- import { copyTextToClipboard } from "./io/clipboard.ts"
29
- import { openInEditor, resolveEditor } from "./io/editor.ts"
30
- import { readFileText } from "./io/readFile.ts"
31
- import { browserBindings, type BrowserCtx } from "./keymap/browser.ts"
32
- import { dispatch } from "./keymap/keymap.ts"
33
- import { canFitInline, defaultPreferredWidth, resolveSidebarWidth } from "./layout/resolve.ts"
34
- import { fitSidebarEmptyValue } from "./layout/sidebarEmptyState.ts"
35
- import { formatSidebarRow } from "./layout/sidebarRow.ts"
36
- import { PromptRow } from "./PromptRow.tsx"
37
- import { StatusPopoverPanel } from "./StatusPopover.tsx"
38
- import { buildReaderEmptyStateTips, pickTipByRotation } from "./tips.ts"
39
- import { openInBrowser } from "./serve/openBrowser.ts"
40
- import { startServer, type ServerHandle } from "./serve/server.ts"
41
- import { colors, setActiveTheme } from "./theme/colors.ts"
42
- import { themeAtom } from "./theme/atom.ts"
43
- import { themeDefinitions, getThemeDefinition } from "./theme/registry.ts"
44
- import { saveThemePreference } from "./config/save.ts"
45
- import { middleTruncate } from "./ui/middleTruncate.ts"
46
-
47
- export type StartupFocus = "sidebar" | "reader" | "filter"
48
-
49
- export interface BrowserProps {
50
- readonly files: readonly FileEntry[]
51
- readonly initialIndex?: number
52
- /** Initial applied filter query seeded from the CLI positional. */
53
- readonly initialQuery?: string
54
- /** Reader wrap width used when wrapping is enabled. */
55
- readonly wrapWidth?: number
56
- /** Initial reader wrap mode. Runtime toggles are session-only. */
57
- readonly initialWrap?: boolean
58
- /** Discovery root label used in the post-discovery empty-vault sidebar row. */
59
- readonly emptyRootLabel?: string
60
- /** Persistent footer indicator (e.g. "indexing… 42"). Pass null/undefined
61
- * when discovery has finished; the indicator clears. */
62
- readonly discoveryStatus?: string | null
63
- /** Test seam: override the footer discovery spinner speed. */
64
- readonly discoverySpinnerIntervalMs?: number
65
- readonly discoverySpinnerInitialFrameIndex?: number
66
- /** Test seam: deterministic footer spinner driver. */
67
- readonly discoverySpinnerRegisterTick?: ((tick: () => void) => void) | null
68
- /** Test seam: override filter debounce timing. */
69
- readonly filterDebounceMs?: number
70
- /** Test seam: override rendered-path debounce timing. */
71
- readonly renderedPathDebounceMs?: number
72
- /** Test seam: disable reader-empty-state tip rotation effect. */
73
- readonly disableReaderEmptyStateRotation?: boolean
74
- readonly onQuit?: () => void
75
- /** Test seam: replaces the file reader. */
76
- readonly readFile?: (path: string) => Promise<string>
77
- /** Test seam: replaces the system clipboard writer. */
78
- readonly copyToClipboard?: (text: string) => Promise<void>
79
- /** Optional one-shot footer toast surfaced on first appearance (e.g. the
80
- * "update available" nudge). Shown with an extended TTL so the user has
81
- * time to read it; subsequent transient toasts (theme cycle, etc.)
82
- * preempt it via the same single-slot channel. Null disables. */
83
- readonly updateNotice?: string | null
84
- /** TTL (ms) for the update-notice toast. Exposed so tests can use a small
85
- * value instead of sleeping for the production 10s window. */
86
- readonly updateNoticeTtlMs?: number
87
- /** Test seam: disable footer-notice auto-clear timers. */
88
- readonly disableFooterNoticeAutoClear?: boolean
89
- /** Flip the parent's discovery vocabulary (#145). Browser doesn't need
90
- * to know which categories are currently on — the toggle is opaque
91
- * from this side; we just snapshot the selected path so it can be
92
- * restored across the re-walk the parent triggers. */
93
- readonly onToggleAll?: () => void
94
- /** Startup pane/input target. `filter` opens the sidebar filter prompt on
95
- * mount so the user can type immediately. */
96
- readonly startupFocus?: StartupFocus | null
97
- }
98
-
99
- const defaultReadFile = (path: string): Promise<string> => Effect.runPromise(readFileText(path))
100
-
101
- const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n))
102
-
103
- let nextReaderEmptyStateTipRotation = 0
104
-
105
- export const resetReaderEmptyStateTipRotationForTests = () => {
106
- nextReaderEmptyStateTipRotation = 0
107
- }
108
-
109
- export const setReaderEmptyStateTipRotationForTests = (next: number) => {
110
- nextReaderEmptyStateTipRotation = next
111
- }
112
-
113
- const FILTER_DEBOUNCE_MS = 50
114
- const RENDERED_PATH_DEBOUNCE_MS = 80
115
-
116
- const isPartialDiscoveryWarning = (status: string | null | undefined): boolean =>
117
- status?.trimStart().startsWith("scan incomplete:") ?? false
118
-
119
- type FloatingOverlay =
120
- | { readonly kind: "none" }
121
- | { readonly kind: "command-palette" }
122
- | { readonly kind: "status-popover"; readonly content: string }
123
-
124
- type FloatingOverlayAction =
125
- | { readonly type: "close" }
126
- | { readonly type: "open-command-palette" }
127
- | { readonly type: "toggle-status-popover"; readonly content: string }
128
- | { readonly type: "update-status-popover"; readonly content: string }
129
-
130
- const noFloatingOverlay: FloatingOverlay = { kind: "none" }
131
-
132
- const floatingOverlayReducer = (
133
- state: FloatingOverlay,
134
- action: FloatingOverlayAction,
135
- ): FloatingOverlay => {
136
- switch (action.type) {
137
- case "close":
138
- return noFloatingOverlay
139
- case "open-command-palette":
140
- return { kind: "command-palette" }
141
- case "toggle-status-popover":
142
- return state.kind === "status-popover"
143
- ? noFloatingOverlay
144
- : { kind: "status-popover", content: action.content }
145
- case "update-status-popover":
146
- return state.kind === "status-popover"
147
- ? { kind: "status-popover", content: action.content }
148
- : state
149
- }
150
- }
151
-
152
- const SidebarEmptyMessage = ({
153
- label,
154
- value,
155
- width,
156
- withTopSpacer,
157
- }: {
158
- readonly label: string
159
- readonly value: string
160
- readonly width: number
161
- readonly withTopSpacer: boolean
162
- }) => (
163
- <>
164
- {withTopSpacer && <text content="" />}
165
- <box
166
- style={{
167
- width,
168
- flexDirection: "row",
169
- flexWrap: "wrap",
170
- justifyContent: "center",
171
- gap: 1,
172
- }}
173
- >
174
- <text wrapMode="none">
175
- <span style={{ fg: colors.textMuted }}>{label}</span>
176
- </text>
177
- <text wrapMode="none">
178
- <span style={{ fg: colors.textMuted }}>{`"${fitSidebarEmptyValue(value, width)}"`}</span>
179
- </text>
180
- </box>
181
- </>
182
- )
183
-
184
- export const Browser = ({
185
- files,
186
- initialIndex = 0,
187
- initialQuery = "",
188
- wrapWidth = 80,
189
- initialWrap = false,
190
- emptyRootLabel = "current root",
191
- discoveryStatus = null,
192
- discoverySpinnerIntervalMs,
193
- discoverySpinnerInitialFrameIndex,
194
- discoverySpinnerRegisterTick = null,
195
- filterDebounceMs = FILTER_DEBOUNCE_MS,
196
- renderedPathDebounceMs = RENDERED_PATH_DEBOUNCE_MS,
197
- disableReaderEmptyStateRotation = false,
198
- onQuit,
199
- readFile = defaultReadFile,
200
- copyToClipboard = copyTextToClipboard,
201
- updateNotice = null,
202
- updateNoticeTtlMs = 10000,
203
- disableFooterNoticeAutoClear = false,
204
- onToggleAll,
205
- startupFocus = null,
206
- }: BrowserProps) => {
207
- const renderer = useRenderer()
208
- const { width, height } = useTerminalDimensions()
209
- const theme = useAtomValue(themeAtom)
210
- const setTheme = useAtomSet(themeAtom)
211
- const syntaxStyle = useMemo(() => SyntaxStyle.fromStyles(colors.syntax), [theme])
212
-
213
- const [selectedIndex, setSelectedIndex] = useState(() =>
214
- clamp(initialIndex, 0, Math.max(0, files.length - 1)),
215
- )
216
- const [wrapEnabled, setWrapEnabled] = useState(initialWrap)
217
- const [loaded, setLoaded] = useState<{ path: string; content: string } | null>(null)
218
- const [error, setError] = useState<string | null>(null)
219
- // `shown` is the user's sticky interactive preference. The sidebar starts
220
- // visible; after launch, `s`/`tab`/`/` may hide or reveal it. Visibility is
221
- // derived: `visible = shown || focus === "sidebar"`. See DESIGN.md §7.1.
222
- const [shown, setShown] = useState<boolean>(true)
223
- const startInFilter = startupFocus === "filter"
224
- const initialFocus: "sidebar" | "reader" =
225
- startupFocus === null
226
- ? shown
227
- ? "sidebar"
228
- : "reader"
229
- : startupFocus === "reader"
230
- ? "reader"
231
- : "sidebar"
232
- // `filter` mirrors `openFilter`'s focus rule: the filter input lives in
233
- // the sidebar, so opening it on mount also forces sidebar focus regardless
234
- // of the current interactive visibility state (§7.1's visibility derivation
235
- // surfaces the sidebar via focus even when `shown` is false). Plain
236
- // `sidebar` startup shares the same pane focus without opening the prompt.
237
- // When omitted, preserve the legacy Browser behavior: initial focus follows
238
- // visibility.
239
- const [focus, setFocus] = useState<"sidebar" | "reader">(() => initialFocus)
240
- const [sidebarScroll, setSidebarScroll] = useState<number>(0)
241
- const [filterOpen, setFilterOpen] = useState<boolean>(startInFilter)
242
- const [filterInput, setFilterInput] = useState<string>(initialQuery)
243
- const [filterApplied, setFilterApplied] = useState<string>(initialQuery)
244
- const [floatingOverlay, dispatchFloatingOverlayState] = useReducer(
245
- floatingOverlayReducer,
246
- noFloatingOverlay,
247
- )
248
- const floatingOverlayRef = useRef<FloatingOverlay>(noFloatingOverlay)
249
- const dispatchFloatingOverlay = (action: FloatingOverlayAction): void => {
250
- const next = floatingOverlayReducer(floatingOverlayRef.current, action)
251
- floatingOverlayRef.current = next
252
- dispatchFloatingOverlayState(action)
253
- }
254
- const closeFloatingOverlay = (): void => dispatchFloatingOverlay({ type: "close" })
255
- const paletteOpen = floatingOverlay.kind === "command-palette"
256
- const activeStatusPopover = floatingOverlay.kind === "status-popover" ? floatingOverlay : null
257
- const discoveryWarningStatus = isPartialDiscoveryWarning(discoveryStatus) ? discoveryStatus : null
258
- const [paletteQuery, setPaletteQuery] = useState<string>("")
259
- const [paletteIndex, setPaletteIndex] = useState<number>(0)
260
- // Synchronous mirrors for the keyboard handler — same reason filterOpenRef
261
- // exists. Modal input can arrive in one React batch (e.g. ctrl+p, Down,
262
- // Return), so every palette field read by later keys must update its ref
263
- // before React state commits.
264
- const paletteQueryRef = useRef("")
265
- const paletteIndexRef = useRef(0)
266
- const [readerEmptyStateTipRotation, setReaderEmptyStateTipRotation] = useState(
267
- () => nextReaderEmptyStateTipRotation,
268
- )
269
- const readerEmptyStateVisibleRef = useRef(false)
270
- // Mirror filter state into refs so the keyboard handler sees synchronous
271
- // updates even when multiple keys arrive in a single React batch (the
272
- // first key opens the filter; subsequent keys in the same tick would
273
- // otherwise still observe filterOpen=false through closure).
274
- const filterOpenRef = useRef(startInFilter)
275
- const filterInputRef = useRef(initialQuery)
276
- const filterAppliedRef = useRef(initialQuery)
277
- const autoSelectForAppliedFilterRef = useRef(true)
278
- const focusRef = useRef<"sidebar" | "reader">(focus)
279
- const restoreFilterOnSidebarFocusRef = useRef(startInFilter)
280
- const [footerNotice, setFooterNoticeState] = useState<{
281
- readonly text: string
282
- readonly ttlMs: number
283
- } | null>(null)
284
- const pushFooterNotice = (text: string, ttlMs = 2000): void =>
285
- setFooterNoticeState({ text, ttlMs })
286
- const serverRef = useRef<ServerHandle | null>(null)
287
- // #145 selection preservation across an `all` re-walk. When the user
288
- // toggles, we snapshot the currently selected path; once the new file set
289
- // streams in, we restore selection by path. If the path isn't present in
290
- // the new set (e.g. it was a hidden file and `all` just went off), the
291
- // ref stays armed so toggling back later re-selects it. Any user-driven
292
- // selection move (j/k/g/G/click) clears it — user intent has moved on.
293
- const pendingSelectionPathRef = useRef<string | null>(null)
294
-
295
- // Stop the preview server on unmount so re-mounts (tests) and clean
296
- // shutdowns don't leak a listening socket.
297
- useEffect(() => {
298
- return () => {
299
- void serverRef.current?.stop()
300
- serverRef.current = null
301
- }
302
- }, [])
303
-
304
- // Single-slot notice with a per-message TTL. A new notice cancels the
305
- // pending timer so the latest message gets its own full window. The TTL
306
- // travels with the message so a long-lived nudge (update available) and a
307
- // transient toast (theme cycle) can share one slot without one stealing
308
- // the other's display window.
309
- useEffect(() => {
310
- if (footerNotice === null) return
311
- closeFloatingOverlay()
312
- if (disableFooterNoticeAutoClear) return
313
- const timer = setTimeout(() => setFooterNoticeState(null), footerNotice.ttlMs)
314
- return () => clearTimeout(timer)
315
- }, [disableFooterNoticeAutoClear, footerNotice])
316
-
317
- useEffect(() => {
318
- if (discoveryWarningStatus === null) {
319
- if (floatingOverlay.kind === "status-popover") closeFloatingOverlay()
320
- return
321
- }
322
- if (
323
- floatingOverlay.kind === "status-popover" &&
324
- floatingOverlay.content !== discoveryWarningStatus
325
- ) {
326
- dispatchFloatingOverlay({ type: "update-status-popover", content: discoveryWarningStatus })
327
- }
328
- }, [discoveryWarningStatus, floatingOverlay])
329
-
330
- // Push the update-available nudge once, when it arrives from the parent
331
- // (the registry probe resolves asynchronously after boot). 10s gives the
332
- // user time to read it before it auto-clears; the quit-time stderr print
333
- // is the durable record they can copy from scrollback.
334
- const updateNoticeSeenRef = useRef<string | null>(null)
335
- useEffect(() => {
336
- focusRef.current = focus
337
- }, [focus])
338
-
339
- useEffect(() => {
340
- if (!updateNotice) return
341
- if (updateNoticeSeenRef.current === updateNotice) return
342
- updateNoticeSeenRef.current = updateNotice
343
- pushFooterNotice(updateNotice, updateNoticeTtlMs)
344
- }, [updateNotice, updateNoticeTtlMs])
345
-
346
- const cycleTheme = (delta: 1 | -1) => {
347
- const idx = themeDefinitions.findIndex((d) => d.id === theme.id)
348
- const next = themeDefinitions[(idx + delta + themeDefinitions.length) % themeDefinitions.length]
349
- if (!next) return
350
- setActiveTheme(next, theme.tone)
351
- setTheme({ id: next.id, tone: theme.tone })
352
- void saveThemePreference({ theme: next.id, tone: theme.tone }).catch((err) => {
353
- pushFooterNotice("theme not saved")
354
- process.stderr.write(
355
- `house: failed to save theme preference: ${err instanceof Error ? err.message : String(err)}\n`,
356
- )
357
- })
358
- pushFooterNotice(`theme: ${next.name}`)
359
- }
360
-
361
- const toggleTone = () => {
362
- const nextTone = theme.tone === "dark" ? "light" : "dark"
363
- const def = getThemeDefinition(theme.id)
364
- if (def) setActiveTheme(def, nextTone)
365
- setTheme({ id: theme.id, tone: nextTone })
366
- void saveThemePreference({ theme: theme.id, tone: nextTone }).catch((err) => {
367
- pushFooterNotice("theme not saved")
368
- process.stderr.write(
369
- `house: failed to save theme preference: ${err instanceof Error ? err.message : String(err)}\n`,
370
- )
371
- })
372
- pushFooterNotice(`tone: ${nextTone}`)
373
- }
374
-
375
- useEffect(() => {
376
- if (filterInput === filterApplied) return
377
- const timer = setTimeout(() => {
378
- filterAppliedRef.current = filterInput
379
- setFilterApplied(filterInput)
380
- }, filterDebounceMs)
381
- return () => clearTimeout(timer)
382
- }, [filterApplied, filterDebounceMs, filterInput])
383
-
384
- useEffect(() => {
385
- autoSelectForAppliedFilterRef.current = true
386
- }, [filterApplied])
387
-
388
- const displayedFiles = useMemo(() => filterFiles(files, filterApplied), [files, filterApplied])
389
- const filterHasNoMatches = filterInput.length > 0 && displayedFiles.length === 0
390
-
391
- useEffect(() => {
392
- if (filterApplied.length === 0) return
393
- if (!autoSelectForAppliedFilterRef.current) return
394
- if (displayedFiles.length === 0) return
395
- setSelectedIndex(0)
396
- autoSelectForAppliedFilterRef.current = false
397
- }, [displayedFiles, filterApplied])
398
- // When the filtered list shrinks, keep selectedIndex valid. The reset to 0
399
- // on every query change happens in the keystroke handler, not here, so a
400
- // no-op rerender doesn't snap the cursor back to the top.
401
- useEffect(() => {
402
- if (selectedIndex >= displayedFiles.length) {
403
- setSelectedIndex(displayedFiles.length === 0 ? 0 : displayedFiles.length - 1)
404
- }
405
- }, [displayedFiles.length, selectedIndex])
406
-
407
- // #145 selection restoration. Runs whenever the displayed file list
408
- // changes (re-walk batches, filter changes). If the user has a path
409
- // armed (set by toggleAll) and it now appears in the displayed subset,
410
- // restore selection to that index and disarm. If the path is not
411
- // present, keep the ref armed — toggling back later (or future stream
412
- // batches in the same toggle) will find it.
413
- useEffect(() => {
414
- const target = pendingSelectionPathRef.current
415
- if (target === null) return
416
- const idx = displayedFiles.findIndex((f) => f.path === target)
417
- if (idx >= 0) {
418
- setSelectedIndex(idx)
419
- pendingSelectionPathRef.current = null
420
- }
421
- }, [displayedFiles])
422
-
423
- const selected = displayedFiles[selectedIndex]
424
-
425
- // Track the path whose content is currently rendered. Updated lazily via
426
- // a debounce: rapid j/k presses don't trigger a load+<markdown>-reflow
427
- // per keystroke. The reflow is the synchronous, main-thread-blocking
428
- // step inside opentui's host commit — useDeferredValue can't yield once
429
- // the host begins it. A real debounce gates the load itself.
430
- const [renderedPath, setRenderedPath] = useState<string | null>(selected?.path ?? null)
431
-
432
- useEffect(() => {
433
- const target = selected?.path ?? null
434
- if (target === renderedPath) return
435
- const timer = setTimeout(() => setRenderedPath(target), renderedPathDebounceMs)
436
- return () => clearTimeout(timer)
437
- }, [selected?.path, renderedPath, renderedPathDebounceMs])
438
-
439
- useEffect(() => {
440
- if (!renderedPath) {
441
- setLoaded(null)
442
- return
443
- }
444
- let cancelled = false
445
- readFile(renderedPath).then(
446
- (text) => {
447
- if (!cancelled) {
448
- setLoaded({ path: renderedPath, content: text })
449
- setError(null)
450
- }
451
- },
452
- (err: unknown) => {
453
- if (!cancelled) {
454
- setLoaded(null)
455
- setError(`Cannot read ${renderedPath}: ${String(err)}`)
456
- }
457
- },
458
- )
459
- return () => {
460
- cancelled = true
461
- }
462
- }, [renderedPath, readFile])
463
-
464
- // One BrowserCtx per render, reused by the keyboard handler and the
465
- // footer's `when`-evaluation. Keeping a single object eliminates the
466
- // drift risk between the two consumers as BrowserCtx grows.
467
- //
468
- // `files` in ctx refers to the *displayed* list (post-filter) so that
469
- // keymap when-clauses like `haveFiles` and selection-index actions
470
- // operate on what the user actually sees.
471
- const ctx: BrowserCtx = {
472
- files: displayedFiles,
473
- hasSelected: selected != null,
474
- focus,
475
- sidebarShown: shown,
476
- filterOpen,
477
- restoreFilterOnSidebarFocus: restoreFilterOnSidebarFocusRef.current,
478
- filterQuery: filterInput,
479
- paletteOpen,
480
- wrapEnabled,
481
- setFocus,
482
- // Wrapped so any keymap-driven selection move (j/k/g/G/[/], reader
483
- // prev/next) clears the pending-restore ref from #145. Internal
484
- // callers that should NOT clear pending (the filter modal's typing
485
- // branch, the post-filter clamp effect, the restoration effect
486
- // itself) deliberately use the raw `setSelectedIndex` setter.
487
- setSelectedIndex: (updater) => {
488
- pendingSelectionPathRef.current = null
489
- autoSelectForAppliedFilterRef.current = false
490
- setSelectedIndex(updater)
491
- },
492
- toggleShown: () => {
493
- // Two layout shapes, two behaviors:
494
- // wide → flip the sticky `shown` preference. Per DESIGN.md §7.1
495
- // s-behavior table: also nudge focus so the visibility
496
- // rule (`visible = shown || focus==="sidebar"`) reflects
497
- // the user's intent instead of forcing the sidebar back
498
- // on via focus.
499
- // narrow → swap which screen is up (focus is the source of truth
500
- // for render). Sync `shown` to the new screen so a later
501
- // resize to wide opens with the right pane visible.
502
- if (!canFitInline(width)) {
503
- const next = focus === "sidebar" ? "reader" : "sidebar"
504
- setFocus(next)
505
- setShown(next === "sidebar")
506
- return
507
- }
508
- if (shown) {
509
- setShown(false)
510
- if (focus === "sidebar") setFocus("reader")
511
- } else {
512
- setShown(true)
513
- if (focus === "reader") setFocus("sidebar")
514
- }
515
- },
516
- openFilter: () => {
517
- closeFloatingOverlay()
518
- // Focus the sidebar so the filter input has a home. In wide,
519
- // §7.1's visibility rule (`shown || focus === "sidebar"`) brings
520
- // the inline sidebar back on screen if it was hidden. In narrow,
521
- // focusing the sidebar swaps to the sidebar screen. Either way
522
- // no need to mutate `shown`.
523
- focusRef.current = "sidebar"
524
- if (focus !== "sidebar") setFocus("sidebar")
525
- restoreFilterOnSidebarFocusRef.current = true
526
- filterOpenRef.current = true
527
- setFilterOpen(true)
528
- },
529
- clearAndOpenFilter: () => {
530
- closeFloatingOverlay()
531
- // Reset both the ref and the state so the freshly-opened modal
532
- // shows an empty input and selection lands on the first file in
533
- // the (now unfiltered) list.
534
- filterInputRef.current = ""
535
- filterAppliedRef.current = ""
536
- setFilterInput("")
537
- setFilterApplied("")
538
- setSelectedIndex(() => 0)
539
- focusRef.current = "sidebar"
540
- if (focus !== "sidebar") setFocus("sidebar")
541
- restoreFilterOnSidebarFocusRef.current = true
542
- filterOpenRef.current = true
543
- setFilterOpen(true)
544
- },
545
- openPalette: () => {
546
- // Reset query/index so each open starts fresh (no stale state from
547
- // the previous session).
548
- paletteQueryRef.current = ""
549
- setPaletteQuery("")
550
- paletteIndexRef.current = 0
551
- setPaletteIndex(0)
552
- dispatchFloatingOverlay({ type: "open-command-palette" })
553
- },
554
- toggleWrap: () => setWrapEnabled((prev) => !prev),
555
- cycleTheme,
556
- toggleTone,
557
- toggleAll: () => {
558
- // Snapshot the currently displayed selection unless a snapshot is
559
- // already armed (a prior toggle's selection survived the re-walk
560
- // and is still waiting to come back). The armed path is the one
561
- // the user originally chose; preserving it across a toggle-off /
562
- // toggle-on round-trip is the headline ergonomic of #145.
563
- // User-driven nav (j/k/g/G via the wrapped setSelectedIndex below)
564
- // clears pending, so a follow-up toggle starts a fresh snapshot.
565
- if (pendingSelectionPathRef.current === null && selected) {
566
- pendingSelectionPathRef.current = selected.path
567
- }
568
- onToggleAll?.()
569
- },
570
- serveCurrent: () => {
571
- const file = displayedFiles[selectedIndex]
572
- if (!file) return
573
- let handle = serverRef.current
574
- if (!handle) {
575
- try {
576
- handle = startServer({ path: file.path })
577
- serverRef.current = handle
578
- openInBrowser(handle.url)
579
- pushFooterNotice(`serving at ${handle.url}`)
580
- } catch (err) {
581
- pushFooterNotice(`serve failed: ${String(err)}`)
582
- }
583
- return
584
- }
585
- if (handle.currentTarget() !== file.path) {
586
- handle.setTarget(file.path)
587
- }
588
- // Always re-open: if the user closed the tab, retargeting alone
589
- // would leave them with nothing visible. `open`/`xdg-open` focus
590
- // an existing tab on the same URL when one is open, so this is
591
- // idempotent for the common case.
592
- openInBrowser(handle.url)
593
- pushFooterNotice(`serving ${file.relativePath} at ${handle.url}`)
594
- },
595
- quit: () => {
596
- if (onQuit) {
597
- onQuit()
598
- return
599
- }
600
- renderer?.destroy()
601
- process.exit(0)
602
- },
603
- editCurrent: () => {
604
- const file = displayedFiles[selectedIndex]
605
- if (!file) return
606
- const editor = resolveEditor(process.env)
607
- if (!editor) {
608
- pushFooterNotice("set $EDITOR or $VISUAL to use E")
609
- return
610
- }
611
- if (!renderer) {
612
- // Test environments without a real renderer (e.g. testRender's
613
- // host) don't expose suspend/resume. Nothing safe to do here.
614
- pushFooterNotice("editor unavailable in this environment")
615
- return
616
- }
617
- // Fire-and-forget: useKeyboard's run() is synchronous, but the
618
- // editor session is naturally async. We always re-enter the
619
- // renderer in the finally block so a thrown error never leaves
620
- // the user staring at a dead terminal.
621
- void (async () => {
622
- renderer.suspend()
623
- renderer.currentRenderBuffer.clear()
624
- let result
625
- try {
626
- result = await openInEditor({ editor, filePath: file.path })
627
- } finally {
628
- renderer.currentRenderBuffer.clear()
629
- renderer.resume()
630
- renderer.requestRender()
631
- }
632
- // Only reload the in-memory cache when the edited file is the
633
- // one currently displayed. Editing a sidebar-selected file that
634
- // the reader hasn't caught up to (debounce in flight) is fine —
635
- // the regular load path picks up the new mtime when renderedPath
636
- // advances.
637
- if (file.path === renderedPath) {
638
- try {
639
- const text = await readFile(file.path)
640
- setLoaded({ path: file.path, content: text })
641
- setError(null)
642
- } catch (err) {
643
- const message = String(err)
644
- const enoent =
645
- (err as { code?: string } | null)?.code === "ENOENT" || message.includes("ENOENT")
646
- if (enoent) {
647
- pushFooterNotice(`${file.relativePath} no longer exists`)
648
- setError(`Cannot read ${file.path}: ${message}`)
649
- setLoaded(null)
650
- } else {
651
- pushFooterNotice(`reload failed: ${message}`)
652
- }
653
- }
654
- }
655
- if (!result.ok) {
656
- if (result.reason === "spawn-failed") {
657
- pushFooterNotice(`editor not found: ${editor.cmd}`)
658
- } else if (result.reason === "non-zero") {
659
- pushFooterNotice(`editor exited ${result.detail}`)
660
- }
661
- }
662
- })()
663
- },
664
- copyCurrentContents: () => {
665
- const file = displayedFiles[selectedIndex]
666
- if (!file) return
667
- void (async () => {
668
- let text: string
669
- try {
670
- text = await readFile(file.path)
671
- } catch {
672
- pushFooterNotice(`copy failed: cannot read ${file.relativePath}`)
673
- return
674
- }
675
- try {
676
- await copyToClipboard(text)
677
- pushFooterNotice(`copied ${file.relativePath}`)
678
- } catch (err) {
679
- const message = err instanceof Error ? err.message : String(err)
680
- pushFooterNotice(`copy failed: ${message}`)
681
- }
682
- })()
683
- },
684
- }
685
-
686
- useKeyboard((key) => {
687
- // Command palette modal: capture keystrokes for the query input and
688
- // list navigation. This branch intentionally runs before the filter
689
- // branch so ctrl+p can open the palette from filter mode while the
690
- // palette still owns Esc/arrows/Return until it closes.
691
- if (floatingOverlayRef.current.kind === "command-palette") {
692
- const closePalette = () => {
693
- paletteQueryRef.current = ""
694
- paletteIndexRef.current = 0
695
- closeFloatingOverlay()
696
- setPaletteQuery("")
697
- setPaletteIndex(0)
698
- }
699
- const setPaletteIndexSync = (next: number) => {
700
- paletteIndexRef.current = next
701
- setPaletteIndex(next)
702
- }
703
- const allCommands = buildCommands(ctx)
704
- const filtered = orderCommandsForPalette(filterCommands(allCommands, paletteQueryRef.current))
705
- if (key.name === "escape") {
706
- closePalette()
707
- return
708
- }
709
- if (key.name === "return") {
710
- const picked = filtered[clampSelectedIndex(paletteIndexRef.current, filtered)]
711
- closePalette()
712
- picked?.run()
713
- return
714
- }
715
- if (key.name === "up") {
716
- setPaletteIndexSync(Math.max(0, paletteIndexRef.current - 1))
717
- return
718
- }
719
- if (key.name === "down") {
720
- setPaletteIndexSync(Math.min(Math.max(0, filtered.length - 1), paletteIndexRef.current + 1))
721
- return
722
- }
723
- if (key.name === "backspace" || key.name === "delete") {
724
- if (paletteQueryRef.current.length === 0) return
725
- paletteQueryRef.current = paletteQueryRef.current.slice(0, -1)
726
- setPaletteQuery(paletteQueryRef.current)
727
- setPaletteIndexSync(0)
728
- return
729
- }
730
- // ctrl+p again closes.
731
- if (key.ctrl && !key.meta && key.name === "p") {
732
- closePalette()
733
- return
734
- }
735
- if (key.ctrl || key.meta) return
736
- let char: string | null = null
737
- if (key.name === "space") char = " "
738
- else if (typeof key.name === "string" && key.name.length === 1) {
739
- char = key.shift ? key.name.toUpperCase() : key.name
740
- }
741
- if (char !== null) {
742
- paletteQueryRef.current = paletteQueryRef.current + char
743
- setPaletteQuery(paletteQueryRef.current)
744
- setPaletteIndexSync(0)
745
- }
746
- return
747
- }
748
-
749
- // Filter modal: capture keystrokes for the input. Esc closes,
750
- // leaving the typed query applied as the active filter; Return
751
- // closes and focuses the reader (open the match); Ctrl+\ clears
752
- // the input but stays in filter mode (same binding used from
753
- // outside the modal — single chord, single mental model. Ctrl+U
754
- // is deliberately not overloaded here; it stays reserved for its
755
- // sidebar/reader half-page-up role); Backspace edits; Up/Down
756
- // navigate the filtered list; printable characters extend the
757
- // query and reset selection to 0. Everything else is swallowed
758
- // so normal bindings (j/k as nav, `s`, `t`, …) don't fire while
759
- // the user is typing. This sits outside the data-driven keymap
760
- // for the same reason the help branch does — see DESIGN.md §12.
761
- if (filterOpenRef.current && focusRef.current === "sidebar") {
762
- // One close path used by both Esc and Return. `commit=true` is
763
- // the Return semantic (open the match in the reader); false is
764
- // Esc (stop typing, keep the applied filter, stay in sidebar).
765
- const closeFilter = (commit: boolean) => {
766
- filterAppliedRef.current = filterInputRef.current
767
- setFilterApplied(filterInputRef.current)
768
- const picked = displayedFiles[selectedIndex] ?? null
769
- const effectiveCommit = commit && picked !== null
770
- restoreFilterOnSidebarFocusRef.current = false
771
- filterOpenRef.current = false
772
- setFilterOpen(false)
773
- // Where focus lands after the filter closes:
774
- // commit (Return on a real pick) → reader. The user asked
775
- // to open the match; show them what they picked.
776
- // otherwise → sidebar if it's up so j/k keeps walking the
777
- // filtered list; reader if the sidebar was hidden.
778
- if (effectiveCommit) {
779
- focusRef.current = "reader"
780
- setFocus("reader")
781
- } else {
782
- const nextFocus = shown ? "sidebar" : "reader"
783
- focusRef.current = nextFocus
784
- setFocus(nextFocus)
785
- }
786
- }
787
- if (key.name === "escape") {
788
- closeFilter(false)
789
- return
790
- }
791
- if (key.name === "return") {
792
- closeFilter(true)
793
- return
794
- }
795
- if (key.name === "tab" || (key.ctrl && key.name === "i" && !key.shift && !key.meta)) {
796
- focusRef.current = "reader"
797
- restoreFilterOnSidebarFocusRef.current = true
798
- filterOpenRef.current = false
799
- setFilterOpen(false)
800
- setFocus("reader")
801
- return
802
- }
803
- if (key.ctrl && !key.meta && key.name === "p") {
804
- ctx.openPalette()
805
- return
806
- }
807
- if (key.ctrl && key.name === "\\") {
808
- // Same action as the `filter.clearOrOpen` binding fires from
809
- // outside the modal: clear the query, reset selection. The
810
- // keymap doesn't see keys in filter mode, so this branch is
811
- // the in-modal half of that single chord.
812
- filterInputRef.current = ""
813
- filterAppliedRef.current = ""
814
- setFilterInput("")
815
- setFilterApplied("")
816
- setSelectedIndex(() => 0)
817
- return
818
- }
819
- if (key.name === "backspace" || key.name === "delete") {
820
- // Backspace on empty input closes the modal — the leading `/`
821
- // chevron is the last thing left to "delete."
822
- if (filterInputRef.current.length === 0) {
823
- closeFilter(false)
824
- return
825
- }
826
- filterInputRef.current = filterInputRef.current.slice(0, -1)
827
- setFilterInput(filterInputRef.current)
828
- setSelectedIndex(() => 0)
829
- return
830
- }
831
- if (key.name === "up") {
832
- setSelectedIndex((i) => Math.max(0, i - 1))
833
- return
834
- }
835
- if (key.name === "down") {
836
- setSelectedIndex((i) => Math.min(Math.max(0, displayedFiles.length - 1), i + 1))
837
- return
838
- }
839
- if (key.ctrl || key.meta) return
840
- let char: string | null = null
841
- if (key.name === "space") char = " "
842
- else if (typeof key.name === "string" && key.name.length === 1) {
843
- char = key.shift ? key.name.toUpperCase() : key.name
844
- }
845
- if (char !== null) {
846
- filterInputRef.current = filterInputRef.current + char
847
- setFilterInput(filterInputRef.current)
848
- setSelectedIndex(() => 0)
849
- }
850
- return
851
- }
852
- dispatch(browserBindings, ctx, key)
853
- })
854
-
855
- // Sidebar width is a pure function of viewport (DESIGN.md §7.1). Until
856
- // persistent config (#13) lands, `preferred` is derived from viewport,
857
- // matching the pre-#22 inline math.
858
- const sidebarWidth = resolveSidebarWidth(width, defaultPreferredWidth(width))
859
- const sidebarActive = focus === "sidebar"
860
- const readerActive = focus === "reader"
861
- // Wide vs narrow drives the entire layout shape.
862
- // wide → inline two-pane (today). visible = shown || focus==="sidebar".
863
- // narrow → single-pane stack: whichever pane has focus fills the area.
864
- // `shown` is silently ignored for render in narrow; `focus`
865
- // is the single source of truth.
866
- // See DESIGN.md §7.1.
867
- const isNarrow = !canFitInline(width)
868
- const sidebarInline = isNarrow ? sidebarActive : shown || sidebarActive
869
- const readerVisible = isNarrow ? readerActive : true
870
- // When fixed-width wrapping is enabled, never size markdown wider than the
871
- // visible reader body. The scrollbox deliberately disables horizontal
872
- // scrolling, so an over-wide markdown node clips instead of wrapping.
873
- const readerPaneWidth = isNarrow || !sidebarInline ? width : Math.max(1, width - sidebarWidth)
874
- const readerBodyWidth = Math.max(1, readerPaneWidth - 2) // reader padding: 1 left + 1 right
875
- const markdownWidth = wrapEnabled ? Math.min(wrapWidth, readerBodyWidth) : "100%"
876
- // Currently-selected file shown in the Header (which replaced the
877
- // per-pane border title that used to carry this information).
878
- const currentFile = selected?.relativePath ?? null
879
- const content = loaded?.path === renderedPath ? loaded.content : ""
880
- const parsedContent = useMemo(() => parseFrontmatter(content), [content])
881
- const readerEmptyStateTitle = filterHasNoMatches
882
- ? `No files match: ${filterInput}`
883
- : `${BRAND} ${BRAND_NAME}`
884
- const readerEmptyStateVisible = error == null && renderedPath == null
885
-
886
- useEffect(() => {
887
- if (disableReaderEmptyStateRotation) return
888
- if (readerEmptyStateVisible) {
889
- if (!readerEmptyStateVisibleRef.current) {
890
- readerEmptyStateVisibleRef.current = true
891
- setReaderEmptyStateTipRotation(nextReaderEmptyStateTipRotation++)
892
- }
893
- return
894
- }
895
- readerEmptyStateVisibleRef.current = false
896
- }, [disableReaderEmptyStateRotation, readerEmptyStateVisible])
897
-
898
- // Sidebar virtualization: render only the visible window. Without this,
899
- // every keystroke re-renders all N file rows even though only the bg of
900
- // two of them changed (old + new selected). On a 195-file vault that
901
- // dominates the per-keystroke cost.
902
- // Chrome budget: header + pane top border + pane bottom border + footer.
903
- // The filter row eats one more cell when files are present *or* while
904
- // discovery is in flight (allocates the row up front so it doesn't pop
905
- // in when the first file arrives).
906
- const discoveryActive = discoveryStatus !== null && discoveryStatus.length > 0
907
- const filterRowVisible = files.length > 0 || discoveryActive
908
- const sidebarBodyHeight = Math.max(
909
- 1,
910
- height - FOOTER_HEIGHT - HEADER_HEIGHT - 2 - (filterRowVisible ? 1 : 0),
911
- )
912
- const maxScroll = Math.max(0, displayedFiles.length - sidebarBodyHeight)
913
- const desiredScroll = (() => {
914
- let s = sidebarScroll
915
- if (selectedIndex < s) s = selectedIndex
916
- else if (selectedIndex >= s + sidebarBodyHeight) s = selectedIndex - sidebarBodyHeight + 1
917
- return clamp(s, 0, maxScroll)
918
- })()
919
- useEffect(() => {
920
- if (desiredScroll !== sidebarScroll) setSidebarScroll(desiredScroll)
921
- }, [desiredScroll, sidebarScroll])
922
- const visibleFiles = displayedFiles.slice(desiredScroll, desiredScroll + sidebarBodyHeight)
923
- // Available width for sidebar text rows. Wide-inline: sidebarWidth minus
924
- // 1-cell left padding and 1-cell right divider border. Narrow-stack: the
925
- // sidebar flex-grows to fill the viewport with no right divider, so the
926
- // budget is the viewport minus the 1-cell left padding only.
927
- const sidebarPaneWidth = isNarrow ? width : sidebarWidth
928
- const sidebarTextWidth = Math.max(4, sidebarPaneWidth - (isNarrow ? 1 : 2))
929
- const layoutSidebarRow = useCallback(
930
- (relativePath: string) => formatSidebarRow(relativePath, sidebarTextWidth),
931
- [sidebarTextWidth],
932
- )
933
-
934
- const footerProps = {
935
- bindings: browserBindings,
936
- ctx,
937
- width,
938
- notice: footerNotice?.text ?? null,
939
- discoveryStatus,
940
- indicators: [
941
- {
942
- id: "wrap",
943
- icon: "W",
944
- variant: "info",
945
- active: wrapEnabled,
946
- onMouseUp: () => setWrapEnabled((prev) => !prev),
947
- },
948
- ],
949
- ...(discoverySpinnerIntervalMs === undefined ? {} : { discoverySpinnerIntervalMs }),
950
- ...(discoverySpinnerInitialFrameIndex === undefined
951
- ? {}
952
- : { discoverySpinnerInitialFrameIndex }),
953
- ...(discoverySpinnerRegisterTick === undefined ? {} : { discoverySpinnerRegisterTick }),
954
- ...(discoveryWarningStatus === null
955
- ? {}
956
- : {
957
- onDiscoveryWarningToggle: () =>
958
- dispatchFloatingOverlay({
959
- type: "toggle-status-popover",
960
- content: discoveryWarningStatus,
961
- }),
962
- }),
963
- } satisfies FooterProps<BrowserCtx>
964
- const readerEmptyStateTips = useMemo(() => buildReaderEmptyStateTips(browserBindings, ctx), [ctx])
965
- const readerEmptyStateTip = useMemo(
966
- () => pickTipByRotation(readerEmptyStateTips, readerEmptyStateTipRotation),
967
- [readerEmptyStateTipRotation, readerEmptyStateTips],
968
- )
969
-
970
- // One sidebar body for both wide-inline and narrow-stack rendering; only
971
- // the wrapper differs (fixed-width sibling vs flex-grow full-pane).
972
- const sidebarBody = (
973
- <>
974
- {filterRowVisible && (
975
- <PromptRow
976
- query={filterInput}
977
- editing={filterOpen}
978
- placeholder="/ to filter…"
979
- width={sidebarTextWidth}
980
- />
981
- )}
982
- {displayedFiles.length === 0 ? (
983
- <SidebarEmptyMessage
984
- withTopSpacer={filterRowVisible}
985
- width={sidebarTextWidth}
986
- label={
987
- files.length === 0
988
- ? discoveryActive
989
- ? "Scanning"
990
- : "No markdown files in"
991
- : "No files match"
992
- }
993
- value={files.length === 0 ? (discoveryActive ? "…" : emptyRootLabel) : filterApplied}
994
- />
995
- ) : (
996
- visibleFiles.map((file, idx) => {
997
- const realIdx = desiredScroll + idx
998
- const isSelected = realIdx === selectedIndex
999
- const { basename, separator, parent } = layoutSidebarRow(file.relativePath)
1000
- const basenameFg = isSelected ? colors.selectedListItemText : colors.text
1001
- const rowStyle = isSelected ? { bg: colors.backgroundElement } : {}
1002
- return (
1003
- <text key={file.path} wrapMode="none" style={rowStyle}>
1004
- <span style={{ fg: basenameFg }}>{basename}</span>
1005
- {parent !== "" && (
1006
- <span style={{ fg: colors.textMuted }}>
1007
- {middleTruncate(
1008
- `${separator}${parent}`,
1009
- Math.max(0, sidebarTextWidth - basename.length),
1010
- )}
1011
- </span>
1012
- )}
1013
- </text>
1014
- )
1015
- })
1016
- )}
1017
- </>
1018
- )
1019
-
1020
- // Pane borders draw a connected frame: each pane's top/bottom edges
1021
- // (the horizontal rules) and the sidebar's right edge (the vertical
1022
- // divider) are rendered by opentui in one pass, so the junctions
1023
- // never get painted over by sibling elements. customBorderChars on
1024
- // the sidebar turns its right-side corners from `┐ ┘` into `┬ ┴` so
1025
- // they connect cleanly with the reader's top/bottom rules.
1026
- const sidebarBorderSides: BorderSides[] = ["top", "bottom", "right"]
1027
- const readerBorderSides: BorderSides[] = ["top", "bottom"]
1028
- const SIDEBAR_BORDER_CHARS = {
1029
- topLeft: "┌",
1030
- topRight: "┬",
1031
- bottomLeft: "└",
1032
- bottomRight: "┴",
1033
- horizontal: "─",
1034
- vertical: "│",
1035
- topT: "┬",
1036
- bottomT: "┴",
1037
- leftT: "├",
1038
- rightT: "┤",
1039
- cross: "┼",
1040
- } as const
1041
- const INACTIVE_PANE_OPACITY = 0.62
1042
-
1043
- return (
1044
- <box
1045
- style={{ width, height, flexDirection: "column", backgroundColor: colors.backgroundPanel }}
1046
- >
1047
- <Header width={width} currentFile={currentFile} />
1048
- <box
1049
- style={{
1050
- flexDirection: "row",
1051
- flexGrow: 1,
1052
- flexShrink: 1,
1053
- backgroundColor: colors.backgroundPanel,
1054
- }}
1055
- >
1056
- {sidebarInline && (
1057
- <box
1058
- style={{
1059
- // Narrow mode runs single-pane: the sidebar fills the area
1060
- // and drops its right divider (no neighbour to abut).
1061
- border: isNarrow ? readerBorderSides : sidebarBorderSides,
1062
- borderColor: colors.border,
1063
- ...(isNarrow
1064
- ? { flexGrow: 1, flexShrink: 1 }
1065
- : { width: sidebarWidth, flexShrink: 0 }),
1066
- flexDirection: "column",
1067
- // Dim by default. Borders/separators ride on this so they read
1068
- // as a single connected frame regardless of focus; only the
1069
- // active pane's inner body overrides to the raised tint below.
1070
- backgroundColor: colors.backgroundPanel,
1071
- }}
1072
- {...(isNarrow ? {} : { customBorderChars: SIDEBAR_BORDER_CHARS })}
1073
- >
1074
- <box
1075
- style={{
1076
- flexGrow: 1,
1077
- flexShrink: 1,
1078
- flexDirection: "column",
1079
- paddingLeft: 1,
1080
- backgroundColor: sidebarActive ? colors.background : colors.backgroundPanel,
1081
- opacity: sidebarActive ? 1 : INACTIVE_PANE_OPACITY,
1082
- }}
1083
- >
1084
- {sidebarBody}
1085
- </box>
1086
- </box>
1087
- )}
1088
- {readerVisible && (
1089
- <box
1090
- style={{
1091
- border: readerBorderSides,
1092
- borderColor: colors.border,
1093
- flexGrow: 1,
1094
- flexShrink: 1,
1095
- flexDirection: "column",
1096
- // Dim by default (see sidebar note); inner body overrides when active.
1097
- backgroundColor: colors.backgroundPanel,
1098
- }}
1099
- >
1100
- <box
1101
- style={{
1102
- flexGrow: 1,
1103
- flexShrink: 1,
1104
- flexDirection: "column",
1105
- padding: 1,
1106
- backgroundColor: readerActive ? colors.background : colors.backgroundPanel,
1107
- opacity: readerActive ? 1 : INACTIVE_PANE_OPACITY,
1108
- }}
1109
- >
1110
- {error ? (
1111
- <text content={error} style={{ fg: colors.error }} />
1112
- ) : !renderedPath ? (
1113
- // Reader empty state — no file selected. Brand mark centered as a
1114
- // welcome anchor; reusable tips live here too.
1115
- <box
1116
- style={{
1117
- flexGrow: 1,
1118
- flexShrink: 1,
1119
- alignItems: "center",
1120
- justifyContent: "center",
1121
- backgroundColor: readerActive ? colors.background : colors.backgroundPanel,
1122
- }}
1123
- >
1124
- <box style={{ flexDirection: "column", gap: 1, alignItems: "center" }}>
1125
- <text
1126
- content={readerEmptyStateTitle}
1127
- wrapMode="none"
1128
- style={{ fg: colors.textMuted }}
1129
- />
1130
- {readerEmptyStateTip && (
1131
- <text
1132
- key={readerEmptyStateTip.id}
1133
- content={readerEmptyStateTip.text}
1134
- wrapMode="none"
1135
- style={{ fg: colors.textMuted }}
1136
- />
1137
- )}
1138
- </box>
1139
- </box>
1140
- ) : (
1141
- <scrollbox
1142
- style={{
1143
- scrollY: true,
1144
- scrollX: false,
1145
- flexGrow: 1,
1146
- flexShrink: 1,
1147
- backgroundColor: readerActive ? colors.background : colors.backgroundPanel,
1148
- }}
1149
- // opentui's scrollbox consumes arrow keys at the focused-element
1150
- // level *before* useKeyboard fires, so a modal that handles
1151
- // arrow keys itself (palette nav) would still
1152
- // see the reader scroll alongside its own action. Unfocus the
1153
- // scrollbox while any blocking modal is up — useKeyboard's
1154
- // modal branches own the keys in that state. Filter is not
1155
- // listed because it force-focuses the sidebar (readerActive
1156
- // is already false).
1157
- focused={readerActive && !paletteOpen}
1158
- >
1159
- {parsedContent.fields.length > 0 && (
1160
- <box style={{ flexDirection: "column", marginBottom: 1 }}>
1161
- {parsedContent.fields.map((field) => (
1162
- <box
1163
- key={field.key}
1164
- style={{ flexDirection: "row", gap: 1, flexWrap: "wrap" }}
1165
- >
1166
- <text
1167
- content={`${field.key}:`}
1168
- wrapMode="word"
1169
- style={{ fg: colors.secondary }}
1170
- />
1171
- <text
1172
- content={field.value}
1173
- wrapMode="word"
1174
- style={{ fg: colors.textMuted }}
1175
- />
1176
- </box>
1177
- ))}
1178
- </box>
1179
- )}
1180
- <markdown
1181
- key={renderedPath ?? "empty"}
1182
- content={parsedContent.body}
1183
- syntaxStyle={syntaxStyle}
1184
- fg={colors.text}
1185
- bg={readerActive ? colors.background : colors.backgroundPanel}
1186
- conceal
1187
- style={{ width: markdownWidth }}
1188
- />
1189
- </scrollbox>
1190
- )}
1191
- </box>
1192
- </box>
1193
- )}
1194
- </box>
1195
- <Footer {...footerProps} />
1196
- {paletteOpen && (
1197
- <CommandPalette
1198
- commands={orderCommandsForPalette(filterCommands(buildCommands(ctx), paletteQuery))}
1199
- query={paletteQuery}
1200
- selectedIndex={paletteIndex}
1201
- viewportWidth={width}
1202
- viewportHeight={height}
1203
- />
1204
- )}
1205
- {activeStatusPopover && (
1206
- <StatusPopoverPanel content={activeStatusPopover.content} variant="warning" />
1207
- )}
1208
- </box>
1209
- )
1210
- }