@carlesandres/house 0.4.5 → 0.4.7
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 +60 -4
- package/README.md +11 -8
- package/package.json +2 -1
- package/src/Browser.tsx +208 -56
- package/src/CommandPalette.tsx +4 -4
- package/src/Footer.tsx +33 -31
- package/src/Header.tsx +1 -1
- package/src/HelpOverlay.tsx +1 -1
- package/src/PromptRow.tsx +7 -5
- package/src/Spinner.tsx +39 -0
- package/src/cli/argv.ts +92 -108
- package/src/config/load.ts +29 -15
- package/src/discovery/filter.ts +44 -12
- package/src/index.tsx +61 -12
- package/src/keymap/browser.ts +11 -4
- package/src/keymap/displayKey.ts +17 -0
- package/src/keymap/keymap.ts +3 -0
- package/src/serve/server.ts +1 -1
- package/src/theme/colors.ts +5 -37
- package/src/theme/types.ts +7 -8
- package/src/tips.ts +93 -0
package/src/Browser.tsx
CHANGED
|
@@ -22,7 +22,7 @@ import { CommandPalette } from "./CommandPalette.tsx"
|
|
|
22
22
|
import { filterFiles } from "./discovery/filter.ts"
|
|
23
23
|
import { type FileEntry } from "./discovery/walk.ts"
|
|
24
24
|
import { BRAND, BRAND_NAME } from "./brand.ts"
|
|
25
|
-
import { Footer, FOOTER_HEIGHT } from "./Footer.tsx"
|
|
25
|
+
import { Footer, FOOTER_HEIGHT, type FooterProps } from "./Footer.tsx"
|
|
26
26
|
import { Header, HEADER_HEIGHT } from "./Header.tsx"
|
|
27
27
|
import { HelpOverlay } from "./HelpOverlay.tsx"
|
|
28
28
|
import { openInEditor, resolveEditor } from "./io/editor.ts"
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
} from "./layout/resolve.ts"
|
|
38
38
|
import { formatSidebarRow } from "./layout/sidebarRow.ts"
|
|
39
39
|
import { PromptRow } from "./PromptRow.tsx"
|
|
40
|
+
import { buildReaderEmptyStateTips, pickTipByRotation } from "./tips.ts"
|
|
40
41
|
import { openInBrowser } from "./serve/openBrowser.ts"
|
|
41
42
|
import { startServer, type ServerHandle } from "./serve/server.ts"
|
|
42
43
|
import { colors, setActiveTheme } from "./theme/colors.ts"
|
|
@@ -44,6 +45,7 @@ import { themeAtom } from "./theme/atom.ts"
|
|
|
44
45
|
import { themeDefinitions, getThemeDefinition } from "./theme/registry.ts"
|
|
45
46
|
|
|
46
47
|
export type SidebarMode = "auto" | "on" | "off"
|
|
48
|
+
export type StartupFocus = "sidebar" | "reader" | "filter"
|
|
47
49
|
|
|
48
50
|
export interface BrowserProps {
|
|
49
51
|
readonly files: readonly FileEntry[]
|
|
@@ -53,6 +55,17 @@ export interface BrowserProps {
|
|
|
53
55
|
/** Persistent footer indicator (e.g. "indexing… 42"). Pass null/undefined
|
|
54
56
|
* when discovery has finished; the indicator clears. */
|
|
55
57
|
readonly discoveryStatus?: string | null
|
|
58
|
+
/** Test seam: override the footer discovery spinner speed. */
|
|
59
|
+
readonly discoverySpinnerIntervalMs?: number
|
|
60
|
+
readonly discoverySpinnerInitialFrameIndex?: number
|
|
61
|
+
/** Test seam: deterministic footer spinner driver. */
|
|
62
|
+
readonly discoverySpinnerRegisterTick?: ((tick: () => void) => void) | null
|
|
63
|
+
/** Test seam: override filter debounce timing. */
|
|
64
|
+
readonly filterDebounceMs?: number
|
|
65
|
+
/** Test seam: override rendered-path debounce timing. */
|
|
66
|
+
readonly renderedPathDebounceMs?: number
|
|
67
|
+
/** Test seam: disable reader-empty-state tip rotation effect. */
|
|
68
|
+
readonly disableReaderEmptyStateRotation?: boolean
|
|
56
69
|
/** Initial sidebar visibility (`--sidebar` flag). `auto` consults the
|
|
57
70
|
* launch viewport bucket once; subsequent visibility goes through `s`. */
|
|
58
71
|
readonly sidebarMode?: SidebarMode
|
|
@@ -67,15 +80,16 @@ export interface BrowserProps {
|
|
|
67
80
|
/** TTL (ms) for the update-notice toast. Exposed so tests can use a small
|
|
68
81
|
* value instead of sleeping for the production 10s window. */
|
|
69
82
|
readonly updateNoticeTtlMs?: number
|
|
83
|
+
/** Test seam: disable footer-notice auto-clear timers. */
|
|
84
|
+
readonly disableFooterNoticeAutoClear?: boolean
|
|
70
85
|
/** Flip the parent's discovery vocabulary (#145). Browser doesn't need
|
|
71
86
|
* to know which categories are currently on — the toggle is opaque
|
|
72
87
|
* from this side; we just snapshot the selected path so it can be
|
|
73
88
|
* restored across the re-walk the parent triggers. */
|
|
74
89
|
readonly onToggleAll?: () => void
|
|
75
|
-
/**
|
|
76
|
-
*
|
|
77
|
-
|
|
78
|
-
readonly startInFilter?: boolean
|
|
90
|
+
/** Startup pane/input target. `filter` opens the sidebar filter prompt on
|
|
91
|
+
* mount so the user can type immediately. */
|
|
92
|
+
readonly startupFocus?: StartupFocus | null
|
|
79
93
|
}
|
|
80
94
|
|
|
81
95
|
const defaultReadFile = (path: string): Promise<string> => Effect.runPromise(readFileText(path))
|
|
@@ -94,18 +108,38 @@ const HELP_ALLOWED_IDS: ReadonlySet<string> = new Set([
|
|
|
94
108
|
"palette.open",
|
|
95
109
|
])
|
|
96
110
|
|
|
111
|
+
let nextReaderEmptyStateTipRotation = 0
|
|
112
|
+
|
|
113
|
+
export const resetReaderEmptyStateTipRotationForTests = () => {
|
|
114
|
+
nextReaderEmptyStateTipRotation = 0
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const setReaderEmptyStateTipRotationForTests = (next: number) => {
|
|
118
|
+
nextReaderEmptyStateTipRotation = next
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const FILTER_DEBOUNCE_MS = 50
|
|
122
|
+
const RENDERED_PATH_DEBOUNCE_MS = 80
|
|
123
|
+
|
|
97
124
|
export const Browser = ({
|
|
98
125
|
files,
|
|
99
126
|
initialIndex = 0,
|
|
100
127
|
maxWidth = null,
|
|
101
128
|
discoveryStatus = null,
|
|
129
|
+
discoverySpinnerIntervalMs,
|
|
130
|
+
discoverySpinnerInitialFrameIndex,
|
|
131
|
+
discoverySpinnerRegisterTick = null,
|
|
132
|
+
filterDebounceMs = FILTER_DEBOUNCE_MS,
|
|
133
|
+
renderedPathDebounceMs = RENDERED_PATH_DEBOUNCE_MS,
|
|
134
|
+
disableReaderEmptyStateRotation = false,
|
|
102
135
|
sidebarMode = "auto",
|
|
103
136
|
onQuit,
|
|
104
137
|
readFile = defaultReadFile,
|
|
105
138
|
updateNotice = null,
|
|
106
139
|
updateNoticeTtlMs = 10000,
|
|
140
|
+
disableFooterNoticeAutoClear = false,
|
|
107
141
|
onToggleAll,
|
|
108
|
-
|
|
142
|
+
startupFocus = null,
|
|
109
143
|
}: BrowserProps) => {
|
|
110
144
|
const renderer = useRenderer()
|
|
111
145
|
const { width, height } = useTerminalDimensions()
|
|
@@ -134,17 +168,29 @@ export const Browser = ({
|
|
|
134
168
|
return initialShownForAuto(width)
|
|
135
169
|
}
|
|
136
170
|
})
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
171
|
+
const startInFilter = startupFocus === "filter"
|
|
172
|
+
const initialFocus: "sidebar" | "reader" =
|
|
173
|
+
startupFocus === null
|
|
174
|
+
? shown
|
|
175
|
+
? "sidebar"
|
|
176
|
+
: "reader"
|
|
177
|
+
: startupFocus === "reader"
|
|
178
|
+
? "reader"
|
|
179
|
+
: "sidebar"
|
|
180
|
+
// `filter` mirrors `openFilter`'s focus rule: the filter input lives in
|
|
181
|
+
// the sidebar, so opening it on mount also forces sidebar focus regardless
|
|
182
|
+
// of `--sidebar=off` (§7.1's visibility derivation surfaces the sidebar via
|
|
183
|
+
// focus even when `shown` is false). Plain `sidebar` startup shares the
|
|
184
|
+
// same pane focus without opening the prompt. When omitted, preserve the
|
|
185
|
+
// legacy Browser behavior: initial focus follows visibility.
|
|
141
186
|
const [focus, setFocus] = useState<"sidebar" | "reader">(() =>
|
|
142
|
-
shown ||
|
|
187
|
+
shown || initialFocus === "sidebar" ? "sidebar" : "reader",
|
|
143
188
|
)
|
|
144
189
|
const [sidebarScroll, setSidebarScroll] = useState<number>(0)
|
|
145
190
|
const [helpVisible, setHelpVisible] = useState<boolean>(false)
|
|
146
191
|
const [filterOpen, setFilterOpen] = useState<boolean>(startInFilter)
|
|
147
|
-
const [
|
|
192
|
+
const [filterInput, setFilterInput] = useState<string>("")
|
|
193
|
+
const [filterApplied, setFilterApplied] = useState<string>("")
|
|
148
194
|
const [paletteOpen, setPaletteOpen] = useState<boolean>(false)
|
|
149
195
|
const [paletteQuery, setPaletteQuery] = useState<string>("")
|
|
150
196
|
const [paletteIndex, setPaletteIndex] = useState<number>(0)
|
|
@@ -155,12 +201,20 @@ export const Browser = ({
|
|
|
155
201
|
const paletteOpenRef = useRef(false)
|
|
156
202
|
const paletteQueryRef = useRef("")
|
|
157
203
|
const paletteIndexRef = useRef(0)
|
|
204
|
+
const [readerEmptyStateTipRotation, setReaderEmptyStateTipRotation] = useState(
|
|
205
|
+
() => nextReaderEmptyStateTipRotation,
|
|
206
|
+
)
|
|
207
|
+
const readerEmptyStateVisibleRef = useRef(false)
|
|
158
208
|
// Mirror filter state into refs so the keyboard handler sees synchronous
|
|
159
209
|
// updates even when multiple keys arrive in a single React batch (the
|
|
160
210
|
// first key opens the filter; subsequent keys in the same tick would
|
|
161
211
|
// otherwise still observe filterOpen=false through closure).
|
|
162
212
|
const filterOpenRef = useRef(startInFilter)
|
|
163
|
-
const
|
|
213
|
+
const filterInputRef = useRef("")
|
|
214
|
+
const filterAppliedRef = useRef("")
|
|
215
|
+
const autoSelectForAppliedFilterRef = useRef(true)
|
|
216
|
+
const focusRef = useRef<"sidebar" | "reader">(focus)
|
|
217
|
+
const restoreFilterOnSidebarFocusRef = useRef(startInFilter)
|
|
164
218
|
const [footerNotice, setFooterNoticeState] = useState<{
|
|
165
219
|
readonly text: string
|
|
166
220
|
readonly ttlMs: number
|
|
@@ -192,15 +246,20 @@ export const Browser = ({
|
|
|
192
246
|
// the other's display window.
|
|
193
247
|
useEffect(() => {
|
|
194
248
|
if (footerNotice === null) return
|
|
249
|
+
if (disableFooterNoticeAutoClear) return
|
|
195
250
|
const timer = setTimeout(() => setFooterNoticeState(null), footerNotice.ttlMs)
|
|
196
251
|
return () => clearTimeout(timer)
|
|
197
|
-
}, [footerNotice])
|
|
252
|
+
}, [disableFooterNoticeAutoClear, footerNotice])
|
|
198
253
|
|
|
199
254
|
// Push the update-available nudge once, when it arrives from the parent
|
|
200
255
|
// (the registry probe resolves asynchronously after boot). 10s gives the
|
|
201
256
|
// user time to read it before it auto-clears; the quit-time stderr print
|
|
202
257
|
// is the durable record they can copy from scrollback.
|
|
203
258
|
const updateNoticeSeenRef = useRef<string | null>(null)
|
|
259
|
+
useEffect(() => {
|
|
260
|
+
focusRef.current = focus
|
|
261
|
+
}, [focus])
|
|
262
|
+
|
|
204
263
|
useEffect(() => {
|
|
205
264
|
if (!updateNotice) return
|
|
206
265
|
if (updateNoticeSeenRef.current === updateNotice) return
|
|
@@ -225,7 +284,29 @@ export const Browser = ({
|
|
|
225
284
|
pushFooterNotice(`tone: ${nextTone}`)
|
|
226
285
|
}
|
|
227
286
|
|
|
228
|
-
|
|
287
|
+
useEffect(() => {
|
|
288
|
+
if (filterInput === filterApplied) return
|
|
289
|
+
const timer = setTimeout(() => {
|
|
290
|
+
filterAppliedRef.current = filterInput
|
|
291
|
+
setFilterApplied(filterInput)
|
|
292
|
+
}, filterDebounceMs)
|
|
293
|
+
return () => clearTimeout(timer)
|
|
294
|
+
}, [filterApplied, filterDebounceMs, filterInput])
|
|
295
|
+
|
|
296
|
+
useEffect(() => {
|
|
297
|
+
autoSelectForAppliedFilterRef.current = true
|
|
298
|
+
}, [filterApplied])
|
|
299
|
+
|
|
300
|
+
const displayedFiles = useMemo(() => filterFiles(files, filterApplied), [files, filterApplied])
|
|
301
|
+
const filterHasNoMatches = filterInput.length > 0 && displayedFiles.length === 0
|
|
302
|
+
|
|
303
|
+
useEffect(() => {
|
|
304
|
+
if (filterApplied.length === 0) return
|
|
305
|
+
if (!autoSelectForAppliedFilterRef.current) return
|
|
306
|
+
if (displayedFiles.length === 0) return
|
|
307
|
+
setSelectedIndex(0)
|
|
308
|
+
autoSelectForAppliedFilterRef.current = false
|
|
309
|
+
}, [displayedFiles, filterApplied])
|
|
229
310
|
// When the filtered list shrinks, keep selectedIndex valid. The reset to 0
|
|
230
311
|
// on every query change happens in the keystroke handler, not here, so a
|
|
231
312
|
// no-op rerender doesn't snap the cursor back to the top.
|
|
@@ -263,9 +344,9 @@ export const Browser = ({
|
|
|
263
344
|
useEffect(() => {
|
|
264
345
|
const target = selected?.path ?? null
|
|
265
346
|
if (target === renderedPath) return
|
|
266
|
-
const timer = setTimeout(() => setRenderedPath(target),
|
|
347
|
+
const timer = setTimeout(() => setRenderedPath(target), renderedPathDebounceMs)
|
|
267
348
|
return () => clearTimeout(timer)
|
|
268
|
-
}, [selected?.path, renderedPath])
|
|
349
|
+
}, [selected?.path, renderedPath, renderedPathDebounceMs])
|
|
269
350
|
|
|
270
351
|
useEffect(() => {
|
|
271
352
|
if (!renderedPath) {
|
|
@@ -306,7 +387,8 @@ export const Browser = ({
|
|
|
306
387
|
sidebarShown: shown,
|
|
307
388
|
helpVisible,
|
|
308
389
|
filterOpen,
|
|
309
|
-
|
|
390
|
+
restoreFilterOnSidebarFocus: restoreFilterOnSidebarFocusRef.current,
|
|
391
|
+
filterQuery: filterInput,
|
|
310
392
|
paletteOpen,
|
|
311
393
|
setFocus,
|
|
312
394
|
// Wrapped so any keymap-driven selection move (j/k/g/G/[/], reader
|
|
@@ -316,6 +398,7 @@ export const Browser = ({
|
|
|
316
398
|
// itself) deliberately use the raw `setSelectedIndex` setter.
|
|
317
399
|
setSelectedIndex: (updater) => {
|
|
318
400
|
pendingSelectionPathRef.current = null
|
|
401
|
+
autoSelectForAppliedFilterRef.current = false
|
|
319
402
|
setSelectedIndex(updater)
|
|
320
403
|
},
|
|
321
404
|
toggleShown: () => {
|
|
@@ -349,7 +432,9 @@ export const Browser = ({
|
|
|
349
432
|
// the inline sidebar back on screen if it was hidden. In narrow,
|
|
350
433
|
// focusing the sidebar swaps to the sidebar screen. Either way
|
|
351
434
|
// no need to mutate `shown`.
|
|
435
|
+
focusRef.current = "sidebar"
|
|
352
436
|
if (focus !== "sidebar") setFocus("sidebar")
|
|
437
|
+
restoreFilterOnSidebarFocusRef.current = true
|
|
353
438
|
filterOpenRef.current = true
|
|
354
439
|
setFilterOpen(true)
|
|
355
440
|
},
|
|
@@ -357,10 +442,14 @@ export const Browser = ({
|
|
|
357
442
|
// Reset both the ref and the state so the freshly-opened modal
|
|
358
443
|
// shows an empty input and selection lands on the first file in
|
|
359
444
|
// the (now unfiltered) list.
|
|
360
|
-
|
|
361
|
-
|
|
445
|
+
filterInputRef.current = ""
|
|
446
|
+
filterAppliedRef.current = ""
|
|
447
|
+
setFilterInput("")
|
|
448
|
+
setFilterApplied("")
|
|
362
449
|
setSelectedIndex(() => 0)
|
|
450
|
+
focusRef.current = "sidebar"
|
|
363
451
|
if (focus !== "sidebar") setFocus("sidebar")
|
|
452
|
+
restoreFilterOnSidebarFocusRef.current = true
|
|
364
453
|
filterOpenRef.current = true
|
|
365
454
|
setFilterOpen(true)
|
|
366
455
|
},
|
|
@@ -429,7 +518,7 @@ export const Browser = ({
|
|
|
429
518
|
if (!file) return
|
|
430
519
|
const editor = resolveEditor(process.env)
|
|
431
520
|
if (!editor) {
|
|
432
|
-
pushFooterNotice("set $EDITOR or $VISUAL to use
|
|
521
|
+
pushFooterNotice("set $EDITOR or $VISUAL to use E")
|
|
433
522
|
return
|
|
434
523
|
}
|
|
435
524
|
if (!renderer) {
|
|
@@ -500,13 +589,16 @@ export const Browser = ({
|
|
|
500
589
|
// so normal bindings (j/k as nav, `s`, `t`, …) don't fire while
|
|
501
590
|
// the user is typing. This sits outside the data-driven keymap
|
|
502
591
|
// for the same reason the help branch does — see DESIGN.md §12.
|
|
503
|
-
if (filterOpenRef.current) {
|
|
592
|
+
if (filterOpenRef.current && focusRef.current === "sidebar") {
|
|
504
593
|
// One close path used by both Esc and Return. `commit=true` is
|
|
505
594
|
// the Return semantic (open the match in the reader); false is
|
|
506
595
|
// Esc (stop typing, keep the applied filter, stay in sidebar).
|
|
507
596
|
const closeFilter = (commit: boolean) => {
|
|
597
|
+
filterAppliedRef.current = filterInputRef.current
|
|
598
|
+
setFilterApplied(filterInputRef.current)
|
|
508
599
|
const picked = displayedFiles[selectedIndex] ?? null
|
|
509
600
|
const effectiveCommit = commit && picked !== null
|
|
601
|
+
restoreFilterOnSidebarFocusRef.current = false
|
|
510
602
|
filterOpenRef.current = false
|
|
511
603
|
setFilterOpen(false)
|
|
512
604
|
// Where focus lands after the filter closes:
|
|
@@ -515,9 +607,12 @@ export const Browser = ({
|
|
|
515
607
|
// otherwise → sidebar if it's up so j/k keeps walking the
|
|
516
608
|
// filtered list; reader if the sidebar was hidden.
|
|
517
609
|
if (effectiveCommit) {
|
|
610
|
+
focusRef.current = "reader"
|
|
518
611
|
setFocus("reader")
|
|
519
612
|
} else {
|
|
520
|
-
|
|
613
|
+
const nextFocus = shown ? "sidebar" : "reader"
|
|
614
|
+
focusRef.current = nextFocus
|
|
615
|
+
setFocus(nextFocus)
|
|
521
616
|
}
|
|
522
617
|
}
|
|
523
618
|
if (key.name === "escape") {
|
|
@@ -528,25 +623,35 @@ export const Browser = ({
|
|
|
528
623
|
closeFilter(true)
|
|
529
624
|
return
|
|
530
625
|
}
|
|
626
|
+
if (key.name === "tab" || (key.ctrl && key.name === "i" && !key.shift && !key.meta)) {
|
|
627
|
+
focusRef.current = "reader"
|
|
628
|
+
restoreFilterOnSidebarFocusRef.current = true
|
|
629
|
+
filterOpenRef.current = false
|
|
630
|
+
setFilterOpen(false)
|
|
631
|
+
setFocus("reader")
|
|
632
|
+
return
|
|
633
|
+
}
|
|
531
634
|
if (key.ctrl && key.name === "\\") {
|
|
532
635
|
// Same action as the `filter.clearOrOpen` binding fires from
|
|
533
636
|
// outside the modal: clear the query, reset selection. The
|
|
534
637
|
// keymap doesn't see keys in filter mode, so this branch is
|
|
535
638
|
// the in-modal half of that single chord.
|
|
536
|
-
|
|
537
|
-
|
|
639
|
+
filterInputRef.current = ""
|
|
640
|
+
filterAppliedRef.current = ""
|
|
641
|
+
setFilterInput("")
|
|
642
|
+
setFilterApplied("")
|
|
538
643
|
setSelectedIndex(() => 0)
|
|
539
644
|
return
|
|
540
645
|
}
|
|
541
646
|
if (key.name === "backspace" || key.name === "delete") {
|
|
542
647
|
// Backspace on empty input closes the modal — the leading `/`
|
|
543
648
|
// chevron is the last thing left to "delete."
|
|
544
|
-
if (
|
|
649
|
+
if (filterInputRef.current.length === 0) {
|
|
545
650
|
closeFilter(false)
|
|
546
651
|
return
|
|
547
652
|
}
|
|
548
|
-
|
|
549
|
-
|
|
653
|
+
filterInputRef.current = filterInputRef.current.slice(0, -1)
|
|
654
|
+
setFilterInput(filterInputRef.current)
|
|
550
655
|
setSelectedIndex(() => 0)
|
|
551
656
|
return
|
|
552
657
|
}
|
|
@@ -565,8 +670,8 @@ export const Browser = ({
|
|
|
565
670
|
char = key.shift ? key.name.toUpperCase() : key.name
|
|
566
671
|
}
|
|
567
672
|
if (char !== null) {
|
|
568
|
-
|
|
569
|
-
|
|
673
|
+
filterInputRef.current = filterInputRef.current + char
|
|
674
|
+
setFilterInput(filterInputRef.current)
|
|
570
675
|
setSelectedIndex(() => 0)
|
|
571
676
|
}
|
|
572
677
|
return
|
|
@@ -682,6 +787,22 @@ export const Browser = ({
|
|
|
682
787
|
// per-pane border title that used to carry this information).
|
|
683
788
|
const currentFile = selected?.relativePath ?? null
|
|
684
789
|
const content = loaded?.path === renderedPath ? loaded.content : ""
|
|
790
|
+
const readerEmptyStateTitle = filterHasNoMatches
|
|
791
|
+
? `No files match: ${filterInput}`
|
|
792
|
+
: `${BRAND} ${BRAND_NAME}`
|
|
793
|
+
const readerEmptyStateVisible = error == null && renderedPath == null
|
|
794
|
+
|
|
795
|
+
useEffect(() => {
|
|
796
|
+
if (disableReaderEmptyStateRotation) return
|
|
797
|
+
if (readerEmptyStateVisible) {
|
|
798
|
+
if (!readerEmptyStateVisibleRef.current) {
|
|
799
|
+
readerEmptyStateVisibleRef.current = true
|
|
800
|
+
setReaderEmptyStateTipRotation(nextReaderEmptyStateTipRotation++)
|
|
801
|
+
}
|
|
802
|
+
return
|
|
803
|
+
}
|
|
804
|
+
readerEmptyStateVisibleRef.current = false
|
|
805
|
+
}, [disableReaderEmptyStateRotation, readerEmptyStateVisible])
|
|
685
806
|
|
|
686
807
|
// Sidebar virtualization: render only the visible window. Without this,
|
|
687
808
|
// every keystroke re-renders all N file rows even though only the bg of
|
|
@@ -732,6 +853,24 @@ export const Browser = ({
|
|
|
732
853
|
: browserBindings,
|
|
733
854
|
[helpVisible],
|
|
734
855
|
)
|
|
856
|
+
const footerProps = {
|
|
857
|
+
bindings: footerBindings,
|
|
858
|
+
ctx,
|
|
859
|
+
width,
|
|
860
|
+
notice: footerNotice?.text ?? null,
|
|
861
|
+
discoveryStatus,
|
|
862
|
+
filterQuery: !filterOpen && filterInput.length > 0 ? filterInput : null,
|
|
863
|
+
...(discoverySpinnerIntervalMs === undefined ? {} : { discoverySpinnerIntervalMs }),
|
|
864
|
+
...(discoverySpinnerInitialFrameIndex === undefined
|
|
865
|
+
? {}
|
|
866
|
+
: { discoverySpinnerInitialFrameIndex }),
|
|
867
|
+
...(discoverySpinnerRegisterTick === undefined ? {} : { discoverySpinnerRegisterTick }),
|
|
868
|
+
} satisfies FooterProps<BrowserCtx>
|
|
869
|
+
const readerEmptyStateTips = useMemo(() => buildReaderEmptyStateTips(browserBindings, ctx), [ctx])
|
|
870
|
+
const readerEmptyStateTip = useMemo(
|
|
871
|
+
() => pickTipByRotation(readerEmptyStateTips, readerEmptyStateTipRotation),
|
|
872
|
+
[readerEmptyStateTipRotation, readerEmptyStateTips],
|
|
873
|
+
)
|
|
735
874
|
|
|
736
875
|
// One sidebar body for both wide-inline and narrow-stack rendering; only
|
|
737
876
|
// the wrapper differs (fixed-width sibling vs flex-grow full-pane).
|
|
@@ -739,7 +878,7 @@ export const Browser = ({
|
|
|
739
878
|
<>
|
|
740
879
|
{filterRowVisible && (
|
|
741
880
|
<PromptRow
|
|
742
|
-
query={
|
|
881
|
+
query={filterInput}
|
|
743
882
|
editing={filterOpen}
|
|
744
883
|
placeholder="/ to filter…"
|
|
745
884
|
width={sidebarTextWidth}
|
|
@@ -761,9 +900,17 @@ export const Browser = ({
|
|
|
761
900
|
const realIdx = desiredScroll + idx
|
|
762
901
|
const isSelected = realIdx === selectedIndex
|
|
763
902
|
const { basename, separator, parent } = layoutSidebarRow(file.relativePath)
|
|
764
|
-
const
|
|
903
|
+
const selectedFg =
|
|
904
|
+
colors.selectedListItemText === colors.background
|
|
905
|
+
? colors.primary
|
|
906
|
+
: colors.selectedListItemText
|
|
907
|
+
const basenameFg = isSelected
|
|
908
|
+
? sidebarActive
|
|
909
|
+
? selectedFg
|
|
910
|
+
: colors.primary
|
|
911
|
+
: colors.text
|
|
765
912
|
const rowStyle = isSelected
|
|
766
|
-
? { bg: sidebarActive ? colors.
|
|
913
|
+
? { bg: sidebarActive ? colors.backgroundElement : colors.borderSubtle }
|
|
767
914
|
: {}
|
|
768
915
|
return (
|
|
769
916
|
<text key={file.path} wrapMode="none" style={rowStyle}>
|
|
@@ -801,14 +948,16 @@ export const Browser = ({
|
|
|
801
948
|
} as const
|
|
802
949
|
|
|
803
950
|
return (
|
|
804
|
-
<box
|
|
951
|
+
<box
|
|
952
|
+
style={{ width, height, flexDirection: "column", backgroundColor: colors.backgroundPanel }}
|
|
953
|
+
>
|
|
805
954
|
<Header width={width} currentFile={currentFile} />
|
|
806
955
|
<box
|
|
807
956
|
style={{
|
|
808
957
|
flexDirection: "row",
|
|
809
958
|
flexGrow: 1,
|
|
810
959
|
flexShrink: 1,
|
|
811
|
-
backgroundColor: colors.
|
|
960
|
+
backgroundColor: colors.backgroundPanel,
|
|
812
961
|
}}
|
|
813
962
|
>
|
|
814
963
|
{sidebarInline && (
|
|
@@ -825,7 +974,7 @@ export const Browser = ({
|
|
|
825
974
|
// Dim by default. Borders/separators ride on this so they read
|
|
826
975
|
// as a single connected frame regardless of focus; only the
|
|
827
976
|
// active pane's inner body overrides to the raised tint below.
|
|
828
|
-
backgroundColor: colors.
|
|
977
|
+
backgroundColor: colors.backgroundPanel,
|
|
829
978
|
}}
|
|
830
979
|
{...(isNarrow ? {} : { customBorderChars: SIDEBAR_BORDER_CHARS })}
|
|
831
980
|
>
|
|
@@ -835,7 +984,7 @@ export const Browser = ({
|
|
|
835
984
|
flexShrink: 1,
|
|
836
985
|
flexDirection: "column",
|
|
837
986
|
paddingLeft: 1,
|
|
838
|
-
backgroundColor: sidebarActive ? colors.background : colors.
|
|
987
|
+
backgroundColor: sidebarActive ? colors.background : colors.backgroundPanel,
|
|
839
988
|
}}
|
|
840
989
|
>
|
|
841
990
|
{sidebarBody}
|
|
@@ -851,7 +1000,7 @@ export const Browser = ({
|
|
|
851
1000
|
flexShrink: 1,
|
|
852
1001
|
flexDirection: "column",
|
|
853
1002
|
// Dim by default (see sidebar note); inner body overrides when active.
|
|
854
|
-
backgroundColor: colors.
|
|
1003
|
+
backgroundColor: colors.backgroundPanel,
|
|
855
1004
|
}}
|
|
856
1005
|
>
|
|
857
1006
|
<box
|
|
@@ -860,28 +1009,38 @@ export const Browser = ({
|
|
|
860
1009
|
flexShrink: 1,
|
|
861
1010
|
flexDirection: "column",
|
|
862
1011
|
padding: 1,
|
|
863
|
-
backgroundColor: readerActive ? colors.background : colors.
|
|
1012
|
+
backgroundColor: readerActive ? colors.background : colors.backgroundPanel,
|
|
864
1013
|
}}
|
|
865
1014
|
>
|
|
866
1015
|
{error ? (
|
|
867
1016
|
<text content={error} style={{ fg: colors.error }} />
|
|
868
1017
|
) : !renderedPath ? (
|
|
869
1018
|
// Reader empty state — no file selected. Brand mark centered as a
|
|
870
|
-
// welcome anchor;
|
|
1019
|
+
// welcome anchor; reusable tips live here too.
|
|
871
1020
|
<box
|
|
872
1021
|
style={{
|
|
873
1022
|
flexGrow: 1,
|
|
874
1023
|
flexShrink: 1,
|
|
875
1024
|
alignItems: "center",
|
|
876
1025
|
justifyContent: "center",
|
|
877
|
-
backgroundColor: readerActive ? colors.background : colors.
|
|
1026
|
+
backgroundColor: readerActive ? colors.background : colors.backgroundPanel,
|
|
878
1027
|
}}
|
|
879
1028
|
>
|
|
880
|
-
<
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
1029
|
+
<box style={{ flexDirection: "column", gap: 1, alignItems: "center" }}>
|
|
1030
|
+
<text
|
|
1031
|
+
content={readerEmptyStateTitle}
|
|
1032
|
+
wrapMode="none"
|
|
1033
|
+
style={{ fg: colors.textMuted }}
|
|
1034
|
+
/>
|
|
1035
|
+
{readerEmptyStateTip && (
|
|
1036
|
+
<text
|
|
1037
|
+
key={readerEmptyStateTip.id}
|
|
1038
|
+
content={readerEmptyStateTip.text}
|
|
1039
|
+
wrapMode="none"
|
|
1040
|
+
style={{ fg: colors.textMuted }}
|
|
1041
|
+
/>
|
|
1042
|
+
)}
|
|
1043
|
+
</box>
|
|
885
1044
|
</box>
|
|
886
1045
|
) : (
|
|
887
1046
|
<scrollbox
|
|
@@ -890,7 +1049,7 @@ export const Browser = ({
|
|
|
890
1049
|
scrollX: false,
|
|
891
1050
|
flexGrow: 1,
|
|
892
1051
|
flexShrink: 1,
|
|
893
|
-
backgroundColor: readerActive ? colors.background : colors.
|
|
1052
|
+
backgroundColor: readerActive ? colors.background : colors.backgroundPanel,
|
|
894
1053
|
}}
|
|
895
1054
|
// opentui's scrollbox consumes arrow keys at the focused-element
|
|
896
1055
|
// level *before* useKeyboard fires, so a modal that handles
|
|
@@ -907,7 +1066,7 @@ export const Browser = ({
|
|
|
907
1066
|
content={content}
|
|
908
1067
|
syntaxStyle={syntaxStyle}
|
|
909
1068
|
fg={colors.text}
|
|
910
|
-
bg={readerActive ? colors.background : colors.
|
|
1069
|
+
bg={readerActive ? colors.background : colors.backgroundPanel}
|
|
911
1070
|
conceal
|
|
912
1071
|
style={{ width: maxWidth ?? "100%" }}
|
|
913
1072
|
/>
|
|
@@ -917,14 +1076,7 @@ export const Browser = ({
|
|
|
917
1076
|
</box>
|
|
918
1077
|
)}
|
|
919
1078
|
</box>
|
|
920
|
-
<Footer
|
|
921
|
-
bindings={footerBindings}
|
|
922
|
-
ctx={ctx}
|
|
923
|
-
width={width}
|
|
924
|
-
notice={footerNotice?.text ?? null}
|
|
925
|
-
discoveryStatus={discoveryStatus}
|
|
926
|
-
filterQuery={!filterOpen && filterQuery.length > 0 ? filterQuery : null}
|
|
927
|
-
/>
|
|
1079
|
+
<Footer {...footerProps} />
|
|
928
1080
|
{helpVisible && (
|
|
929
1081
|
<HelpOverlay bindings={browserBindings} viewportWidth={width} viewportHeight={height} />
|
|
930
1082
|
)}
|
package/src/CommandPalette.tsx
CHANGED
|
@@ -102,7 +102,7 @@ export const CommandPalette = ({
|
|
|
102
102
|
border: true,
|
|
103
103
|
borderColor: colors.textMuted,
|
|
104
104
|
flexDirection: "column",
|
|
105
|
-
backgroundColor: colors.
|
|
105
|
+
backgroundColor: colors.backgroundPanel,
|
|
106
106
|
}}
|
|
107
107
|
>
|
|
108
108
|
<PromptRow query={query} editing={true} width={rowWidth} />
|
|
@@ -119,15 +119,15 @@ export const CommandPalette = ({
|
|
|
119
119
|
? fitRight(cmd.shortcut, SHORTCUT_WIDTH)
|
|
120
120
|
: " ".repeat(SHORTCUT_WIDTH)
|
|
121
121
|
// Title and shortcut render as separate spans so the shortcut
|
|
122
|
-
// can use `textMuted` while the title uses `text`/`
|
|
122
|
+
// can use `textMuted` while the title uses `text`/`primary`.
|
|
123
123
|
// Same trick opencode pulls with `--text-weak` — the theme
|
|
124
124
|
// guarantees the contrast, we just pick the right role.
|
|
125
|
-
const titleFg = isSelected ? colors.
|
|
125
|
+
const titleFg = isSelected ? colors.primary : colors.text
|
|
126
126
|
return (
|
|
127
127
|
<text
|
|
128
128
|
key={cmd.id}
|
|
129
129
|
wrapMode="none"
|
|
130
|
-
style={isSelected ? { bg: colors.
|
|
130
|
+
style={isSelected ? { bg: colors.backgroundElement } : {}}
|
|
131
131
|
>
|
|
132
132
|
<span style={{ fg: titleFg }}>{`${selector}${titleText} `}</span>
|
|
133
133
|
<span style={{ fg: colors.textMuted }}>{shortcutText}</span>
|