@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.
- package/CHANGELOG.md +32 -2
- package/README.md +10 -8
- package/dist/bin.js +92 -0
- package/dist/index.js +10005 -0
- package/package.json +15 -3
- package/src/Browser.tsx +0 -1210
- package/src/CommandPalette.tsx +0 -214
- package/src/Footer.tsx +0 -258
- package/src/Header.tsx +0 -71
- package/src/PromptRow.tsx +0 -51
- package/src/Spinner.tsx +0 -39
- package/src/StatusIndicator.tsx +0 -55
- package/src/StatusPopover.tsx +0 -166
- package/src/brand.ts +0 -7
- package/src/cli/argv.ts +0 -179
- package/src/commands/buildCommands.ts +0 -98
- package/src/commands/paletteOnlyCommands.ts +0 -27
- package/src/commands/score.ts +0 -78
- package/src/commands/types.ts +0 -26
- package/src/config/load.ts +0 -381
- package/src/config/save.ts +0 -95
- package/src/discovery/filter.ts +0 -148
- package/src/discovery/show.ts +0 -48
- package/src/discovery/walk.ts +0 -209
- package/src/index.tsx +0 -464
- package/src/io/clipboard.ts +0 -53
- package/src/io/editor.ts +0 -162
- package/src/io/readFile.ts +0 -14
- package/src/keymap/browser.ts +0 -335
- package/src/keymap/displayKey.ts +0 -17
- package/src/keymap/keymap.ts +0 -95
- package/src/layout/resolve.ts +0 -52
- package/src/layout/sidebarEmptyState.ts +0 -7
- package/src/layout/sidebarRow.ts +0 -69
- package/src/markdown/frontmatter.ts +0 -62
- package/src/serve/css.ts +0 -120
- package/src/serve/openBrowser.ts +0 -19
- package/src/serve/render.ts +0 -56
- package/src/serve/server.ts +0 -166
- package/src/theme/atom.ts +0 -23
- package/src/theme/colors.ts +0 -86
- package/src/theme/loader.ts +0 -110
- package/src/theme/registry.ts +0 -12
- package/src/theme/resolve.ts +0 -168
- package/src/theme/themes/aura.json +0 -58
- package/src/theme/themes/ayu.json +0 -69
- package/src/theme/themes/carbonfox.json +0 -201
- package/src/theme/themes/catppuccin-frappe.json +0 -186
- package/src/theme/themes/catppuccin-macchiato.json +0 -186
- package/src/theme/themes/catppuccin.json +0 -212
- package/src/theme/themes/cobalt2.json +0 -181
- package/src/theme/themes/cursor.json +0 -202
- package/src/theme/themes/dracula.json +0 -172
- package/src/theme/themes/everforest.json +0 -194
- package/src/theme/themes/flexoki.json +0 -190
- package/src/theme/themes/github.json +0 -186
- package/src/theme/themes/gruvbox.json +0 -195
- package/src/theme/themes/kanagawa.json +0 -180
- package/src/theme/themes/lucent-orng.json +0 -186
- package/src/theme/themes/material.json +0 -188
- package/src/theme/themes/matrix.json +0 -180
- package/src/theme/themes/mercury.json +0 -198
- package/src/theme/themes/monokai.json +0 -174
- package/src/theme/themes/nightowl.json +0 -174
- package/src/theme/themes/nord.json +0 -176
- package/src/theme/themes/one-dark.json +0 -184
- package/src/theme/themes/opencode.json +0 -198
- package/src/theme/themes/orng.json +0 -202
- package/src/theme/themes/osaka-jade.json +0 -193
- package/src/theme/themes/palenight.json +0 -175
- package/src/theme/themes/rosepine.json +0 -187
- package/src/theme/themes/solarized.json +0 -180
- package/src/theme/themes/synthwave84.json +0 -179
- package/src/theme/themes/tokyonight.json +0 -196
- package/src/theme/themes/vercel.json +0 -198
- package/src/theme/themes/vesper.json +0 -171
- package/src/theme/themes/zenburn.json +0 -176
- package/src/theme/types.ts +0 -115
- package/src/tips.ts +0 -88
- package/src/ui/middleTruncate.ts +0 -27
- package/src/update/cache.ts +0 -77
- package/src/update/check.ts +0 -165
- package/src/update/compare.ts +0 -41
- package/src/update/notice.ts +0 -29
- package/src/update/runtime.ts +0 -48
- 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
|
-
}
|