@carlesandres/house 0.4.0 → 0.4.2
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 +75 -137
- package/README.md +37 -20
- package/package.json +14 -12
- package/src/Browser.tsx +331 -226
- package/src/CommandPalette.tsx +67 -46
- package/src/Footer.tsx +71 -27
- package/src/Header.tsx +68 -0
- package/src/HelpOverlay.tsx +59 -41
- package/src/PromptRow.tsx +49 -0
- package/src/brand.ts +7 -0
- package/src/cli/argv.ts +34 -3
- package/src/commands/buildCommands.ts +1 -0
- package/src/config/load.ts +84 -19
- package/src/discovery/walk.ts +8 -3
- package/src/index.tsx +64 -11
- package/src/io/editor.ts +162 -0
- package/src/keymap/browser.ts +79 -22
- package/src/keymap/keymap.ts +6 -0
- package/src/layout/resolve.ts +9 -9
- package/src/layout/sidebarRow.ts +85 -0
- package/src/serve/server.ts +4 -1
- 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,8 +21,11 @@ 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"
|
|
28
|
+
import { openInEditor, resolveEditor } from "./io/editor.ts"
|
|
25
29
|
import { readFileText } from "./io/readFile.ts"
|
|
26
30
|
import { browserBindings, type BrowserCtx } from "./keymap/browser.ts"
|
|
27
31
|
import { dispatch } from "./keymap/keymap.ts"
|
|
@@ -31,6 +35,8 @@ import {
|
|
|
31
35
|
initialShownForAuto,
|
|
32
36
|
resolveSidebarWidth,
|
|
33
37
|
} from "./layout/resolve.ts"
|
|
38
|
+
import { formatSidebarRow } from "./layout/sidebarRow.ts"
|
|
39
|
+
import { PromptRow } from "./PromptRow.tsx"
|
|
34
40
|
import { openInBrowser } from "./serve/openBrowser.ts"
|
|
35
41
|
import { startServer, type ServerHandle } from "./serve/server.ts"
|
|
36
42
|
import { colors, setActiveTheme } from "./theme/colors.ts"
|
|
@@ -41,7 +47,6 @@ export type SidebarMode = "auto" | "on" | "off"
|
|
|
41
47
|
|
|
42
48
|
export interface BrowserProps {
|
|
43
49
|
readonly files: readonly FileEntry[]
|
|
44
|
-
readonly title?: string
|
|
45
50
|
readonly initialIndex?: number
|
|
46
51
|
/** Cap the rendered markdown's width at N columns. Null = fill the pane. */
|
|
47
52
|
readonly maxWidth?: number | null
|
|
@@ -54,6 +59,14 @@ export interface BrowserProps {
|
|
|
54
59
|
readonly onQuit?: () => void
|
|
55
60
|
/** Test seam: replaces the file reader. */
|
|
56
61
|
readonly readFile?: (path: string) => Promise<string>
|
|
62
|
+
/** Optional one-shot footer toast surfaced on first appearance (e.g. the
|
|
63
|
+
* "update available" nudge). Shown with an extended TTL so the user has
|
|
64
|
+
* time to read it; subsequent transient toasts (theme cycle, etc.)
|
|
65
|
+
* preempt it via the same single-slot channel. Null disables. */
|
|
66
|
+
readonly updateNotice?: string | null
|
|
67
|
+
/** TTL (ms) for the update-notice toast. Exposed so tests can use a small
|
|
68
|
+
* value instead of sleeping for the production 10s window. */
|
|
69
|
+
readonly updateNoticeTtlMs?: number
|
|
57
70
|
}
|
|
58
71
|
|
|
59
72
|
const defaultReadFile = (path: string): Promise<string> => Effect.runPromise(readFileText(path))
|
|
@@ -74,13 +87,14 @@ const HELP_ALLOWED_IDS: ReadonlySet<string> = new Set([
|
|
|
74
87
|
|
|
75
88
|
export const Browser = ({
|
|
76
89
|
files,
|
|
77
|
-
title = "house",
|
|
78
90
|
initialIndex = 0,
|
|
79
91
|
maxWidth = null,
|
|
80
92
|
discoveryStatus = null,
|
|
81
93
|
sidebarMode = "auto",
|
|
82
94
|
onQuit,
|
|
83
95
|
readFile = defaultReadFile,
|
|
96
|
+
updateNotice = null,
|
|
97
|
+
updateNoticeTtlMs = 10000,
|
|
84
98
|
}: BrowserProps) => {
|
|
85
99
|
const renderer = useRenderer()
|
|
86
100
|
const { width, height } = useTerminalDimensions()
|
|
@@ -130,11 +144,12 @@ export const Browser = ({
|
|
|
130
144
|
// otherwise still observe filterOpen=false through closure).
|
|
131
145
|
const filterOpenRef = useRef(false)
|
|
132
146
|
const filterQueryRef = useRef("")
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const
|
|
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])
|
|
@@ -231,21 +261,31 @@ export const Browser = ({
|
|
|
231
261
|
// operate on what the user actually sees.
|
|
232
262
|
const ctx: BrowserCtx = {
|
|
233
263
|
files: displayedFiles,
|
|
264
|
+
hasSelected: selected != null,
|
|
234
265
|
focus,
|
|
235
266
|
sidebarShown: shown,
|
|
236
267
|
helpVisible,
|
|
237
268
|
filterOpen,
|
|
269
|
+
filterQuery,
|
|
238
270
|
paletteOpen,
|
|
239
271
|
setFocus,
|
|
240
272
|
setSelectedIndex,
|
|
241
273
|
toggleShown: () => {
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
274
|
+
// Two layout shapes, two behaviors:
|
|
275
|
+
// wide → flip the sticky `shown` preference. Per DESIGN.md §7.1
|
|
276
|
+
// s-behavior table: also nudge focus so the visibility
|
|
277
|
+
// rule (`visible = shown || focus==="sidebar"`) reflects
|
|
278
|
+
// the user's intent instead of forcing the sidebar back
|
|
279
|
+
// on via focus.
|
|
280
|
+
// narrow → swap which screen is up (focus is the source of truth
|
|
281
|
+
// for render). Sync `shown` to the new screen so a later
|
|
282
|
+
// resize to wide opens with the right pane visible.
|
|
283
|
+
if (!canFitInline(width)) {
|
|
284
|
+
const next = focus === "sidebar" ? "reader" : "sidebar"
|
|
285
|
+
setFocus(next)
|
|
286
|
+
setShown(next === "sidebar")
|
|
287
|
+
return
|
|
288
|
+
}
|
|
249
289
|
if (shown) {
|
|
250
290
|
setShown(false)
|
|
251
291
|
if (focus === "sidebar") setFocus("reader")
|
|
@@ -256,11 +296,22 @@ export const Browser = ({
|
|
|
256
296
|
},
|
|
257
297
|
setHelpVisible,
|
|
258
298
|
openFilter: () => {
|
|
259
|
-
// Focus the sidebar so the filter input has a home.
|
|
260
|
-
//
|
|
261
|
-
// the sidebar
|
|
262
|
-
//
|
|
263
|
-
|
|
299
|
+
// Focus the sidebar so the filter input has a home. In wide,
|
|
300
|
+
// §7.1's visibility rule (`shown || focus === "sidebar"`) brings
|
|
301
|
+
// the inline sidebar back on screen if it was hidden. In narrow,
|
|
302
|
+
// focusing the sidebar swaps to the sidebar screen. Either way
|
|
303
|
+
// no need to mutate `shown`.
|
|
304
|
+
if (focus !== "sidebar") setFocus("sidebar")
|
|
305
|
+
filterOpenRef.current = true
|
|
306
|
+
setFilterOpen(true)
|
|
307
|
+
},
|
|
308
|
+
clearAndOpenFilter: () => {
|
|
309
|
+
// Reset both the ref and the state so the freshly-opened modal
|
|
310
|
+
// shows an empty input and selection lands on the first file in
|
|
311
|
+
// the (now unfiltered) list.
|
|
312
|
+
filterQueryRef.current = ""
|
|
313
|
+
setFilterQuery("")
|
|
314
|
+
setSelectedIndex(() => 0)
|
|
264
315
|
if (focus !== "sidebar") setFocus("sidebar")
|
|
265
316
|
filterOpenRef.current = true
|
|
266
317
|
setFilterOpen(true)
|
|
@@ -288,9 +339,9 @@ export const Browser = ({
|
|
|
288
339
|
handle = startServer({ path: file.path })
|
|
289
340
|
serverRef.current = handle
|
|
290
341
|
openInBrowser(handle.url)
|
|
291
|
-
|
|
342
|
+
pushFooterNotice(`serving at ${handle.url}`)
|
|
292
343
|
} catch (err) {
|
|
293
|
-
|
|
344
|
+
pushFooterNotice(`serve failed: ${String(err)}`)
|
|
294
345
|
}
|
|
295
346
|
return
|
|
296
347
|
}
|
|
@@ -302,7 +353,7 @@ export const Browser = ({
|
|
|
302
353
|
// an existing tab on the same URL when one is open, so this is
|
|
303
354
|
// idempotent for the common case.
|
|
304
355
|
openInBrowser(handle.url)
|
|
305
|
-
|
|
356
|
+
pushFooterNotice(`serving ${file.relativePath} at ${handle.url}`)
|
|
306
357
|
},
|
|
307
358
|
quit: () => {
|
|
308
359
|
if (onQuit) {
|
|
@@ -312,67 +363,100 @@ export const Browser = ({
|
|
|
312
363
|
renderer?.destroy()
|
|
313
364
|
process.exit(0)
|
|
314
365
|
},
|
|
366
|
+
editCurrent: () => {
|
|
367
|
+
const file = displayedFiles[selectedIndex]
|
|
368
|
+
if (!file) return
|
|
369
|
+
const editor = resolveEditor(process.env)
|
|
370
|
+
if (!editor) {
|
|
371
|
+
pushFooterNotice("set $EDITOR or $VISUAL to use e")
|
|
372
|
+
return
|
|
373
|
+
}
|
|
374
|
+
if (!renderer) {
|
|
375
|
+
// Test environments without a real renderer (e.g. testRender's
|
|
376
|
+
// host) don't expose suspend/resume. Nothing safe to do here.
|
|
377
|
+
pushFooterNotice("editor unavailable in this environment")
|
|
378
|
+
return
|
|
379
|
+
}
|
|
380
|
+
// Fire-and-forget: useKeyboard's run() is synchronous, but the
|
|
381
|
+
// editor session is naturally async. We always re-enter the
|
|
382
|
+
// renderer in the finally block so a thrown error never leaves
|
|
383
|
+
// the user staring at a dead terminal.
|
|
384
|
+
void (async () => {
|
|
385
|
+
renderer.suspend()
|
|
386
|
+
renderer.currentRenderBuffer.clear()
|
|
387
|
+
let result
|
|
388
|
+
try {
|
|
389
|
+
result = await openInEditor({ editor, filePath: file.path })
|
|
390
|
+
} finally {
|
|
391
|
+
renderer.currentRenderBuffer.clear()
|
|
392
|
+
renderer.resume()
|
|
393
|
+
renderer.requestRender()
|
|
394
|
+
}
|
|
395
|
+
// Only reload the in-memory cache when the edited file is the
|
|
396
|
+
// one currently displayed. Editing a sidebar-selected file that
|
|
397
|
+
// the reader hasn't caught up to (debounce in flight) is fine —
|
|
398
|
+
// the regular load path picks up the new mtime when renderedPath
|
|
399
|
+
// advances.
|
|
400
|
+
if (file.path === renderedPath) {
|
|
401
|
+
try {
|
|
402
|
+
const text = await readFile(file.path)
|
|
403
|
+
setLoaded({ path: file.path, content: text })
|
|
404
|
+
setError(null)
|
|
405
|
+
} catch (err) {
|
|
406
|
+
const message = String(err)
|
|
407
|
+
const enoent =
|
|
408
|
+
(err as { code?: string } | null)?.code === "ENOENT" || message.includes("ENOENT")
|
|
409
|
+
if (enoent) {
|
|
410
|
+
pushFooterNotice(`${file.relativePath} no longer exists`)
|
|
411
|
+
setError(`Cannot read ${file.path}: ${message}`)
|
|
412
|
+
setLoaded(null)
|
|
413
|
+
} else {
|
|
414
|
+
pushFooterNotice(`reload failed: ${message}`)
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
if (!result.ok) {
|
|
419
|
+
if (result.reason === "spawn-failed") {
|
|
420
|
+
pushFooterNotice(`editor not found: ${editor.cmd}`)
|
|
421
|
+
} else if (result.reason === "non-zero") {
|
|
422
|
+
pushFooterNotice(`editor exited ${result.detail}`)
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
})()
|
|
426
|
+
},
|
|
315
427
|
}
|
|
316
428
|
|
|
317
429
|
useKeyboard((key) => {
|
|
318
|
-
// Filter modal: capture keystrokes for the input. Esc closes
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
430
|
+
// Filter modal: capture keystrokes for the input. Esc closes,
|
|
431
|
+
// leaving the typed query applied as the active filter; Return
|
|
432
|
+
// closes and focuses the reader (open the match); Ctrl+\ clears
|
|
433
|
+
// the input but stays in filter mode (same binding used from
|
|
434
|
+
// outside the modal — single chord, single mental model. Ctrl+U
|
|
435
|
+
// is deliberately not overloaded here; it stays reserved for its
|
|
436
|
+
// sidebar/reader half-page-up role); Backspace edits; Up/Down
|
|
437
|
+
// navigate the filtered list; printable characters extend the
|
|
438
|
+
// query and reset selection to 0. Everything else is swallowed
|
|
439
|
+
// so normal bindings (j/k as nav, `s`, `t`, …) don't fire while
|
|
440
|
+
// the user is typing. This sits outside the data-driven keymap
|
|
441
|
+
// for the same reason the help branch does — see DESIGN.md §12.
|
|
326
442
|
if (filterOpenRef.current) {
|
|
327
|
-
// One close path used by both Esc and Return.
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
// looking at when they hit the key, instead of landing on a
|
|
331
|
-
// random file at the same numeric position in a now-different
|
|
332
|
-
// list. `focusReader=true` is the Return semantic (open the
|
|
333
|
-
// match); false is Esc (cancel, stay in sidebar).
|
|
334
|
-
//
|
|
335
|
-
// Centralized so the dual filterOpenRef / filterOpen invariant
|
|
336
|
-
// only has to be maintained in one place (plus `openFilter`).
|
|
443
|
+
// One close path used by both Esc and Return. `commit=true` is
|
|
444
|
+
// the Return semantic (open the match in the reader); false is
|
|
445
|
+
// Esc (stop typing, keep the applied filter, stay in sidebar).
|
|
337
446
|
const closeFilter = (commit: boolean) => {
|
|
338
447
|
const picked = displayedFiles[selectedIndex] ?? null
|
|
339
|
-
// Return on a zero-match list has nothing to commit. Treat it
|
|
340
|
-
// as Esc so the user isn't stranded in an "applied filter with
|
|
341
|
-
// no visible files" state they'd have to back out of manually.
|
|
342
448
|
const effectiveCommit = commit && picked !== null
|
|
343
449
|
filterOpenRef.current = false
|
|
344
450
|
setFilterOpen(false)
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
// Esc reverts the query to its pre-session value. After the
|
|
351
|
-
// revert, displayedFiles may change shape — translate the
|
|
352
|
-
// cursor by path so it stays on whatever the user was
|
|
353
|
-
// looking at, instead of snapping to a numerically-equivalent
|
|
354
|
-
// row in the restored list.
|
|
355
|
-
const before = priorFilterQueryRef.current
|
|
356
|
-
filterQueryRef.current = before
|
|
357
|
-
setFilterQuery(before)
|
|
358
|
-
if (picked) {
|
|
359
|
-
const restored = before === "" ? files : filterFiles(files, before)
|
|
360
|
-
const idx = restored.findIndex((f) => f.path === picked.path)
|
|
361
|
-
if (idx >= 0) setSelectedIndex(() => idx)
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
// Where focus lands depends on layout intent (DESIGN.md §7.1):
|
|
365
|
-
// commit (Return on a real pick) → reader, always. The user
|
|
366
|
-
// asked to open the match; show them what they picked.
|
|
367
|
-
// cancel (Esc, or Return with no pick) → if the sidebar is
|
|
368
|
-
// inline (shown && fits), keep focus there so j/k keeps
|
|
369
|
-
// walking the list; if the sidebar was only up as a drawer
|
|
370
|
-
// (shown=false), dismiss focus to the reader so the drawer
|
|
371
|
-
// disappears under the §7.1 visibility rule.
|
|
451
|
+
// Where focus lands after the filter closes:
|
|
452
|
+
// commit (Return on a real pick) → reader. The user asked
|
|
453
|
+
// to open the match; show them what they picked.
|
|
454
|
+
// otherwise → sidebar if it's up so j/k keeps walking the
|
|
455
|
+
// filtered list; reader if the sidebar was hidden.
|
|
372
456
|
if (effectiveCommit) {
|
|
373
457
|
setFocus("reader")
|
|
374
458
|
} else {
|
|
375
|
-
setFocus(shown
|
|
459
|
+
setFocus(shown ? "sidebar" : "reader")
|
|
376
460
|
}
|
|
377
461
|
}
|
|
378
462
|
if (key.name === "escape") {
|
|
@@ -383,11 +467,19 @@ export const Browser = ({
|
|
|
383
467
|
closeFilter(true)
|
|
384
468
|
return
|
|
385
469
|
}
|
|
470
|
+
if (key.ctrl && key.name === "\\") {
|
|
471
|
+
// Same action as the `filter.clearOrOpen` binding fires from
|
|
472
|
+
// outside the modal: clear the query, reset selection. The
|
|
473
|
+
// keymap doesn't see keys in filter mode, so this branch is
|
|
474
|
+
// the in-modal half of that single chord.
|
|
475
|
+
filterQueryRef.current = ""
|
|
476
|
+
setFilterQuery("")
|
|
477
|
+
setSelectedIndex(() => 0)
|
|
478
|
+
return
|
|
479
|
+
}
|
|
386
480
|
if (key.name === "backspace" || key.name === "delete") {
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
// reverts to the pre-session query (so an applied filter
|
|
390
|
-
// survives a "I changed my mind" tap).
|
|
481
|
+
// Backspace on empty input closes the modal — the leading `/`
|
|
482
|
+
// chevron is the last thing left to "delete."
|
|
391
483
|
if (filterQueryRef.current.length === 0) {
|
|
392
484
|
closeFilter(false)
|
|
393
485
|
return
|
|
@@ -516,42 +608,33 @@ export const Browser = ({
|
|
|
516
608
|
const sidebarWidth = resolveSidebarWidth(width, defaultPreferredWidth(width))
|
|
517
609
|
const sidebarActive = focus === "sidebar"
|
|
518
610
|
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} `
|
|
611
|
+
// Wide vs narrow drives the entire layout shape.
|
|
612
|
+
// wide → inline two-pane (today). visible = shown || focus==="sidebar".
|
|
613
|
+
// narrow → single-pane stack: whichever pane has focus fills the area.
|
|
614
|
+
// `shown` is silently ignored for render in narrow; `focus`
|
|
615
|
+
// is the single source of truth.
|
|
616
|
+
// See DESIGN.md §7.1.
|
|
617
|
+
const isNarrow = !canFitInline(width)
|
|
618
|
+
const sidebarInline = isNarrow ? sidebarActive : shown || sidebarActive
|
|
619
|
+
const readerVisible = isNarrow ? readerActive : true
|
|
620
|
+
// Currently-selected file shown in the Header (which replaced the
|
|
621
|
+
// per-pane border title that used to carry this information).
|
|
622
|
+
const currentFile = selected?.relativePath ?? null
|
|
536
623
|
const content = loaded?.path === renderedPath ? loaded.content : ""
|
|
537
624
|
|
|
538
625
|
// Sidebar virtualization: render only the visible window. Without this,
|
|
539
626
|
// every keystroke re-renders all N file rows even though only the bg of
|
|
540
627
|
// two of them changed (old + new selected). On a 195-file vault that
|
|
541
628
|
// dominates the per-keystroke cost.
|
|
542
|
-
//
|
|
543
|
-
//
|
|
629
|
+
// Chrome budget: header + pane top border + pane bottom border + footer.
|
|
630
|
+
// The filter row eats one more cell when files are present *or* while
|
|
544
631
|
// discovery is in flight (allocates the row up front so it doesn't pop
|
|
545
632
|
// in when the first file arrives).
|
|
546
633
|
const discoveryActive = discoveryStatus !== null && discoveryStatus.length > 0
|
|
547
634
|
const filterRowVisible = files.length > 0 || discoveryActive
|
|
548
635
|
const sidebarBodyHeight = Math.max(
|
|
549
636
|
1,
|
|
550
|
-
height -
|
|
551
|
-
2 -
|
|
552
|
-
FOOTER_HEIGHT -
|
|
553
|
-
(filterRowVisible ? 1 : 0) -
|
|
554
|
-
(sidebarAsDrawer ? drawerTopOffset : 0),
|
|
637
|
+
height - FOOTER_HEIGHT - HEADER_HEIGHT - 2 - (filterRowVisible ? 1 : 0),
|
|
555
638
|
)
|
|
556
639
|
const maxScroll = Math.max(0, displayedFiles.length - sidebarBodyHeight)
|
|
557
640
|
const desiredScroll = (() => {
|
|
@@ -564,40 +647,17 @@ export const Browser = ({
|
|
|
564
647
|
if (desiredScroll !== sidebarScroll) setSidebarScroll(desiredScroll)
|
|
565
648
|
}, [desiredScroll, sidebarScroll])
|
|
566
649
|
const visibleFiles = displayedFiles.slice(desiredScroll, desiredScroll + sidebarBodyHeight)
|
|
567
|
-
// Available width for sidebar text rows:
|
|
568
|
-
|
|
569
|
-
//
|
|
570
|
-
//
|
|
571
|
-
const
|
|
572
|
-
|
|
573
|
-
|
|
650
|
+
// Available width for sidebar text rows. Wide-inline: sidebarWidth minus
|
|
651
|
+
// 1-cell left padding and 1-cell right divider border. Narrow-stack: the
|
|
652
|
+
// sidebar flex-grows to fill the viewport with no right divider, so the
|
|
653
|
+
// budget is the viewport minus the 1-cell left padding only.
|
|
654
|
+
const sidebarPaneWidth = isNarrow ? width : sidebarWidth
|
|
655
|
+
const sidebarTextWidth = Math.max(4, sidebarPaneWidth - (isNarrow ? 1 : 2))
|
|
656
|
+
const layoutSidebarRow = useCallback(
|
|
657
|
+
(relativePath: string) => formatSidebarRow(relativePath, sidebarTextWidth),
|
|
574
658
|
[sidebarTextWidth],
|
|
575
659
|
)
|
|
576
660
|
|
|
577
|
-
// Filter row content + color. Three reachable states:
|
|
578
|
-
// editing — filterOpen=true → /<query>▏ in textStrong
|
|
579
|
-
// applied — !filterOpen && query !== "" → /<query> in text
|
|
580
|
-
// idle — !filterOpen && query === "" → "/ filter…" in textMuted
|
|
581
|
-
const filterRowFg = filterOpen
|
|
582
|
-
? colors.textStrong
|
|
583
|
-
: filterQuery.length > 0
|
|
584
|
-
? colors.text
|
|
585
|
-
: colors.textMuted
|
|
586
|
-
const filterRowRaw = filterOpen
|
|
587
|
-
? `/${filterQuery}▏`
|
|
588
|
-
: filterQuery.length > 0
|
|
589
|
-
? `/${filterQuery}`
|
|
590
|
-
: "/ filter…"
|
|
591
|
-
// Editing keeps the cursor visible — anchor the right edge with a leading
|
|
592
|
-
// ellipsis when the query overflows. Applied/idle anchor the left edge
|
|
593
|
-
// (lose the tail) so the leading `/` always reads as a filter marker.
|
|
594
|
-
const filterRowContent =
|
|
595
|
-
filterRowRaw.length <= sidebarTextWidth
|
|
596
|
-
? filterRowRaw
|
|
597
|
-
: filterOpen
|
|
598
|
-
? "…" + filterRowRaw.slice(filterRowRaw.length - sidebarTextWidth + 1)
|
|
599
|
-
: filterRowRaw.slice(0, sidebarTextWidth - 1) + "…"
|
|
600
|
-
|
|
601
661
|
// While help is open, the `?` key closes the overlay — relabel its hint
|
|
602
662
|
// so the footer accurately describes what pressing the key will do.
|
|
603
663
|
// Memoized: `helpVisible` changes rarely; `browserBindings` and
|
|
@@ -612,13 +672,17 @@ export const Browser = ({
|
|
|
612
672
|
[helpVisible],
|
|
613
673
|
)
|
|
614
674
|
|
|
615
|
-
// One sidebar
|
|
616
|
-
// the wrapper differs (
|
|
617
|
-
// identical so file rows / filter row don't drift between modes.
|
|
675
|
+
// One sidebar body for both wide-inline and narrow-stack rendering; only
|
|
676
|
+
// the wrapper differs (fixed-width sibling vs flex-grow full-pane).
|
|
618
677
|
const sidebarBody = (
|
|
619
678
|
<>
|
|
620
679
|
{filterRowVisible && (
|
|
621
|
-
<
|
|
680
|
+
<PromptRow
|
|
681
|
+
query={filterQuery}
|
|
682
|
+
editing={filterOpen}
|
|
683
|
+
placeholder="/ to filter…"
|
|
684
|
+
width={sidebarTextWidth}
|
|
685
|
+
/>
|
|
622
686
|
)}
|
|
623
687
|
{displayedFiles.length === 0 ? (
|
|
624
688
|
<text
|
|
@@ -635,127 +699,168 @@ export const Browser = ({
|
|
|
635
699
|
visibleFiles.map((file, idx) => {
|
|
636
700
|
const realIdx = desiredScroll + idx
|
|
637
701
|
const isSelected = realIdx === selectedIndex
|
|
638
|
-
const
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
}
|
|
644
|
-
const bg = sidebarActive ? colors.selectedBg : colors.selectedBgInactive
|
|
702
|
+
const { basename, separator, parent } = layoutSidebarRow(file.relativePath)
|
|
703
|
+
const basenameFg = isSelected ? colors.textStrong : colors.text
|
|
704
|
+
const rowStyle = isSelected
|
|
705
|
+
? { bg: sidebarActive ? colors.selectedBg : colors.selectedBgInactive }
|
|
706
|
+
: {}
|
|
645
707
|
return (
|
|
646
|
-
<text
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
708
|
+
<text key={file.path} wrapMode="none" style={rowStyle}>
|
|
709
|
+
<span style={{ fg: basenameFg }}>{basename}</span>
|
|
710
|
+
{parent !== "" && (
|
|
711
|
+
<span style={{ fg: colors.textMuted }}>{`${separator}${parent}`}</span>
|
|
712
|
+
)}
|
|
713
|
+
</text>
|
|
652
714
|
)
|
|
653
715
|
})
|
|
654
716
|
)}
|
|
655
717
|
</>
|
|
656
718
|
)
|
|
657
719
|
|
|
720
|
+
// Pane borders draw a connected frame: each pane's top/bottom edges
|
|
721
|
+
// (the horizontal rules) and the sidebar's right edge (the vertical
|
|
722
|
+
// divider) are rendered by opentui in one pass, so the junctions
|
|
723
|
+
// never get painted over by sibling elements. customBorderChars on
|
|
724
|
+
// the sidebar turns its right-side corners from `┐ ┘` into `┬ ┴` so
|
|
725
|
+
// they connect cleanly with the reader's top/bottom rules.
|
|
726
|
+
const sidebarBorderSides: BorderSides[] = ["top", "bottom", "right"]
|
|
727
|
+
const readerBorderSides: BorderSides[] = ["top", "bottom"]
|
|
728
|
+
const SIDEBAR_BORDER_CHARS = {
|
|
729
|
+
topLeft: "┌",
|
|
730
|
+
topRight: "┬",
|
|
731
|
+
bottomLeft: "└",
|
|
732
|
+
bottomRight: "┴",
|
|
733
|
+
horizontal: "─",
|
|
734
|
+
vertical: "│",
|
|
735
|
+
topT: "┬",
|
|
736
|
+
bottomT: "┴",
|
|
737
|
+
leftT: "├",
|
|
738
|
+
rightT: "┤",
|
|
739
|
+
cross: "┼",
|
|
740
|
+
} as const
|
|
741
|
+
|
|
658
742
|
return (
|
|
659
|
-
<box style={{ width, height, flexDirection: "column", backgroundColor: colors.
|
|
743
|
+
<box style={{ width, height, flexDirection: "column", backgroundColor: colors.surface }}>
|
|
744
|
+
<Header width={width} currentFile={currentFile} />
|
|
660
745
|
<box
|
|
661
746
|
style={{
|
|
662
747
|
flexDirection: "row",
|
|
663
748
|
flexGrow: 1,
|
|
664
749
|
flexShrink: 1,
|
|
665
|
-
backgroundColor: colors.
|
|
750
|
+
backgroundColor: colors.surface,
|
|
666
751
|
}}
|
|
667
752
|
>
|
|
668
753
|
{sidebarInline && (
|
|
669
754
|
<box
|
|
670
|
-
title={sidebarTitle}
|
|
671
|
-
titleAlignment="left"
|
|
672
755
|
style={{
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
756
|
+
// Narrow mode runs single-pane: the sidebar fills the area
|
|
757
|
+
// and drops its right divider (no neighbour to abut).
|
|
758
|
+
border: isNarrow ? readerBorderSides : sidebarBorderSides,
|
|
759
|
+
borderColor: colors.textMuted,
|
|
760
|
+
...(isNarrow
|
|
761
|
+
? { flexGrow: 1, flexShrink: 1 }
|
|
762
|
+
: { width: sidebarWidth, flexShrink: 0 }),
|
|
677
763
|
flexDirection: "column",
|
|
764
|
+
// Dim by default. Borders/separators ride on this so they read
|
|
765
|
+
// as a single connected frame regardless of focus; only the
|
|
766
|
+
// active pane's inner body overrides to the raised tint below.
|
|
678
767
|
backgroundColor: colors.surface,
|
|
679
768
|
}}
|
|
769
|
+
{...(isNarrow ? {} : { customBorderChars: SIDEBAR_BORDER_CHARS })}
|
|
680
770
|
>
|
|
681
|
-
|
|
771
|
+
<box
|
|
772
|
+
style={{
|
|
773
|
+
flexGrow: 1,
|
|
774
|
+
flexShrink: 1,
|
|
775
|
+
flexDirection: "column",
|
|
776
|
+
paddingLeft: 1,
|
|
777
|
+
backgroundColor: sidebarActive ? colors.background : colors.surface,
|
|
778
|
+
}}
|
|
779
|
+
>
|
|
780
|
+
{sidebarBody}
|
|
781
|
+
</box>
|
|
682
782
|
</box>
|
|
683
783
|
)}
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
<text content={error} style={{ fg: colors.error }} />
|
|
698
|
-
) : (
|
|
699
|
-
<scrollbox
|
|
784
|
+
{readerVisible && (
|
|
785
|
+
<box
|
|
786
|
+
style={{
|
|
787
|
+
border: readerBorderSides,
|
|
788
|
+
borderColor: colors.textMuted,
|
|
789
|
+
flexGrow: 1,
|
|
790
|
+
flexShrink: 1,
|
|
791
|
+
flexDirection: "column",
|
|
792
|
+
// Dim by default (see sidebar note); inner body overrides when active.
|
|
793
|
+
backgroundColor: colors.surface,
|
|
794
|
+
}}
|
|
795
|
+
>
|
|
796
|
+
<box
|
|
700
797
|
style={{
|
|
701
|
-
scrollY: true,
|
|
702
|
-
scrollX: false,
|
|
703
798
|
flexGrow: 1,
|
|
704
799
|
flexShrink: 1,
|
|
705
|
-
|
|
800
|
+
flexDirection: "column",
|
|
801
|
+
padding: 1,
|
|
802
|
+
backgroundColor: readerActive ? colors.background : colors.surface,
|
|
706
803
|
}}
|
|
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
804
|
>
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
805
|
+
{error ? (
|
|
806
|
+
<text content={error} style={{ fg: colors.error }} />
|
|
807
|
+
) : !renderedPath ? (
|
|
808
|
+
// Reader empty state — no file selected. Brand mark centered as a
|
|
809
|
+
// welcome anchor; in-app tips (#47) will live here too.
|
|
810
|
+
<box
|
|
811
|
+
style={{
|
|
812
|
+
flexGrow: 1,
|
|
813
|
+
flexShrink: 1,
|
|
814
|
+
alignItems: "center",
|
|
815
|
+
justifyContent: "center",
|
|
816
|
+
backgroundColor: readerActive ? colors.background : colors.surface,
|
|
817
|
+
}}
|
|
818
|
+
>
|
|
819
|
+
<text
|
|
820
|
+
content={`${BRAND} ${BRAND_NAME}`}
|
|
821
|
+
wrapMode="none"
|
|
822
|
+
style={{ fg: colors.textMuted }}
|
|
823
|
+
/>
|
|
824
|
+
</box>
|
|
825
|
+
) : (
|
|
826
|
+
<scrollbox
|
|
827
|
+
style={{
|
|
828
|
+
scrollY: true,
|
|
829
|
+
scrollX: false,
|
|
830
|
+
flexGrow: 1,
|
|
831
|
+
flexShrink: 1,
|
|
832
|
+
backgroundColor: readerActive ? colors.background : colors.surface,
|
|
833
|
+
}}
|
|
834
|
+
// opentui's scrollbox consumes arrow keys at the focused-element
|
|
835
|
+
// level *before* useKeyboard fires, so a modal that handles
|
|
836
|
+
// arrow keys itself (palette nav, help dismissal) would still
|
|
837
|
+
// see the reader scroll alongside its own action. Unfocus the
|
|
838
|
+
// scrollbox while any blocking modal is up — useKeyboard's
|
|
839
|
+
// modal branches own the keys in that state. Filter is not
|
|
840
|
+
// listed because it force-focuses the sidebar (readerActive
|
|
841
|
+
// is already false).
|
|
842
|
+
focused={readerActive && !paletteOpen && !helpVisible}
|
|
843
|
+
>
|
|
844
|
+
<markdown
|
|
845
|
+
key={renderedPath ?? "empty"}
|
|
846
|
+
content={content}
|
|
847
|
+
syntaxStyle={syntaxStyle}
|
|
848
|
+
fg={colors.text}
|
|
849
|
+
bg={readerActive ? colors.background : colors.surface}
|
|
850
|
+
conceal
|
|
851
|
+
style={{ width: maxWidth ?? "100%" }}
|
|
852
|
+
/>
|
|
853
|
+
</scrollbox>
|
|
854
|
+
)}
|
|
855
|
+
</box>
|
|
856
|
+
</box>
|
|
857
|
+
)}
|
|
729
858
|
</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
859
|
<Footer
|
|
755
860
|
bindings={footerBindings}
|
|
756
861
|
ctx={ctx}
|
|
757
862
|
width={width}
|
|
758
|
-
notice={footerNotice}
|
|
863
|
+
notice={footerNotice?.text ?? null}
|
|
759
864
|
discoveryStatus={discoveryStatus}
|
|
760
865
|
filterQuery={!filterOpen && filterQuery.length > 0 ? filterQuery : null}
|
|
761
866
|
/>
|