@carlesandres/house 0.4.7 → 0.4.9
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 +43 -4
- package/README.md +64 -53
- package/package.json +2 -1
- package/src/Browser.tsx +235 -160
- package/src/CommandPalette.tsx +78 -14
- package/src/Footer.tsx +44 -37
- package/src/Header.tsx +4 -1
- package/src/StatusPopover.tsx +189 -0
- package/src/cli/argv.ts +15 -12
- package/src/commands/buildCommands.ts +22 -33
- package/src/commands/score.ts +1 -1
- package/src/discovery/filter.ts +83 -23
- package/src/discovery/walk.ts +24 -15
- package/src/index.tsx +120 -170
- package/src/keymap/browser.ts +14 -20
- package/src/keymap/keymap.ts +4 -4
- package/src/layout/sidebarEmptyState.ts +7 -0
- package/src/layout/sidebarRow.ts +1 -1
- package/src/markdown/frontmatter.ts +62 -0
- package/src/theme/resolve.ts +1 -1
- package/src/theme/themes/aura.json +1 -1
- package/src/theme/themes/carbonfox.json +1 -1
- package/src/theme/themes/lucent-orng.json +4 -4
- package/src/theme/themes/nightowl.json +2 -2
- package/src/theme/themes/orng.json +2 -2
- package/src/theme/themes/solarized.json +6 -2
- package/src/theme/themes/vesper.json +2 -2
- package/src/tips.ts +0 -5
- package/src/update/check.ts +1 -1
- package/src/update/runtime.ts +1 -1
- package/src/HelpOverlay.tsx +0 -148
package/src/Browser.tsx
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* - q / ctrl+c quit.
|
|
8
8
|
*
|
|
9
9
|
* Deferred to next iteration: focus model, reader scrolling via j/k,
|
|
10
|
-
* sidebar collapse with
|
|
10
|
+
* sidebar collapse with `\`.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { SyntaxStyle } from "@opentui/core"
|
|
@@ -15,16 +15,16 @@ import type { BorderSides } from "@opentui/core"
|
|
|
15
15
|
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
|
|
16
16
|
import { useAtomValue, useAtomSet } from "@effect/atom-react"
|
|
17
17
|
import { Effect } from "effect"
|
|
18
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
18
|
+
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"
|
|
19
19
|
import { buildCommands } from "./commands/buildCommands.ts"
|
|
20
20
|
import { clampSelectedIndex, filterCommands } from "./commands/score.ts"
|
|
21
|
-
import { CommandPalette } from "./CommandPalette.tsx"
|
|
21
|
+
import { CommandPalette, orderCommandsForPalette } from "./CommandPalette.tsx"
|
|
22
22
|
import { filterFiles } from "./discovery/filter.ts"
|
|
23
23
|
import { type FileEntry } from "./discovery/walk.ts"
|
|
24
|
+
import { parseFrontmatter } from "./markdown/frontmatter.ts"
|
|
24
25
|
import { BRAND, BRAND_NAME } from "./brand.ts"
|
|
25
26
|
import { Footer, FOOTER_HEIGHT, type FooterProps } from "./Footer.tsx"
|
|
26
27
|
import { Header, HEADER_HEIGHT } from "./Header.tsx"
|
|
27
|
-
import { HelpOverlay } from "./HelpOverlay.tsx"
|
|
28
28
|
import { openInEditor, resolveEditor } from "./io/editor.ts"
|
|
29
29
|
import { readFileText } from "./io/readFile.ts"
|
|
30
30
|
import { browserBindings, type BrowserCtx } from "./keymap/browser.ts"
|
|
@@ -35,8 +35,10 @@ import {
|
|
|
35
35
|
initialShownForAuto,
|
|
36
36
|
resolveSidebarWidth,
|
|
37
37
|
} from "./layout/resolve.ts"
|
|
38
|
+
import { fitSidebarEmptyValue } from "./layout/sidebarEmptyState.ts"
|
|
38
39
|
import { formatSidebarRow } from "./layout/sidebarRow.ts"
|
|
39
40
|
import { PromptRow } from "./PromptRow.tsx"
|
|
41
|
+
import { StatusPopoverPanel } from "./StatusPopover.tsx"
|
|
40
42
|
import { buildReaderEmptyStateTips, pickTipByRotation } from "./tips.ts"
|
|
41
43
|
import { openInBrowser } from "./serve/openBrowser.ts"
|
|
42
44
|
import { startServer, type ServerHandle } from "./serve/server.ts"
|
|
@@ -50,8 +52,12 @@ export type StartupFocus = "sidebar" | "reader" | "filter"
|
|
|
50
52
|
export interface BrowserProps {
|
|
51
53
|
readonly files: readonly FileEntry[]
|
|
52
54
|
readonly initialIndex?: number
|
|
55
|
+
/** Initial applied filter query seeded from the CLI positional. */
|
|
56
|
+
readonly initialQuery?: string
|
|
53
57
|
/** Cap the rendered markdown's width at N columns. Null = fill the pane. */
|
|
54
58
|
readonly maxWidth?: number | null
|
|
59
|
+
/** Discovery root label used in the post-discovery empty-vault sidebar row. */
|
|
60
|
+
readonly emptyRootLabel?: string
|
|
55
61
|
/** Persistent footer indicator (e.g. "indexing… 42"). Pass null/undefined
|
|
56
62
|
* when discovery has finished; the indicator clears. */
|
|
57
63
|
readonly discoveryStatus?: string | null
|
|
@@ -96,18 +102,6 @@ const defaultReadFile = (path: string): Promise<string> => Effect.runPromise(rea
|
|
|
96
102
|
|
|
97
103
|
const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n))
|
|
98
104
|
|
|
99
|
-
/** Bindings the help overlay lets through. Single source of truth for both
|
|
100
|
-
* the keyboard early-return and the footer hint filter. `palette.open`
|
|
101
|
-
* passes through so users can jump from help into the palette in one
|
|
102
|
-
* keystroke — `openPalette` closes help on its way in. */
|
|
103
|
-
const HELP_ALLOWED_IDS: ReadonlySet<string> = new Set([
|
|
104
|
-
"help.toggle",
|
|
105
|
-
"theme.next",
|
|
106
|
-
"theme.prev",
|
|
107
|
-
"theme.toneToggle",
|
|
108
|
-
"palette.open",
|
|
109
|
-
])
|
|
110
|
-
|
|
111
105
|
let nextReaderEmptyStateTipRotation = 0
|
|
112
106
|
|
|
113
107
|
export const resetReaderEmptyStateTipRotationForTests = () => {
|
|
@@ -121,10 +115,80 @@ export const setReaderEmptyStateTipRotationForTests = (next: number) => {
|
|
|
121
115
|
const FILTER_DEBOUNCE_MS = 50
|
|
122
116
|
const RENDERED_PATH_DEBOUNCE_MS = 80
|
|
123
117
|
|
|
118
|
+
const isPartialDiscoveryWarning = (status: string | null | undefined): boolean =>
|
|
119
|
+
status?.trimStart().startsWith("scan incomplete:") ?? false
|
|
120
|
+
|
|
121
|
+
type FloatingOverlay =
|
|
122
|
+
| { readonly kind: "none" }
|
|
123
|
+
| { readonly kind: "command-palette" }
|
|
124
|
+
| { readonly kind: "status-popover"; readonly content: string }
|
|
125
|
+
|
|
126
|
+
type FloatingOverlayAction =
|
|
127
|
+
| { readonly type: "close" }
|
|
128
|
+
| { readonly type: "open-command-palette" }
|
|
129
|
+
| { readonly type: "toggle-status-popover"; readonly content: string }
|
|
130
|
+
| { readonly type: "update-status-popover"; readonly content: string }
|
|
131
|
+
|
|
132
|
+
const noFloatingOverlay: FloatingOverlay = { kind: "none" }
|
|
133
|
+
|
|
134
|
+
const floatingOverlayReducer = (
|
|
135
|
+
state: FloatingOverlay,
|
|
136
|
+
action: FloatingOverlayAction,
|
|
137
|
+
): FloatingOverlay => {
|
|
138
|
+
switch (action.type) {
|
|
139
|
+
case "close":
|
|
140
|
+
return noFloatingOverlay
|
|
141
|
+
case "open-command-palette":
|
|
142
|
+
return { kind: "command-palette" }
|
|
143
|
+
case "toggle-status-popover":
|
|
144
|
+
return state.kind === "status-popover"
|
|
145
|
+
? noFloatingOverlay
|
|
146
|
+
: { kind: "status-popover", content: action.content }
|
|
147
|
+
case "update-status-popover":
|
|
148
|
+
return state.kind === "status-popover"
|
|
149
|
+
? { kind: "status-popover", content: action.content }
|
|
150
|
+
: state
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const SidebarEmptyMessage = ({
|
|
155
|
+
label,
|
|
156
|
+
value,
|
|
157
|
+
width,
|
|
158
|
+
withTopSpacer,
|
|
159
|
+
}: {
|
|
160
|
+
readonly label: string
|
|
161
|
+
readonly value: string
|
|
162
|
+
readonly width: number
|
|
163
|
+
readonly withTopSpacer: boolean
|
|
164
|
+
}) => (
|
|
165
|
+
<>
|
|
166
|
+
{withTopSpacer && <text content="" />}
|
|
167
|
+
<box
|
|
168
|
+
style={{
|
|
169
|
+
width,
|
|
170
|
+
flexDirection: "row",
|
|
171
|
+
flexWrap: "wrap",
|
|
172
|
+
justifyContent: "center",
|
|
173
|
+
gap: 1,
|
|
174
|
+
}}
|
|
175
|
+
>
|
|
176
|
+
<text wrapMode="none">
|
|
177
|
+
<span style={{ fg: colors.textMuted }}>{label}</span>
|
|
178
|
+
</text>
|
|
179
|
+
<text wrapMode="none">
|
|
180
|
+
<span style={{ fg: colors.textMuted }}>{`"${fitSidebarEmptyValue(value, width)}"`}</span>
|
|
181
|
+
</text>
|
|
182
|
+
</box>
|
|
183
|
+
</>
|
|
184
|
+
)
|
|
185
|
+
|
|
124
186
|
export const Browser = ({
|
|
125
187
|
files,
|
|
126
188
|
initialIndex = 0,
|
|
189
|
+
initialQuery = "",
|
|
127
190
|
maxWidth = null,
|
|
191
|
+
emptyRootLabel = "current root",
|
|
128
192
|
discoveryStatus = null,
|
|
129
193
|
discoverySpinnerIntervalMs,
|
|
130
194
|
discoverySpinnerInitialFrameIndex,
|
|
@@ -187,18 +251,29 @@ export const Browser = ({
|
|
|
187
251
|
shown || initialFocus === "sidebar" ? "sidebar" : "reader",
|
|
188
252
|
)
|
|
189
253
|
const [sidebarScroll, setSidebarScroll] = useState<number>(0)
|
|
190
|
-
const [helpVisible, setHelpVisible] = useState<boolean>(false)
|
|
191
254
|
const [filterOpen, setFilterOpen] = useState<boolean>(startInFilter)
|
|
192
|
-
const [filterInput, setFilterInput] = useState<string>(
|
|
193
|
-
const [filterApplied, setFilterApplied] = useState<string>(
|
|
194
|
-
const [
|
|
255
|
+
const [filterInput, setFilterInput] = useState<string>(initialQuery)
|
|
256
|
+
const [filterApplied, setFilterApplied] = useState<string>(initialQuery)
|
|
257
|
+
const [floatingOverlay, dispatchFloatingOverlayState] = useReducer(
|
|
258
|
+
floatingOverlayReducer,
|
|
259
|
+
noFloatingOverlay,
|
|
260
|
+
)
|
|
261
|
+
const floatingOverlayRef = useRef<FloatingOverlay>(noFloatingOverlay)
|
|
262
|
+
const dispatchFloatingOverlay = (action: FloatingOverlayAction): void => {
|
|
263
|
+
const next = floatingOverlayReducer(floatingOverlayRef.current, action)
|
|
264
|
+
floatingOverlayRef.current = next
|
|
265
|
+
dispatchFloatingOverlayState(action)
|
|
266
|
+
}
|
|
267
|
+
const closeFloatingOverlay = (): void => dispatchFloatingOverlay({ type: "close" })
|
|
268
|
+
const paletteOpen = floatingOverlay.kind === "command-palette"
|
|
269
|
+
const activeStatusPopover = floatingOverlay.kind === "status-popover" ? floatingOverlay : null
|
|
270
|
+
const discoveryWarningStatus = isPartialDiscoveryWarning(discoveryStatus) ? discoveryStatus : null
|
|
195
271
|
const [paletteQuery, setPaletteQuery] = useState<string>("")
|
|
196
272
|
const [paletteIndex, setPaletteIndex] = useState<number>(0)
|
|
197
273
|
// Synchronous mirrors for the keyboard handler — same reason filterOpenRef
|
|
198
274
|
// exists. Modal input can arrive in one React batch (e.g. ctrl+p, Down,
|
|
199
275
|
// Return), so every palette field read by later keys must update its ref
|
|
200
276
|
// before React state commits.
|
|
201
|
-
const paletteOpenRef = useRef(false)
|
|
202
277
|
const paletteQueryRef = useRef("")
|
|
203
278
|
const paletteIndexRef = useRef(0)
|
|
204
279
|
const [readerEmptyStateTipRotation, setReaderEmptyStateTipRotation] = useState(
|
|
@@ -210,8 +285,8 @@ export const Browser = ({
|
|
|
210
285
|
// first key opens the filter; subsequent keys in the same tick would
|
|
211
286
|
// otherwise still observe filterOpen=false through closure).
|
|
212
287
|
const filterOpenRef = useRef(startInFilter)
|
|
213
|
-
const filterInputRef = useRef(
|
|
214
|
-
const filterAppliedRef = useRef(
|
|
288
|
+
const filterInputRef = useRef(initialQuery)
|
|
289
|
+
const filterAppliedRef = useRef(initialQuery)
|
|
215
290
|
const autoSelectForAppliedFilterRef = useRef(true)
|
|
216
291
|
const focusRef = useRef<"sidebar" | "reader">(focus)
|
|
217
292
|
const restoreFilterOnSidebarFocusRef = useRef(startInFilter)
|
|
@@ -246,11 +321,25 @@ export const Browser = ({
|
|
|
246
321
|
// the other's display window.
|
|
247
322
|
useEffect(() => {
|
|
248
323
|
if (footerNotice === null) return
|
|
324
|
+
closeFloatingOverlay()
|
|
249
325
|
if (disableFooterNoticeAutoClear) return
|
|
250
326
|
const timer = setTimeout(() => setFooterNoticeState(null), footerNotice.ttlMs)
|
|
251
327
|
return () => clearTimeout(timer)
|
|
252
328
|
}, [disableFooterNoticeAutoClear, footerNotice])
|
|
253
329
|
|
|
330
|
+
useEffect(() => {
|
|
331
|
+
if (discoveryWarningStatus === null) {
|
|
332
|
+
if (floatingOverlay.kind === "status-popover") closeFloatingOverlay()
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
if (
|
|
336
|
+
floatingOverlay.kind === "status-popover" &&
|
|
337
|
+
floatingOverlay.content !== discoveryWarningStatus
|
|
338
|
+
) {
|
|
339
|
+
dispatchFloatingOverlay({ type: "update-status-popover", content: discoveryWarningStatus })
|
|
340
|
+
}
|
|
341
|
+
}, [discoveryWarningStatus, floatingOverlay])
|
|
342
|
+
|
|
254
343
|
// Push the update-available nudge once, when it arrives from the parent
|
|
255
344
|
// (the registry probe resolves asynchronously after boot). 10s gives the
|
|
256
345
|
// user time to read it before it auto-clears; the quit-time stderr print
|
|
@@ -385,7 +474,6 @@ export const Browser = ({
|
|
|
385
474
|
hasSelected: selected != null,
|
|
386
475
|
focus,
|
|
387
476
|
sidebarShown: shown,
|
|
388
|
-
helpVisible,
|
|
389
477
|
filterOpen,
|
|
390
478
|
restoreFilterOnSidebarFocus: restoreFilterOnSidebarFocusRef.current,
|
|
391
479
|
filterQuery: filterInput,
|
|
@@ -425,8 +513,8 @@ export const Browser = ({
|
|
|
425
513
|
if (focus === "reader") setFocus("sidebar")
|
|
426
514
|
}
|
|
427
515
|
},
|
|
428
|
-
setHelpVisible,
|
|
429
516
|
openFilter: () => {
|
|
517
|
+
closeFloatingOverlay()
|
|
430
518
|
// Focus the sidebar so the filter input has a home. In wide,
|
|
431
519
|
// §7.1's visibility rule (`shown || focus === "sidebar"`) brings
|
|
432
520
|
// the inline sidebar back on screen if it was hidden. In narrow,
|
|
@@ -439,6 +527,7 @@ export const Browser = ({
|
|
|
439
527
|
setFilterOpen(true)
|
|
440
528
|
},
|
|
441
529
|
clearAndOpenFilter: () => {
|
|
530
|
+
closeFloatingOverlay()
|
|
442
531
|
// Reset both the ref and the state so the freshly-opened modal
|
|
443
532
|
// shows an empty input and selection lands on the first file in
|
|
444
533
|
// the (now unfiltered) list.
|
|
@@ -454,16 +543,13 @@ export const Browser = ({
|
|
|
454
543
|
setFilterOpen(true)
|
|
455
544
|
},
|
|
456
545
|
openPalette: () => {
|
|
457
|
-
// Close help if it was open — palette is the active modal now.
|
|
458
546
|
// Reset query/index so each open starts fresh (no stale state from
|
|
459
547
|
// the previous session).
|
|
460
|
-
if (helpVisible) setHelpVisible(() => false)
|
|
461
548
|
paletteQueryRef.current = ""
|
|
462
549
|
setPaletteQuery("")
|
|
463
550
|
paletteIndexRef.current = 0
|
|
464
551
|
setPaletteIndex(0)
|
|
465
|
-
|
|
466
|
-
setPaletteOpen(true)
|
|
552
|
+
dispatchFloatingOverlay({ type: "open-command-palette" })
|
|
467
553
|
},
|
|
468
554
|
cycleTheme,
|
|
469
555
|
toggleTone,
|
|
@@ -577,6 +663,68 @@ export const Browser = ({
|
|
|
577
663
|
}
|
|
578
664
|
|
|
579
665
|
useKeyboard((key) => {
|
|
666
|
+
// Command palette modal: capture keystrokes for the query input and
|
|
667
|
+
// list navigation. This branch intentionally runs before the filter
|
|
668
|
+
// branch so ctrl+p can open the palette from filter mode while the
|
|
669
|
+
// palette still owns Esc/arrows/Return until it closes.
|
|
670
|
+
if (floatingOverlayRef.current.kind === "command-palette") {
|
|
671
|
+
const closePalette = () => {
|
|
672
|
+
paletteQueryRef.current = ""
|
|
673
|
+
paletteIndexRef.current = 0
|
|
674
|
+
closeFloatingOverlay()
|
|
675
|
+
setPaletteQuery("")
|
|
676
|
+
setPaletteIndex(0)
|
|
677
|
+
}
|
|
678
|
+
const setPaletteIndexSync = (next: number) => {
|
|
679
|
+
paletteIndexRef.current = next
|
|
680
|
+
setPaletteIndex(next)
|
|
681
|
+
}
|
|
682
|
+
const allCommands = buildCommands(ctx)
|
|
683
|
+
const filtered = orderCommandsForPalette(filterCommands(allCommands, paletteQueryRef.current))
|
|
684
|
+
if (key.name === "escape") {
|
|
685
|
+
closePalette()
|
|
686
|
+
return
|
|
687
|
+
}
|
|
688
|
+
if (key.name === "return") {
|
|
689
|
+
const picked = filtered[clampSelectedIndex(paletteIndexRef.current, filtered)]
|
|
690
|
+
closePalette()
|
|
691
|
+
picked?.run()
|
|
692
|
+
return
|
|
693
|
+
}
|
|
694
|
+
if (key.name === "up") {
|
|
695
|
+
setPaletteIndexSync(Math.max(0, paletteIndexRef.current - 1))
|
|
696
|
+
return
|
|
697
|
+
}
|
|
698
|
+
if (key.name === "down") {
|
|
699
|
+
setPaletteIndexSync(Math.min(Math.max(0, filtered.length - 1), paletteIndexRef.current + 1))
|
|
700
|
+
return
|
|
701
|
+
}
|
|
702
|
+
if (key.name === "backspace" || key.name === "delete") {
|
|
703
|
+
if (paletteQueryRef.current.length === 0) return
|
|
704
|
+
paletteQueryRef.current = paletteQueryRef.current.slice(0, -1)
|
|
705
|
+
setPaletteQuery(paletteQueryRef.current)
|
|
706
|
+
setPaletteIndexSync(0)
|
|
707
|
+
return
|
|
708
|
+
}
|
|
709
|
+
// ctrl+p again closes.
|
|
710
|
+
if (key.ctrl && !key.meta && key.name === "p") {
|
|
711
|
+
closePalette()
|
|
712
|
+
return
|
|
713
|
+
}
|
|
714
|
+
if (key.ctrl || key.meta) return
|
|
715
|
+
let char: string | null = null
|
|
716
|
+
if (key.name === "space") char = " "
|
|
717
|
+
else if (typeof key.name === "string" && key.name.length === 1) {
|
|
718
|
+
char = key.shift ? key.name.toUpperCase() : key.name
|
|
719
|
+
}
|
|
720
|
+
if (char !== null) {
|
|
721
|
+
paletteQueryRef.current = paletteQueryRef.current + char
|
|
722
|
+
setPaletteQuery(paletteQueryRef.current)
|
|
723
|
+
setPaletteIndexSync(0)
|
|
724
|
+
}
|
|
725
|
+
return
|
|
726
|
+
}
|
|
727
|
+
|
|
580
728
|
// Filter modal: capture keystrokes for the input. Esc closes,
|
|
581
729
|
// leaving the typed query applied as the active filter; Return
|
|
582
730
|
// closes and focuses the reader (open the match); Ctrl+\ clears
|
|
@@ -631,6 +779,10 @@ export const Browser = ({
|
|
|
631
779
|
setFocus("reader")
|
|
632
780
|
return
|
|
633
781
|
}
|
|
782
|
+
if (key.ctrl && !key.meta && key.name === "p") {
|
|
783
|
+
ctx.openPalette()
|
|
784
|
+
return
|
|
785
|
+
}
|
|
634
786
|
if (key.ctrl && key.name === "\\") {
|
|
635
787
|
// Same action as the `filter.clearOrOpen` binding fires from
|
|
636
788
|
// outside the modal: clear the query, reset selection. The
|
|
@@ -676,95 +828,6 @@ export const Browser = ({
|
|
|
676
828
|
}
|
|
677
829
|
return
|
|
678
830
|
}
|
|
679
|
-
// Command palette modal: capture keystrokes for the query input and
|
|
680
|
-
// list navigation. Esc closes (single press, regardless of query —
|
|
681
|
-
// #70 Q7a). Return runs the selected command. Up/Down navigate.
|
|
682
|
-
// Backspace edits the query and is a no-op on empty (#70 Q7b —
|
|
683
|
-
// intentionally diverges from the filter modal, which closes on
|
|
684
|
-
// empty-backspace, because accidental close feels worse in the
|
|
685
|
-
// palette). Printable characters extend the query and snap selection
|
|
686
|
-
// to 0 (#70 Q7c). Ctrl/Meta-modified keys are swallowed except
|
|
687
|
-
// ctrl+p, which toggles the palette closed (matches help-toggle's
|
|
688
|
-
// re-press-to-close behavior).
|
|
689
|
-
if (paletteOpenRef.current) {
|
|
690
|
-
const closePalette = () => {
|
|
691
|
-
paletteOpenRef.current = false
|
|
692
|
-
paletteQueryRef.current = ""
|
|
693
|
-
paletteIndexRef.current = 0
|
|
694
|
-
setPaletteOpen(false)
|
|
695
|
-
setPaletteQuery("")
|
|
696
|
-
setPaletteIndex(0)
|
|
697
|
-
}
|
|
698
|
-
const setPaletteIndexSync = (next: number) => {
|
|
699
|
-
paletteIndexRef.current = next
|
|
700
|
-
setPaletteIndex(next)
|
|
701
|
-
}
|
|
702
|
-
const allCommands = buildCommands(ctx)
|
|
703
|
-
const filtered = filterCommands(allCommands, paletteQueryRef.current)
|
|
704
|
-
if (key.name === "escape") {
|
|
705
|
-
closePalette()
|
|
706
|
-
return
|
|
707
|
-
}
|
|
708
|
-
if (key.name === "return") {
|
|
709
|
-
const picked = filtered[clampSelectedIndex(paletteIndexRef.current, filtered)]
|
|
710
|
-
closePalette()
|
|
711
|
-
picked?.run()
|
|
712
|
-
return
|
|
713
|
-
}
|
|
714
|
-
if (key.name === "up") {
|
|
715
|
-
setPaletteIndexSync(Math.max(0, paletteIndexRef.current - 1))
|
|
716
|
-
return
|
|
717
|
-
}
|
|
718
|
-
if (key.name === "down") {
|
|
719
|
-
setPaletteIndexSync(Math.min(Math.max(0, filtered.length - 1), paletteIndexRef.current + 1))
|
|
720
|
-
return
|
|
721
|
-
}
|
|
722
|
-
if (key.name === "backspace" || key.name === "delete") {
|
|
723
|
-
if (paletteQueryRef.current.length === 0) return
|
|
724
|
-
paletteQueryRef.current = paletteQueryRef.current.slice(0, -1)
|
|
725
|
-
setPaletteQuery(paletteQueryRef.current)
|
|
726
|
-
setPaletteIndexSync(0)
|
|
727
|
-
return
|
|
728
|
-
}
|
|
729
|
-
// ctrl+p again closes — matches help-toggle behavior.
|
|
730
|
-
if (key.ctrl && !key.meta && key.name === "p") {
|
|
731
|
-
closePalette()
|
|
732
|
-
return
|
|
733
|
-
}
|
|
734
|
-
if (key.ctrl || key.meta) return
|
|
735
|
-
let char: string | null = null
|
|
736
|
-
if (key.name === "space") char = " "
|
|
737
|
-
else if (typeof key.name === "string" && key.name.length === 1) {
|
|
738
|
-
char = key.shift ? key.name.toUpperCase() : key.name
|
|
739
|
-
}
|
|
740
|
-
if (char !== null) {
|
|
741
|
-
paletteQueryRef.current = paletteQueryRef.current + char
|
|
742
|
-
setPaletteQuery(paletteQueryRef.current)
|
|
743
|
-
setPaletteIndexSync(0)
|
|
744
|
-
}
|
|
745
|
-
return
|
|
746
|
-
}
|
|
747
|
-
// While help is open, swallow most keys: only ? (toggle), esc
|
|
748
|
-
// (close), and the theme bindings pass through. Theme keys stay live
|
|
749
|
-
// so users can preview palette changes against the overlay itself —
|
|
750
|
-
// it is the largest theme-painted surface in the app. Everything else
|
|
751
|
-
// is suppressed so the user can read without driving the UI behind.
|
|
752
|
-
// This is the one place we step outside the data-driven keymap; the
|
|
753
|
-
// alternative — adding `when: !c.helpVisible` to every other binding
|
|
754
|
-
// — would clutter the array. See DESIGN.md §12 (keymap composition).
|
|
755
|
-
if (helpVisible) {
|
|
756
|
-
if (key.name === "escape") {
|
|
757
|
-
setHelpVisible(() => false)
|
|
758
|
-
return
|
|
759
|
-
}
|
|
760
|
-
const allowed = browserBindings.filter((b) => HELP_ALLOWED_IDS.has(b.id))
|
|
761
|
-
// Defensive: stub quit even though no allowed binding currently
|
|
762
|
-
// calls it. Keeps the invariant local to this branch instead of
|
|
763
|
-
// relying on a future maintainer remembering not to add quit-ish
|
|
764
|
-
// bindings to HELP_ALLOWED_IDS.
|
|
765
|
-
dispatch(allowed, { ...ctx, quit: () => {} }, key)
|
|
766
|
-
return
|
|
767
|
-
}
|
|
768
831
|
dispatch(browserBindings, ctx, key)
|
|
769
832
|
})
|
|
770
833
|
|
|
@@ -787,6 +850,7 @@ export const Browser = ({
|
|
|
787
850
|
// per-pane border title that used to carry this information).
|
|
788
851
|
const currentFile = selected?.relativePath ?? null
|
|
789
852
|
const content = loaded?.path === renderedPath ? loaded.content : ""
|
|
853
|
+
const parsedContent = useMemo(() => parseFrontmatter(content), [content])
|
|
790
854
|
const readerEmptyStateTitle = filterHasNoMatches
|
|
791
855
|
? `No files match: ${filterInput}`
|
|
792
856
|
: `${BRAND} ${BRAND_NAME}`
|
|
@@ -840,31 +904,26 @@ export const Browser = ({
|
|
|
840
904
|
[sidebarTextWidth],
|
|
841
905
|
)
|
|
842
906
|
|
|
843
|
-
// While help is open, the `?` key closes the overlay — relabel its hint
|
|
844
|
-
// so the footer accurately describes what pressing the key will do.
|
|
845
|
-
// Memoized: `helpVisible` changes rarely; `browserBindings` and
|
|
846
|
-
// `HELP_ALLOWED_IDS` are module-level constants.
|
|
847
|
-
const footerBindings = useMemo(
|
|
848
|
-
() =>
|
|
849
|
-
helpVisible
|
|
850
|
-
? browserBindings
|
|
851
|
-
.filter((b) => HELP_ALLOWED_IDS.has(b.id))
|
|
852
|
-
.map((b) => (b.id === "help.toggle" ? { ...b, hint: "close" } : b))
|
|
853
|
-
: browserBindings,
|
|
854
|
-
[helpVisible],
|
|
855
|
-
)
|
|
856
907
|
const footerProps = {
|
|
857
|
-
bindings:
|
|
908
|
+
bindings: browserBindings,
|
|
858
909
|
ctx,
|
|
859
910
|
width,
|
|
860
911
|
notice: footerNotice?.text ?? null,
|
|
861
912
|
discoveryStatus,
|
|
862
|
-
filterQuery: !filterOpen && filterInput.length > 0 ? filterInput : null,
|
|
863
913
|
...(discoverySpinnerIntervalMs === undefined ? {} : { discoverySpinnerIntervalMs }),
|
|
864
914
|
...(discoverySpinnerInitialFrameIndex === undefined
|
|
865
915
|
? {}
|
|
866
916
|
: { discoverySpinnerInitialFrameIndex }),
|
|
867
917
|
...(discoverySpinnerRegisterTick === undefined ? {} : { discoverySpinnerRegisterTick }),
|
|
918
|
+
...(discoveryWarningStatus === null
|
|
919
|
+
? {}
|
|
920
|
+
: {
|
|
921
|
+
onDiscoveryWarningToggle: () =>
|
|
922
|
+
dispatchFloatingOverlay({
|
|
923
|
+
type: "toggle-status-popover",
|
|
924
|
+
content: discoveryWarningStatus,
|
|
925
|
+
}),
|
|
926
|
+
}),
|
|
868
927
|
} satisfies FooterProps<BrowserCtx>
|
|
869
928
|
const readerEmptyStateTips = useMemo(() => buildReaderEmptyStateTips(browserBindings, ctx), [ctx])
|
|
870
929
|
const readerEmptyStateTip = useMemo(
|
|
@@ -885,33 +944,25 @@ export const Browser = ({
|
|
|
885
944
|
/>
|
|
886
945
|
)}
|
|
887
946
|
{displayedFiles.length === 0 ? (
|
|
888
|
-
<
|
|
889
|
-
|
|
947
|
+
<SidebarEmptyMessage
|
|
948
|
+
withTopSpacer={filterRowVisible}
|
|
949
|
+
width={sidebarTextWidth}
|
|
950
|
+
label={
|
|
890
951
|
files.length === 0
|
|
891
952
|
? discoveryActive
|
|
892
|
-
? "
|
|
893
|
-
: "
|
|
894
|
-
: "
|
|
953
|
+
? "Scanning"
|
|
954
|
+
: "No markdown files in"
|
|
955
|
+
: "No files match"
|
|
895
956
|
}
|
|
896
|
-
|
|
957
|
+
value={files.length === 0 ? (discoveryActive ? "…" : emptyRootLabel) : filterApplied}
|
|
897
958
|
/>
|
|
898
959
|
) : (
|
|
899
960
|
visibleFiles.map((file, idx) => {
|
|
900
961
|
const realIdx = desiredScroll + idx
|
|
901
962
|
const isSelected = realIdx === selectedIndex
|
|
902
963
|
const { basename, separator, parent } = layoutSidebarRow(file.relativePath)
|
|
903
|
-
const
|
|
904
|
-
|
|
905
|
-
? colors.primary
|
|
906
|
-
: colors.selectedListItemText
|
|
907
|
-
const basenameFg = isSelected
|
|
908
|
-
? sidebarActive
|
|
909
|
-
? selectedFg
|
|
910
|
-
: colors.primary
|
|
911
|
-
: colors.text
|
|
912
|
-
const rowStyle = isSelected
|
|
913
|
-
? { bg: sidebarActive ? colors.backgroundElement : colors.borderSubtle }
|
|
914
|
-
: {}
|
|
964
|
+
const basenameFg = isSelected ? colors.selectedListItemText : colors.text
|
|
965
|
+
const rowStyle = isSelected ? { bg: colors.backgroundElement } : {}
|
|
915
966
|
return (
|
|
916
967
|
<text key={file.path} wrapMode="none" style={rowStyle}>
|
|
917
968
|
<span style={{ fg: basenameFg }}>{basename}</span>
|
|
@@ -946,6 +997,7 @@ export const Browser = ({
|
|
|
946
997
|
rightT: "┤",
|
|
947
998
|
cross: "┼",
|
|
948
999
|
} as const
|
|
1000
|
+
const INACTIVE_PANE_OPACITY = 0.62
|
|
949
1001
|
|
|
950
1002
|
return (
|
|
951
1003
|
<box
|
|
@@ -966,7 +1018,7 @@ export const Browser = ({
|
|
|
966
1018
|
// Narrow mode runs single-pane: the sidebar fills the area
|
|
967
1019
|
// and drops its right divider (no neighbour to abut).
|
|
968
1020
|
border: isNarrow ? readerBorderSides : sidebarBorderSides,
|
|
969
|
-
borderColor: colors.
|
|
1021
|
+
borderColor: colors.border,
|
|
970
1022
|
...(isNarrow
|
|
971
1023
|
? { flexGrow: 1, flexShrink: 1 }
|
|
972
1024
|
: { width: sidebarWidth, flexShrink: 0 }),
|
|
@@ -985,6 +1037,7 @@ export const Browser = ({
|
|
|
985
1037
|
flexDirection: "column",
|
|
986
1038
|
paddingLeft: 1,
|
|
987
1039
|
backgroundColor: sidebarActive ? colors.background : colors.backgroundPanel,
|
|
1040
|
+
opacity: sidebarActive ? 1 : INACTIVE_PANE_OPACITY,
|
|
988
1041
|
}}
|
|
989
1042
|
>
|
|
990
1043
|
{sidebarBody}
|
|
@@ -995,7 +1048,7 @@ export const Browser = ({
|
|
|
995
1048
|
<box
|
|
996
1049
|
style={{
|
|
997
1050
|
border: readerBorderSides,
|
|
998
|
-
borderColor: colors.
|
|
1051
|
+
borderColor: colors.border,
|
|
999
1052
|
flexGrow: 1,
|
|
1000
1053
|
flexShrink: 1,
|
|
1001
1054
|
flexDirection: "column",
|
|
@@ -1010,6 +1063,7 @@ export const Browser = ({
|
|
|
1010
1063
|
flexDirection: "column",
|
|
1011
1064
|
padding: 1,
|
|
1012
1065
|
backgroundColor: readerActive ? colors.background : colors.backgroundPanel,
|
|
1066
|
+
opacity: readerActive ? 1 : INACTIVE_PANE_OPACITY,
|
|
1013
1067
|
}}
|
|
1014
1068
|
>
|
|
1015
1069
|
{error ? (
|
|
@@ -1053,17 +1107,38 @@ export const Browser = ({
|
|
|
1053
1107
|
}}
|
|
1054
1108
|
// opentui's scrollbox consumes arrow keys at the focused-element
|
|
1055
1109
|
// level *before* useKeyboard fires, so a modal that handles
|
|
1056
|
-
// arrow keys itself (palette nav
|
|
1110
|
+
// arrow keys itself (palette nav) would still
|
|
1057
1111
|
// see the reader scroll alongside its own action. Unfocus the
|
|
1058
1112
|
// scrollbox while any blocking modal is up — useKeyboard's
|
|
1059
1113
|
// modal branches own the keys in that state. Filter is not
|
|
1060
1114
|
// listed because it force-focuses the sidebar (readerActive
|
|
1061
1115
|
// is already false).
|
|
1062
|
-
focused={readerActive && !paletteOpen
|
|
1116
|
+
focused={readerActive && !paletteOpen}
|
|
1063
1117
|
>
|
|
1118
|
+
{parsedContent.fields.length > 0 && (
|
|
1119
|
+
<box style={{ flexDirection: "column", marginBottom: 1 }}>
|
|
1120
|
+
{parsedContent.fields.map((field) => (
|
|
1121
|
+
<box
|
|
1122
|
+
key={field.key}
|
|
1123
|
+
style={{ flexDirection: "row", gap: 1, flexWrap: "wrap" }}
|
|
1124
|
+
>
|
|
1125
|
+
<text
|
|
1126
|
+
content={`${field.key}:`}
|
|
1127
|
+
wrapMode="word"
|
|
1128
|
+
style={{ fg: colors.secondary }}
|
|
1129
|
+
/>
|
|
1130
|
+
<text
|
|
1131
|
+
content={field.value}
|
|
1132
|
+
wrapMode="word"
|
|
1133
|
+
style={{ fg: colors.textMuted }}
|
|
1134
|
+
/>
|
|
1135
|
+
</box>
|
|
1136
|
+
))}
|
|
1137
|
+
</box>
|
|
1138
|
+
)}
|
|
1064
1139
|
<markdown
|
|
1065
1140
|
key={renderedPath ?? "empty"}
|
|
1066
|
-
content={
|
|
1141
|
+
content={parsedContent.body}
|
|
1067
1142
|
syntaxStyle={syntaxStyle}
|
|
1068
1143
|
fg={colors.text}
|
|
1069
1144
|
bg={readerActive ? colors.background : colors.backgroundPanel}
|
|
@@ -1077,18 +1152,18 @@ export const Browser = ({
|
|
|
1077
1152
|
)}
|
|
1078
1153
|
</box>
|
|
1079
1154
|
<Footer {...footerProps} />
|
|
1080
|
-
{helpVisible && (
|
|
1081
|
-
<HelpOverlay bindings={browserBindings} viewportWidth={width} viewportHeight={height} />
|
|
1082
|
-
)}
|
|
1083
1155
|
{paletteOpen && (
|
|
1084
1156
|
<CommandPalette
|
|
1085
|
-
commands={filterCommands(buildCommands(ctx), paletteQuery)}
|
|
1157
|
+
commands={orderCommandsForPalette(filterCommands(buildCommands(ctx), paletteQuery))}
|
|
1086
1158
|
query={paletteQuery}
|
|
1087
1159
|
selectedIndex={paletteIndex}
|
|
1088
1160
|
viewportWidth={width}
|
|
1089
1161
|
viewportHeight={height}
|
|
1090
1162
|
/>
|
|
1091
1163
|
)}
|
|
1164
|
+
{activeStatusPopover && (
|
|
1165
|
+
<StatusPopoverPanel content={activeStatusPopover.content} variant="warning" />
|
|
1166
|
+
)}
|
|
1092
1167
|
</box>
|
|
1093
1168
|
)
|
|
1094
1169
|
}
|