@carlesandres/house 0.4.0 → 0.4.1
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 +47 -137
- package/README.md +15 -15
- package/package.json +1 -1
- package/src/Browser.tsx +212 -134
- package/src/CommandPalette.tsx +70 -46
- package/src/Footer.tsx +66 -26
- package/src/Header.tsx +68 -0
- package/src/HelpOverlay.tsx +59 -41
- package/src/brand.ts +7 -0
- package/src/cli/argv.ts +34 -3
- package/src/config/load.ts +84 -19
- package/src/discovery/walk.ts +8 -3
- package/src/index.tsx +64 -11
- package/src/layout/resolve.ts +9 -9
- package/src/theme/colors.ts +51 -15
- package/src/theme/types.ts +7 -0
- package/src/update/cache.ts +77 -0
- package/src/update/check.ts +165 -0
- package/src/update/compare.ts +41 -0
- package/src/update/notice.ts +29 -0
- package/src/update/runtime.ts +48 -0
- package/src/update/useUpdateNotice.ts +24 -0
package/src/Browser.tsx
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { SyntaxStyle } from "@opentui/core"
|
|
14
|
+
import type { BorderSides } from "@opentui/core"
|
|
14
15
|
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
|
|
15
16
|
import { useAtomValue, useAtomSet } from "@effect/atom-react"
|
|
16
17
|
import { Effect } from "effect"
|
|
@@ -20,7 +21,9 @@ import { clampSelectedIndex, filterCommands } from "./commands/score.ts"
|
|
|
20
21
|
import { CommandPalette } from "./CommandPalette.tsx"
|
|
21
22
|
import { filterFiles } from "./discovery/filter.ts"
|
|
22
23
|
import { type FileEntry } from "./discovery/walk.ts"
|
|
24
|
+
import { BRAND, BRAND_NAME } from "./brand.ts"
|
|
23
25
|
import { Footer, FOOTER_HEIGHT } from "./Footer.tsx"
|
|
26
|
+
import { Header, HEADER_HEIGHT } from "./Header.tsx"
|
|
24
27
|
import { HelpOverlay } from "./HelpOverlay.tsx"
|
|
25
28
|
import { readFileText } from "./io/readFile.ts"
|
|
26
29
|
import { browserBindings, type BrowserCtx } from "./keymap/browser.ts"
|
|
@@ -41,7 +44,6 @@ export type SidebarMode = "auto" | "on" | "off"
|
|
|
41
44
|
|
|
42
45
|
export interface BrowserProps {
|
|
43
46
|
readonly files: readonly FileEntry[]
|
|
44
|
-
readonly title?: string
|
|
45
47
|
readonly initialIndex?: number
|
|
46
48
|
/** Cap the rendered markdown's width at N columns. Null = fill the pane. */
|
|
47
49
|
readonly maxWidth?: number | null
|
|
@@ -54,6 +56,14 @@ export interface BrowserProps {
|
|
|
54
56
|
readonly onQuit?: () => void
|
|
55
57
|
/** Test seam: replaces the file reader. */
|
|
56
58
|
readonly readFile?: (path: string) => Promise<string>
|
|
59
|
+
/** Optional one-shot footer toast surfaced on first appearance (e.g. the
|
|
60
|
+
* "update available" nudge). Shown with an extended TTL so the user has
|
|
61
|
+
* time to read it; subsequent transient toasts (theme cycle, etc.)
|
|
62
|
+
* preempt it via the same single-slot channel. Null disables. */
|
|
63
|
+
readonly updateNotice?: string | null
|
|
64
|
+
/** TTL (ms) for the update-notice toast. Exposed so tests can use a small
|
|
65
|
+
* value instead of sleeping for the production 10s window. */
|
|
66
|
+
readonly updateNoticeTtlMs?: number
|
|
57
67
|
}
|
|
58
68
|
|
|
59
69
|
const defaultReadFile = (path: string): Promise<string> => Effect.runPromise(readFileText(path))
|
|
@@ -74,13 +84,14 @@ const HELP_ALLOWED_IDS: ReadonlySet<string> = new Set([
|
|
|
74
84
|
|
|
75
85
|
export const Browser = ({
|
|
76
86
|
files,
|
|
77
|
-
title = "house",
|
|
78
87
|
initialIndex = 0,
|
|
79
88
|
maxWidth = null,
|
|
80
89
|
discoveryStatus = null,
|
|
81
90
|
sidebarMode = "auto",
|
|
82
91
|
onQuit,
|
|
83
92
|
readFile = defaultReadFile,
|
|
93
|
+
updateNotice = null,
|
|
94
|
+
updateNoticeTtlMs = 10000,
|
|
84
95
|
}: BrowserProps) => {
|
|
85
96
|
const renderer = useRenderer()
|
|
86
97
|
const { width, height } = useTerminalDimensions()
|
|
@@ -131,10 +142,14 @@ export const Browser = ({
|
|
|
131
142
|
const filterOpenRef = useRef(false)
|
|
132
143
|
const filterQueryRef = useRef("")
|
|
133
144
|
// Snapshot the query at filter-open so Esc reverts edits but commit (Return)
|
|
134
|
-
// keeps them.
|
|
135
|
-
// dismissal under the §7.1 visibility rule.
|
|
145
|
+
// keeps them.
|
|
136
146
|
const priorFilterQueryRef = useRef("")
|
|
137
|
-
const [footerNotice,
|
|
147
|
+
const [footerNotice, setFooterNoticeState] = useState<{
|
|
148
|
+
readonly text: string
|
|
149
|
+
readonly ttlMs: number
|
|
150
|
+
} | null>(null)
|
|
151
|
+
const pushFooterNotice = (text: string, ttlMs = 2000): void =>
|
|
152
|
+
setFooterNoticeState({ text, ttlMs })
|
|
138
153
|
const serverRef = useRef<ServerHandle | null>(null)
|
|
139
154
|
|
|
140
155
|
// Stop the preview server on unmount so re-mounts (tests) and clean
|
|
@@ -146,21 +161,36 @@ export const Browser = ({
|
|
|
146
161
|
}
|
|
147
162
|
}, [])
|
|
148
163
|
|
|
149
|
-
// Single-slot notice with a
|
|
150
|
-
// timer so the latest message gets its own full window.
|
|
164
|
+
// Single-slot notice with a per-message TTL. A new notice cancels the
|
|
165
|
+
// pending timer so the latest message gets its own full window. The TTL
|
|
166
|
+
// travels with the message so a long-lived nudge (update available) and a
|
|
167
|
+
// transient toast (theme cycle) can share one slot without one stealing
|
|
168
|
+
// the other's display window.
|
|
151
169
|
useEffect(() => {
|
|
152
170
|
if (footerNotice === null) return
|
|
153
|
-
const timer = setTimeout(() =>
|
|
171
|
+
const timer = setTimeout(() => setFooterNoticeState(null), footerNotice.ttlMs)
|
|
154
172
|
return () => clearTimeout(timer)
|
|
155
173
|
}, [footerNotice])
|
|
156
174
|
|
|
175
|
+
// Push the update-available nudge once, when it arrives from the parent
|
|
176
|
+
// (the registry probe resolves asynchronously after boot). 10s gives the
|
|
177
|
+
// user time to read it before it auto-clears; the quit-time stderr print
|
|
178
|
+
// is the durable record they can copy from scrollback.
|
|
179
|
+
const updateNoticeSeenRef = useRef<string | null>(null)
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
if (!updateNotice) return
|
|
182
|
+
if (updateNoticeSeenRef.current === updateNotice) return
|
|
183
|
+
updateNoticeSeenRef.current = updateNotice
|
|
184
|
+
pushFooterNotice(updateNotice, updateNoticeTtlMs)
|
|
185
|
+
}, [updateNotice, updateNoticeTtlMs])
|
|
186
|
+
|
|
157
187
|
const cycleTheme = (delta: 1 | -1) => {
|
|
158
188
|
const idx = themeDefinitions.findIndex((d) => d.id === theme.id)
|
|
159
189
|
const next = themeDefinitions[(idx + delta + themeDefinitions.length) % themeDefinitions.length]
|
|
160
190
|
if (!next) return
|
|
161
191
|
setActiveTheme(next, theme.tone)
|
|
162
192
|
setTheme({ id: next.id, tone: theme.tone })
|
|
163
|
-
|
|
193
|
+
pushFooterNotice(`theme: ${next.name}`)
|
|
164
194
|
}
|
|
165
195
|
|
|
166
196
|
const toggleTone = () => {
|
|
@@ -168,7 +198,7 @@ export const Browser = ({
|
|
|
168
198
|
const def = getThemeDefinition(theme.id)
|
|
169
199
|
if (def) setActiveTheme(def, nextTone)
|
|
170
200
|
setTheme({ id: theme.id, tone: nextTone })
|
|
171
|
-
|
|
201
|
+
pushFooterNotice(`tone: ${nextTone}`)
|
|
172
202
|
}
|
|
173
203
|
|
|
174
204
|
const displayedFiles = useMemo(() => filterFiles(files, filterQuery), [files, filterQuery])
|
|
@@ -239,13 +269,21 @@ export const Browser = ({
|
|
|
239
269
|
setFocus,
|
|
240
270
|
setSelectedIndex,
|
|
241
271
|
toggleShown: () => {
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
272
|
+
// Two layout shapes, two behaviors:
|
|
273
|
+
// wide → flip the sticky `shown` preference. Per DESIGN.md §7.1
|
|
274
|
+
// s-behavior table: also nudge focus so the visibility
|
|
275
|
+
// rule (`visible = shown || focus==="sidebar"`) reflects
|
|
276
|
+
// the user's intent instead of forcing the sidebar back
|
|
277
|
+
// on via focus.
|
|
278
|
+
// narrow → swap which screen is up (focus is the source of truth
|
|
279
|
+
// for render). Sync `shown` to the new screen so a later
|
|
280
|
+
// resize to wide opens with the right pane visible.
|
|
281
|
+
if (!canFitInline(width)) {
|
|
282
|
+
const next = focus === "sidebar" ? "reader" : "sidebar"
|
|
283
|
+
setFocus(next)
|
|
284
|
+
setShown(next === "sidebar")
|
|
285
|
+
return
|
|
286
|
+
}
|
|
249
287
|
if (shown) {
|
|
250
288
|
setShown(false)
|
|
251
289
|
if (focus === "sidebar") setFocus("reader")
|
|
@@ -256,10 +294,11 @@ export const Browser = ({
|
|
|
256
294
|
},
|
|
257
295
|
setHelpVisible,
|
|
258
296
|
openFilter: () => {
|
|
259
|
-
// Focus the sidebar so the filter input has a home.
|
|
260
|
-
//
|
|
261
|
-
// the sidebar
|
|
262
|
-
//
|
|
297
|
+
// Focus the sidebar so the filter input has a home. In wide,
|
|
298
|
+
// §7.1's visibility rule (`shown || focus === "sidebar"`) brings
|
|
299
|
+
// the inline sidebar back on screen if it was hidden. In narrow,
|
|
300
|
+
// focusing the sidebar swaps to the sidebar screen. Either way
|
|
301
|
+
// no need to mutate `shown`.
|
|
263
302
|
priorFilterQueryRef.current = filterQueryRef.current
|
|
264
303
|
if (focus !== "sidebar") setFocus("sidebar")
|
|
265
304
|
filterOpenRef.current = true
|
|
@@ -288,9 +327,9 @@ export const Browser = ({
|
|
|
288
327
|
handle = startServer({ path: file.path })
|
|
289
328
|
serverRef.current = handle
|
|
290
329
|
openInBrowser(handle.url)
|
|
291
|
-
|
|
330
|
+
pushFooterNotice(`serving at ${handle.url}`)
|
|
292
331
|
} catch (err) {
|
|
293
|
-
|
|
332
|
+
pushFooterNotice(`serve failed: ${String(err)}`)
|
|
294
333
|
}
|
|
295
334
|
return
|
|
296
335
|
}
|
|
@@ -302,7 +341,7 @@ export const Browser = ({
|
|
|
302
341
|
// an existing tab on the same URL when one is open, so this is
|
|
303
342
|
// idempotent for the common case.
|
|
304
343
|
openInBrowser(handle.url)
|
|
305
|
-
|
|
344
|
+
pushFooterNotice(`serving ${file.relativePath} at ${handle.url}`)
|
|
306
345
|
},
|
|
307
346
|
quit: () => {
|
|
308
347
|
if (onQuit) {
|
|
@@ -361,18 +400,20 @@ export const Browser = ({
|
|
|
361
400
|
if (idx >= 0) setSelectedIndex(() => idx)
|
|
362
401
|
}
|
|
363
402
|
}
|
|
364
|
-
// Where focus lands
|
|
403
|
+
// Where focus lands after the filter closes:
|
|
365
404
|
// commit (Return on a real pick) → reader, always. The user
|
|
366
405
|
// asked to open the match; show them what they picked.
|
|
367
|
-
// cancel (Esc, or Return with no pick) →
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
// (shown=false),
|
|
371
|
-
//
|
|
406
|
+
// cancel (Esc, or Return with no pick) → restore the user's
|
|
407
|
+
// pre-filter intent. If the sidebar was up before the
|
|
408
|
+
// filter opened (shown=true), stay in it so j/k keeps
|
|
409
|
+
// walking. If the sidebar was hidden (shown=false), go
|
|
410
|
+
// back to the reader so the sidebar dismisses — in narrow
|
|
411
|
+
// that swaps screens; in wide that drops the focus-driven
|
|
412
|
+
// sidebar revival.
|
|
372
413
|
if (effectiveCommit) {
|
|
373
414
|
setFocus("reader")
|
|
374
415
|
} else {
|
|
375
|
-
setFocus(shown
|
|
416
|
+
setFocus(shown ? "sidebar" : "reader")
|
|
376
417
|
}
|
|
377
418
|
}
|
|
378
419
|
if (key.name === "escape") {
|
|
@@ -516,42 +557,33 @@ export const Browser = ({
|
|
|
516
557
|
const sidebarWidth = resolveSidebarWidth(width, defaultPreferredWidth(width))
|
|
517
558
|
const sidebarActive = focus === "sidebar"
|
|
518
559
|
const readerActive = focus === "reader"
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
//
|
|
525
|
-
const
|
|
526
|
-
const
|
|
527
|
-
const
|
|
528
|
-
//
|
|
529
|
-
//
|
|
530
|
-
|
|
531
|
-
// slice matches the wrapper that actually paints it.
|
|
532
|
-
const drawerTopOffset = 1
|
|
533
|
-
const sidebarTitle = sidebarActive ? " ▸ files " : " files "
|
|
534
|
-
const readerLabel = selected?.relativePath ?? title
|
|
535
|
-
const readerTitle = readerActive ? ` ▸ ${readerLabel} ` : ` ${readerLabel} `
|
|
560
|
+
// Wide vs narrow drives the entire layout shape.
|
|
561
|
+
// wide → inline two-pane (today). visible = shown || focus==="sidebar".
|
|
562
|
+
// narrow → single-pane stack: whichever pane has focus fills the area.
|
|
563
|
+
// `shown` is silently ignored for render in narrow; `focus`
|
|
564
|
+
// is the single source of truth.
|
|
565
|
+
// See DESIGN.md §7.1.
|
|
566
|
+
const isNarrow = !canFitInline(width)
|
|
567
|
+
const sidebarInline = isNarrow ? sidebarActive : shown || sidebarActive
|
|
568
|
+
const readerVisible = isNarrow ? readerActive : true
|
|
569
|
+
// Currently-selected file shown in the Header (which replaced the
|
|
570
|
+
// per-pane border title that used to carry this information).
|
|
571
|
+
const currentFile = selected?.relativePath ?? null
|
|
536
572
|
const content = loaded?.path === renderedPath ? loaded.content : ""
|
|
537
573
|
|
|
538
574
|
// Sidebar virtualization: render only the visible window. Without this,
|
|
539
575
|
// every keystroke re-renders all N file rows even though only the bg of
|
|
540
576
|
// two of them changed (old + new selected). On a 195-file vault that
|
|
541
577
|
// dominates the per-keystroke cost.
|
|
542
|
-
//
|
|
543
|
-
//
|
|
578
|
+
// Chrome budget: header + pane top border + pane bottom border + footer.
|
|
579
|
+
// The filter row eats one more cell when files are present *or* while
|
|
544
580
|
// discovery is in flight (allocates the row up front so it doesn't pop
|
|
545
581
|
// in when the first file arrives).
|
|
546
582
|
const discoveryActive = discoveryStatus !== null && discoveryStatus.length > 0
|
|
547
583
|
const filterRowVisible = files.length > 0 || discoveryActive
|
|
548
584
|
const sidebarBodyHeight = Math.max(
|
|
549
585
|
1,
|
|
550
|
-
height -
|
|
551
|
-
2 -
|
|
552
|
-
FOOTER_HEIGHT -
|
|
553
|
-
(filterRowVisible ? 1 : 0) -
|
|
554
|
-
(sidebarAsDrawer ? drawerTopOffset : 0),
|
|
586
|
+
height - FOOTER_HEIGHT - HEADER_HEIGHT - 2 - (filterRowVisible ? 1 : 0),
|
|
555
587
|
)
|
|
556
588
|
const maxScroll = Math.max(0, displayedFiles.length - sidebarBodyHeight)
|
|
557
589
|
const desiredScroll = (() => {
|
|
@@ -564,8 +596,12 @@ export const Browser = ({
|
|
|
564
596
|
if (desiredScroll !== sidebarScroll) setSidebarScroll(desiredScroll)
|
|
565
597
|
}, [desiredScroll, sidebarScroll])
|
|
566
598
|
const visibleFiles = displayedFiles.slice(desiredScroll, desiredScroll + sidebarBodyHeight)
|
|
567
|
-
// Available width for sidebar text rows:
|
|
568
|
-
|
|
599
|
+
// Available width for sidebar text rows. Wide-inline: sidebarWidth minus
|
|
600
|
+
// 1-cell left padding and 1-cell right divider border. Narrow-stack: the
|
|
601
|
+
// sidebar flex-grows to fill the viewport with no right divider, so the
|
|
602
|
+
// budget is the viewport minus the 1-cell left padding only.
|
|
603
|
+
const sidebarPaneWidth = isNarrow ? width : sidebarWidth
|
|
604
|
+
const sidebarTextWidth = Math.max(4, sidebarPaneWidth - (isNarrow ? 1 : 2))
|
|
569
605
|
// Right-anchored truncation: keep the filename visible, lose the prefix
|
|
570
606
|
// with a leading ellipsis when the path is too long.
|
|
571
607
|
const truncatePath = useCallback(
|
|
@@ -612,9 +648,8 @@ export const Browser = ({
|
|
|
612
648
|
[helpVisible],
|
|
613
649
|
)
|
|
614
650
|
|
|
615
|
-
// One sidebar
|
|
616
|
-
// the wrapper differs (
|
|
617
|
-
// identical so file rows / filter row don't drift between modes.
|
|
651
|
+
// One sidebar body for both wide-inline and narrow-stack rendering; only
|
|
652
|
+
// the wrapper differs (fixed-width sibling vs flex-grow full-pane).
|
|
618
653
|
const sidebarBody = (
|
|
619
654
|
<>
|
|
620
655
|
{filterRowVisible && (
|
|
@@ -655,107 +690,150 @@ export const Browser = ({
|
|
|
655
690
|
</>
|
|
656
691
|
)
|
|
657
692
|
|
|
693
|
+
// Pane borders draw a connected frame: each pane's top/bottom edges
|
|
694
|
+
// (the horizontal rules) and the sidebar's right edge (the vertical
|
|
695
|
+
// divider) are rendered by opentui in one pass, so the junctions
|
|
696
|
+
// never get painted over by sibling elements. customBorderChars on
|
|
697
|
+
// the sidebar turns its right-side corners from `┐ ┘` into `┬ ┴` so
|
|
698
|
+
// they connect cleanly with the reader's top/bottom rules.
|
|
699
|
+
const sidebarBorderSides: BorderSides[] = ["top", "bottom", "right"]
|
|
700
|
+
const readerBorderSides: BorderSides[] = ["top", "bottom"]
|
|
701
|
+
const SIDEBAR_BORDER_CHARS = {
|
|
702
|
+
topLeft: "┌",
|
|
703
|
+
topRight: "┬",
|
|
704
|
+
bottomLeft: "└",
|
|
705
|
+
bottomRight: "┴",
|
|
706
|
+
horizontal: "─",
|
|
707
|
+
vertical: "│",
|
|
708
|
+
topT: "┬",
|
|
709
|
+
bottomT: "┴",
|
|
710
|
+
leftT: "├",
|
|
711
|
+
rightT: "┤",
|
|
712
|
+
cross: "┼",
|
|
713
|
+
} as const
|
|
714
|
+
|
|
658
715
|
return (
|
|
659
|
-
<box style={{ width, height, flexDirection: "column", backgroundColor: colors.
|
|
716
|
+
<box style={{ width, height, flexDirection: "column", backgroundColor: colors.surface }}>
|
|
717
|
+
<Header width={width} currentFile={currentFile} />
|
|
660
718
|
<box
|
|
661
719
|
style={{
|
|
662
720
|
flexDirection: "row",
|
|
663
721
|
flexGrow: 1,
|
|
664
722
|
flexShrink: 1,
|
|
665
|
-
backgroundColor: colors.
|
|
723
|
+
backgroundColor: colors.surface,
|
|
666
724
|
}}
|
|
667
725
|
>
|
|
668
726
|
{sidebarInline && (
|
|
669
727
|
<box
|
|
670
|
-
title={sidebarTitle}
|
|
671
|
-
titleAlignment="left"
|
|
672
728
|
style={{
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
729
|
+
// Narrow mode runs single-pane: the sidebar fills the area
|
|
730
|
+
// and drops its right divider (no neighbour to abut).
|
|
731
|
+
border: isNarrow ? readerBorderSides : sidebarBorderSides,
|
|
732
|
+
borderColor: colors.textMuted,
|
|
733
|
+
...(isNarrow
|
|
734
|
+
? { flexGrow: 1, flexShrink: 1 }
|
|
735
|
+
: { width: sidebarWidth, flexShrink: 0 }),
|
|
677
736
|
flexDirection: "column",
|
|
737
|
+
// Dim by default. Borders/separators ride on this so they read
|
|
738
|
+
// as a single connected frame regardless of focus; only the
|
|
739
|
+
// active pane's inner body overrides to the raised tint below.
|
|
678
740
|
backgroundColor: colors.surface,
|
|
679
741
|
}}
|
|
742
|
+
{...(isNarrow ? {} : { customBorderChars: SIDEBAR_BORDER_CHARS })}
|
|
680
743
|
>
|
|
681
|
-
|
|
744
|
+
<box
|
|
745
|
+
style={{
|
|
746
|
+
flexGrow: 1,
|
|
747
|
+
flexShrink: 1,
|
|
748
|
+
flexDirection: "column",
|
|
749
|
+
paddingLeft: 1,
|
|
750
|
+
backgroundColor: sidebarActive ? colors.background : colors.surface,
|
|
751
|
+
}}
|
|
752
|
+
>
|
|
753
|
+
{sidebarBody}
|
|
754
|
+
</box>
|
|
682
755
|
</box>
|
|
683
756
|
)}
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
<text content={error} style={{ fg: colors.error }} />
|
|
698
|
-
) : (
|
|
699
|
-
<scrollbox
|
|
757
|
+
{readerVisible && (
|
|
758
|
+
<box
|
|
759
|
+
style={{
|
|
760
|
+
border: readerBorderSides,
|
|
761
|
+
borderColor: colors.textMuted,
|
|
762
|
+
flexGrow: 1,
|
|
763
|
+
flexShrink: 1,
|
|
764
|
+
flexDirection: "column",
|
|
765
|
+
// Dim by default (see sidebar note); inner body overrides when active.
|
|
766
|
+
backgroundColor: colors.surface,
|
|
767
|
+
}}
|
|
768
|
+
>
|
|
769
|
+
<box
|
|
700
770
|
style={{
|
|
701
|
-
scrollY: true,
|
|
702
|
-
scrollX: false,
|
|
703
771
|
flexGrow: 1,
|
|
704
772
|
flexShrink: 1,
|
|
705
|
-
|
|
773
|
+
flexDirection: "column",
|
|
774
|
+
padding: 1,
|
|
775
|
+
backgroundColor: readerActive ? colors.background : colors.surface,
|
|
706
776
|
}}
|
|
707
|
-
// opentui's scrollbox consumes arrow keys at the focused-element
|
|
708
|
-
// level *before* useKeyboard fires, so a modal that handles
|
|
709
|
-
// arrow keys itself (palette nav, help dismissal) would still
|
|
710
|
-
// see the reader scroll alongside its own action. Unfocus the
|
|
711
|
-
// scrollbox while any blocking modal is up — useKeyboard's
|
|
712
|
-
// modal branches own the keys in that state. Filter is not
|
|
713
|
-
// listed because it force-focuses the sidebar (readerActive
|
|
714
|
-
// is already false).
|
|
715
|
-
focused={readerActive && !paletteOpen && !helpVisible}
|
|
716
777
|
>
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
778
|
+
{error ? (
|
|
779
|
+
<text content={error} style={{ fg: colors.error }} />
|
|
780
|
+
) : !renderedPath ? (
|
|
781
|
+
// Reader empty state — no file selected. Brand mark centered as a
|
|
782
|
+
// welcome anchor; in-app tips (#47) will live here too.
|
|
783
|
+
<box
|
|
784
|
+
style={{
|
|
785
|
+
flexGrow: 1,
|
|
786
|
+
flexShrink: 1,
|
|
787
|
+
alignItems: "center",
|
|
788
|
+
justifyContent: "center",
|
|
789
|
+
backgroundColor: readerActive ? colors.background : colors.surface,
|
|
790
|
+
}}
|
|
791
|
+
>
|
|
792
|
+
<text
|
|
793
|
+
content={`${BRAND} ${BRAND_NAME}`}
|
|
794
|
+
wrapMode="none"
|
|
795
|
+
style={{ fg: colors.textMuted }}
|
|
796
|
+
/>
|
|
797
|
+
</box>
|
|
798
|
+
) : (
|
|
799
|
+
<scrollbox
|
|
800
|
+
style={{
|
|
801
|
+
scrollY: true,
|
|
802
|
+
scrollX: false,
|
|
803
|
+
flexGrow: 1,
|
|
804
|
+
flexShrink: 1,
|
|
805
|
+
backgroundColor: readerActive ? colors.background : colors.surface,
|
|
806
|
+
}}
|
|
807
|
+
// opentui's scrollbox consumes arrow keys at the focused-element
|
|
808
|
+
// level *before* useKeyboard fires, so a modal that handles
|
|
809
|
+
// arrow keys itself (palette nav, help dismissal) would still
|
|
810
|
+
// see the reader scroll alongside its own action. Unfocus the
|
|
811
|
+
// scrollbox while any blocking modal is up — useKeyboard's
|
|
812
|
+
// modal branches own the keys in that state. Filter is not
|
|
813
|
+
// listed because it force-focuses the sidebar (readerActive
|
|
814
|
+
// is already false).
|
|
815
|
+
focused={readerActive && !paletteOpen && !helpVisible}
|
|
816
|
+
>
|
|
817
|
+
<markdown
|
|
818
|
+
key={renderedPath ?? "empty"}
|
|
819
|
+
content={content}
|
|
820
|
+
syntaxStyle={syntaxStyle}
|
|
821
|
+
fg={colors.text}
|
|
822
|
+
bg={readerActive ? colors.background : colors.surface}
|
|
823
|
+
conceal
|
|
824
|
+
style={{ width: maxWidth ?? "100%" }}
|
|
825
|
+
/>
|
|
826
|
+
</scrollbox>
|
|
827
|
+
)}
|
|
828
|
+
</box>
|
|
829
|
+
</box>
|
|
830
|
+
)}
|
|
729
831
|
</box>
|
|
730
|
-
{sidebarAsDrawer && (
|
|
731
|
-
// Offset by 1 row so the reader pane's top border (which carries
|
|
732
|
-
// the current file name) stays visible above the drawer. Without
|
|
733
|
-
// this, the user loses the only on-screen indicator of which
|
|
734
|
-
// file they're reading whenever the drawer is up.
|
|
735
|
-
<box
|
|
736
|
-
position="absolute"
|
|
737
|
-
left={0}
|
|
738
|
-
top={drawerTopOffset}
|
|
739
|
-
width={sidebarWidth}
|
|
740
|
-
height={Math.max(1, height - FOOTER_HEIGHT - drawerTopOffset)}
|
|
741
|
-
zIndex={5}
|
|
742
|
-
title={sidebarTitle}
|
|
743
|
-
titleAlignment="left"
|
|
744
|
-
style={{
|
|
745
|
-
border: true,
|
|
746
|
-
borderColor: sidebarActive ? colors.borderActive : colors.border,
|
|
747
|
-
flexDirection: "column",
|
|
748
|
-
backgroundColor: colors.surface,
|
|
749
|
-
}}
|
|
750
|
-
>
|
|
751
|
-
{sidebarBody}
|
|
752
|
-
</box>
|
|
753
|
-
)}
|
|
754
832
|
<Footer
|
|
755
833
|
bindings={footerBindings}
|
|
756
834
|
ctx={ctx}
|
|
757
835
|
width={width}
|
|
758
|
-
notice={footerNotice}
|
|
836
|
+
notice={footerNotice?.text ?? null}
|
|
759
837
|
discoveryStatus={discoveryStatus}
|
|
760
838
|
filterQuery={!filterOpen && filterQuery.length > 0 ? filterQuery : null}
|
|
761
839
|
/>
|
package/src/CommandPalette.tsx
CHANGED
|
@@ -9,9 +9,16 @@
|
|
|
9
9
|
* no mouse, no recency. See #91/#93/#94/#95/#96 for the follow-ups.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { RGBA } from "@opentui/core"
|
|
12
13
|
import { colors } from "./theme/colors.ts"
|
|
13
14
|
import type { AppCommand } from "./commands/types.ts"
|
|
14
15
|
|
|
16
|
+
// Semi-transparent black scrim painted across the viewport behind the modal.
|
|
17
|
+
// Opentui composites it over the chrome underneath, so the rest of the UI
|
|
18
|
+
// reads as darkened while the modal stays fully opaque. Mirrors opencode's
|
|
19
|
+
// dialog backdrop (cli/cmd/tui/ui/dialog.tsx).
|
|
20
|
+
const SCRIM = RGBA.fromInts(0, 0, 0, 150)
|
|
21
|
+
|
|
15
22
|
export interface CommandPaletteProps {
|
|
16
23
|
readonly commands: readonly AppCommand[]
|
|
17
24
|
readonly query: string
|
|
@@ -73,54 +80,71 @@ export const CommandPalette = ({
|
|
|
73
80
|
return (
|
|
74
81
|
<box
|
|
75
82
|
position="absolute"
|
|
76
|
-
left={
|
|
77
|
-
top={
|
|
78
|
-
width={
|
|
79
|
-
height={
|
|
83
|
+
left={0}
|
|
84
|
+
top={0}
|
|
85
|
+
width={viewportWidth}
|
|
86
|
+
height={viewportHeight}
|
|
80
87
|
zIndex={20}
|
|
81
|
-
|
|
82
|
-
titleAlignment="left"
|
|
83
|
-
paddingLeft={1}
|
|
84
|
-
paddingRight={1}
|
|
85
|
-
style={{
|
|
86
|
-
border: true,
|
|
87
|
-
borderColor: colors.borderActive,
|
|
88
|
-
flexDirection: "column",
|
|
89
|
-
backgroundColor: colors.surface,
|
|
90
|
-
}}
|
|
88
|
+
style={{ backgroundColor: SCRIM }}
|
|
91
89
|
>
|
|
92
|
-
<
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
90
|
+
<box
|
|
91
|
+
position="absolute"
|
|
92
|
+
left={left}
|
|
93
|
+
top={top}
|
|
94
|
+
width={overlayWidth}
|
|
95
|
+
height={overlayHeight}
|
|
96
|
+
title=" Commands "
|
|
97
|
+
titleAlignment="left"
|
|
98
|
+
paddingLeft={1}
|
|
99
|
+
paddingRight={1}
|
|
100
|
+
style={{
|
|
101
|
+
border: true,
|
|
102
|
+
borderColor: colors.textMuted,
|
|
103
|
+
flexDirection: "column",
|
|
104
|
+
backgroundColor: colors.surface,
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
<text
|
|
108
|
+
wrapMode="none"
|
|
109
|
+
content={fit(`> ${query}▏`, rowWidth)}
|
|
110
|
+
style={{ fg: colors.textStrong }}
|
|
111
|
+
/>
|
|
112
|
+
<text content=" " />
|
|
113
|
+
{commands.length === 0 ? (
|
|
114
|
+
<text wrapMode="none" content=" (no matches)" style={{ fg: colors.textMuted }} />
|
|
115
|
+
) : (
|
|
116
|
+
visible.map((cmd, i) => {
|
|
117
|
+
const realIdx = scrollTop + i
|
|
118
|
+
const isSelected = realIdx === selectedIndex
|
|
119
|
+
const selector = isSelected ? "▸ " : " "
|
|
120
|
+
const titleText = fit(cmd.title, titleWidth)
|
|
121
|
+
const shortcutText = cmd.shortcut
|
|
122
|
+
? fitRight(cmd.shortcut, SHORTCUT_WIDTH)
|
|
123
|
+
: " ".repeat(SHORTCUT_WIDTH)
|
|
124
|
+
// Title and shortcut render as separate spans so the shortcut
|
|
125
|
+
// can use `textMuted` while the title uses `text`/`textStrong`.
|
|
126
|
+
// Same trick opencode pulls with `--text-weak` — the theme
|
|
127
|
+
// guarantees the contrast, we just pick the right role.
|
|
128
|
+
const titleFg = isSelected ? colors.textStrong : colors.text
|
|
129
|
+
return (
|
|
130
|
+
<text
|
|
131
|
+
key={cmd.id}
|
|
132
|
+
wrapMode="none"
|
|
133
|
+
style={isSelected ? { bg: colors.selectedBg } : {}}
|
|
134
|
+
>
|
|
135
|
+
<span style={{ fg: titleFg }}>{`${selector}${titleText} `}</span>
|
|
136
|
+
<span style={{ fg: colors.textMuted }}>{shortcutText}</span>
|
|
137
|
+
</text>
|
|
138
|
+
)
|
|
139
|
+
})
|
|
140
|
+
)}
|
|
141
|
+
<text content=" " />
|
|
142
|
+
<text
|
|
143
|
+
wrapMode="none"
|
|
144
|
+
content={fit(FOOTER_HINT, rowWidth)}
|
|
145
|
+
style={{ fg: colors.textMuted }}
|
|
146
|
+
/>
|
|
147
|
+
</box>
|
|
124
148
|
</box>
|
|
125
149
|
)
|
|
126
150
|
}
|