@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/index.tsx
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { stat } from "node:fs/promises"
|
|
12
|
+
import { dirname, resolve } from "node:path"
|
|
12
13
|
import { createCliRenderer, SyntaxStyle } from "@opentui/core"
|
|
13
14
|
import type { BorderSides } from "@opentui/core"
|
|
14
15
|
import { createRoot, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
|
|
@@ -16,7 +17,7 @@ import { RegistryProvider, useAtomSet, useAtomValue } from "@effect/atom-react"
|
|
|
16
17
|
import { Cause, Duration, Effect, Fiber, Stream } from "effect"
|
|
17
18
|
import { useEffect, useMemo, useRef, useState } from "react"
|
|
18
19
|
import pkg from "../package.json" with { type: "json" }
|
|
19
|
-
import { Browser } from "./Browser.tsx"
|
|
20
|
+
import { Browser, type StartupFocus } from "./Browser.tsx"
|
|
20
21
|
import { parseArgv, usage } from "./cli/argv.ts"
|
|
21
22
|
import { defaultConfigPath, formatConfigError, loadConfig } from "./config/load.ts"
|
|
22
23
|
import { parseShowList, SHOW_CATEGORIES, type ShowCategory } from "./discovery/show.ts"
|
|
@@ -58,6 +59,39 @@ export interface AppProps {
|
|
|
58
59
|
*/
|
|
59
60
|
export type SidebarMode = "auto" | "on" | "off"
|
|
60
61
|
|
|
62
|
+
const pathIsDirectory = async (path: string): Promise<boolean> => {
|
|
63
|
+
try {
|
|
64
|
+
return (await stat(path)).isDirectory()
|
|
65
|
+
} catch {
|
|
66
|
+
return false
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const findGitRoot = async (cwd: string): Promise<string> => {
|
|
71
|
+
const start = resolve(cwd)
|
|
72
|
+
let current = start
|
|
73
|
+
for (;;) {
|
|
74
|
+
if (await pathIsDirectory(resolve(current, ".git"))) return current
|
|
75
|
+
const parent = dirname(current)
|
|
76
|
+
if (parent === current) return start
|
|
77
|
+
current = parent
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const resolveDiscoveryRoot = async ({
|
|
82
|
+
cliRoot,
|
|
83
|
+
defaultRoot,
|
|
84
|
+
cwd,
|
|
85
|
+
}: {
|
|
86
|
+
readonly cliRoot: string | null
|
|
87
|
+
readonly defaultRoot: "cwd" | "git"
|
|
88
|
+
readonly cwd: string
|
|
89
|
+
}): Promise<string> => {
|
|
90
|
+
if (cliRoot !== null) return cliRoot
|
|
91
|
+
if (defaultRoot === "git") return findGitRoot(cwd)
|
|
92
|
+
return cwd
|
|
93
|
+
}
|
|
94
|
+
|
|
61
95
|
interface DiscoverShellProps {
|
|
62
96
|
readonly target: string
|
|
63
97
|
/** Resolved discovery vocabulary from the config layer. The shift+a
|
|
@@ -69,7 +103,7 @@ interface DiscoverShellProps {
|
|
|
69
103
|
readonly mdx: boolean
|
|
70
104
|
readonly maxWidth: number | null
|
|
71
105
|
readonly sidebarMode: SidebarMode
|
|
72
|
-
readonly
|
|
106
|
+
readonly startupFocus: StartupFocus
|
|
73
107
|
}
|
|
74
108
|
|
|
75
109
|
const DiscoverShell = ({
|
|
@@ -79,7 +113,7 @@ const DiscoverShell = ({
|
|
|
79
113
|
mdx,
|
|
80
114
|
maxWidth,
|
|
81
115
|
sidebarMode,
|
|
82
|
-
|
|
116
|
+
startupFocus,
|
|
83
117
|
}: DiscoverShellProps) => {
|
|
84
118
|
const updateNotice = useUpdateNotice()
|
|
85
119
|
const [show, setShow] = useState<readonly ShowCategory[]>(initialShow)
|
|
@@ -134,7 +168,7 @@ const DiscoverShell = ({
|
|
|
134
168
|
maxWidth={maxWidth}
|
|
135
169
|
discoveryStatus={discoveryStatus}
|
|
136
170
|
sidebarMode={sidebarMode}
|
|
137
|
-
|
|
171
|
+
startupFocus={startupFocus}
|
|
138
172
|
updateNotice={updateNotice}
|
|
139
173
|
onToggleAll={() => {
|
|
140
174
|
// shift+a is the only place the categories are treated as a
|
|
@@ -267,15 +301,17 @@ if (import.meta.main) {
|
|
|
267
301
|
// `--show` replaces env/file when present (set semantics —
|
|
268
302
|
// no per-category merge across sources). `null` falls through.
|
|
269
303
|
show: cliShow,
|
|
270
|
-
|
|
271
|
-
|
|
304
|
+
focus:
|
|
305
|
+
args.focus === "sidebar" || args.focus === "reader" || args.focus === "filter"
|
|
306
|
+
? args.focus
|
|
307
|
+
: null,
|
|
272
308
|
},
|
|
273
309
|
}),
|
|
274
310
|
).catch((err: unknown) => {
|
|
275
311
|
console.error(`house: ${formatConfigError(err)}`)
|
|
276
312
|
process.exit(2)
|
|
277
313
|
})
|
|
278
|
-
const { theme: themeId, tone, mdx, show,
|
|
314
|
+
const { theme: themeId, tone, mdx, show, focus: startupFocus, defaultRoot } = config
|
|
279
315
|
const themeDef = getThemeDefinition(themeId)
|
|
280
316
|
if (themeDef === undefined) {
|
|
281
317
|
// Unreachable: Config.schema validated themeId against themeDefinitions.
|
|
@@ -294,7 +330,9 @@ if (import.meta.main) {
|
|
|
294
330
|
maxWidth = n
|
|
295
331
|
}
|
|
296
332
|
|
|
333
|
+
const cwd = process.cwd()
|
|
297
334
|
const target = args.path ?? "."
|
|
335
|
+
const discoveryRoot = await resolveDiscoveryRoot({ cliRoot: args.root, defaultRoot, cwd })
|
|
298
336
|
|
|
299
337
|
if (args.serve) {
|
|
300
338
|
let stats: Awaited<ReturnType<typeof stat>>
|
|
@@ -345,8 +383,17 @@ if (import.meta.main) {
|
|
|
345
383
|
}
|
|
346
384
|
sidebarMode = args.sidebar
|
|
347
385
|
}
|
|
386
|
+
if (args.focus !== null) {
|
|
387
|
+
if (args.focus !== "sidebar" && args.focus !== "reader" && args.focus !== "filter") {
|
|
388
|
+
console.error(
|
|
389
|
+
`house: --focus must be "sidebar", "reader", or "filter", got "${args.focus}"`,
|
|
390
|
+
)
|
|
391
|
+
process.exit(2)
|
|
392
|
+
}
|
|
393
|
+
}
|
|
348
394
|
await runTui({
|
|
349
395
|
target,
|
|
396
|
+
discoveryRoot,
|
|
350
397
|
themeId,
|
|
351
398
|
tone,
|
|
352
399
|
maxWidth,
|
|
@@ -354,7 +401,7 @@ if (import.meta.main) {
|
|
|
354
401
|
sort,
|
|
355
402
|
mdx,
|
|
356
403
|
sidebarMode,
|
|
357
|
-
|
|
404
|
+
startupFocus,
|
|
358
405
|
updateCheck: !args.noUpdateCheck,
|
|
359
406
|
})
|
|
360
407
|
}
|
|
@@ -362,6 +409,7 @@ if (import.meta.main) {
|
|
|
362
409
|
|
|
363
410
|
interface TuiBootOptions {
|
|
364
411
|
readonly target: string
|
|
412
|
+
readonly discoveryRoot: string
|
|
365
413
|
readonly themeId: string
|
|
366
414
|
readonly tone: "dark" | "light"
|
|
367
415
|
readonly maxWidth: number | null
|
|
@@ -369,7 +417,7 @@ interface TuiBootOptions {
|
|
|
369
417
|
readonly sort: SortOrder
|
|
370
418
|
readonly mdx: boolean
|
|
371
419
|
readonly sidebarMode: SidebarMode
|
|
372
|
-
readonly
|
|
420
|
+
readonly startupFocus: StartupFocus
|
|
373
421
|
/** Run the npm-registry probe and surface the "update available" notice.
|
|
374
422
|
* False suppresses both the toast and the quit-time print. */
|
|
375
423
|
readonly updateCheck: boolean
|
|
@@ -377,6 +425,7 @@ interface TuiBootOptions {
|
|
|
377
425
|
|
|
378
426
|
async function runTui({
|
|
379
427
|
target,
|
|
428
|
+
discoveryRoot,
|
|
380
429
|
themeId,
|
|
381
430
|
tone,
|
|
382
431
|
maxWidth,
|
|
@@ -384,7 +433,7 @@ async function runTui({
|
|
|
384
433
|
sort,
|
|
385
434
|
mdx,
|
|
386
435
|
sidebarMode,
|
|
387
|
-
|
|
436
|
+
startupFocus,
|
|
388
437
|
updateCheck,
|
|
389
438
|
}: TuiBootOptions): Promise<void> {
|
|
390
439
|
let stats: Awaited<ReturnType<typeof stat>>
|
|
@@ -420,13 +469,13 @@ async function runTui({
|
|
|
420
469
|
createRoot(renderer).render(
|
|
421
470
|
<RegistryProvider initialValues={[[themeAtom, initialTheme]]}>
|
|
422
471
|
<DiscoverShell
|
|
423
|
-
target={
|
|
472
|
+
target={discoveryRoot}
|
|
424
473
|
initialShow={show}
|
|
425
474
|
sort={sort}
|
|
426
475
|
mdx={mdx}
|
|
427
476
|
maxWidth={maxWidth}
|
|
428
477
|
sidebarMode={sidebarMode}
|
|
429
|
-
|
|
478
|
+
startupFocus={startupFocus}
|
|
430
479
|
/>
|
|
431
480
|
</RegistryProvider>,
|
|
432
481
|
)
|
package/src/keymap/browser.ts
CHANGED
|
@@ -11,7 +11,7 @@ export type BrowserFocus = "sidebar" | "reader"
|
|
|
11
11
|
export interface BrowserCtx {
|
|
12
12
|
readonly files: readonly FileEntry[]
|
|
13
13
|
/** True iff `files[selectedIndex]` resolves to an entry. The honest
|
|
14
|
-
* predicate for File-group actions (`
|
|
14
|
+
* predicate for File-group actions (`O`, `E`, `[`, `]`): with debounced
|
|
15
15
|
* filter and sticky auto-select, `files.length > 0` can be true while
|
|
16
16
|
* `selectedIndex` is invalid for the displayed list. See #115. */
|
|
17
17
|
readonly hasSelected: boolean
|
|
@@ -20,6 +20,7 @@ export interface BrowserCtx {
|
|
|
20
20
|
readonly sidebarShown: boolean
|
|
21
21
|
readonly helpVisible: boolean
|
|
22
22
|
readonly filterOpen: boolean
|
|
23
|
+
readonly restoreFilterOnSidebarFocus: boolean
|
|
23
24
|
/** Current applied/edited filter query. Used by `filter.clearOrOpen`'s
|
|
24
25
|
* hint gate so the hint only appears when there is something to clear. */
|
|
25
26
|
readonly filterQuery: string
|
|
@@ -87,7 +88,13 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
|
|
|
87
88
|
description: "Toggle focus (sidebar ↔ reader)",
|
|
88
89
|
hint: "focus",
|
|
89
90
|
keys: ["tab"],
|
|
90
|
-
run: (c) =>
|
|
91
|
+
run: (c) => {
|
|
92
|
+
if (c.focus === "reader" && c.restoreFilterOnSidebarFocus) {
|
|
93
|
+
c.openFilter()
|
|
94
|
+
} else {
|
|
95
|
+
c.setFocus((f) => (f === "sidebar" ? "reader" : "sidebar"))
|
|
96
|
+
}
|
|
97
|
+
},
|
|
91
98
|
},
|
|
92
99
|
{
|
|
93
100
|
id: "sidebar.toggle",
|
|
@@ -271,7 +278,7 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
|
|
|
271
278
|
group: "File",
|
|
272
279
|
description: "Open current file in browser as HTML",
|
|
273
280
|
hint: "html",
|
|
274
|
-
keys: ["o"],
|
|
281
|
+
keys: ["shift+o"],
|
|
275
282
|
when: hasSelected,
|
|
276
283
|
run: (c) => c.serveCurrent(),
|
|
277
284
|
},
|
|
@@ -280,7 +287,7 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
|
|
|
280
287
|
group: "File",
|
|
281
288
|
description: "Open current file in $EDITOR",
|
|
282
289
|
hint: "edit",
|
|
283
|
-
keys: ["e"],
|
|
290
|
+
keys: ["shift+e"],
|
|
284
291
|
when: hasSelected,
|
|
285
292
|
run: (c) => c.editCurrent(),
|
|
286
293
|
},
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Terminal-friendly display form for a binding's first key chord. */
|
|
2
|
+
export const displayKey = (raw: string): string => {
|
|
3
|
+
switch (raw) {
|
|
4
|
+
case "return":
|
|
5
|
+
return "↵"
|
|
6
|
+
case "escape":
|
|
7
|
+
return "esc"
|
|
8
|
+
case "space":
|
|
9
|
+
return "␣"
|
|
10
|
+
case "pageup":
|
|
11
|
+
return "pgup"
|
|
12
|
+
case "pagedown":
|
|
13
|
+
return "pgdn"
|
|
14
|
+
default:
|
|
15
|
+
return raw
|
|
16
|
+
}
|
|
17
|
+
}
|
package/src/keymap/keymap.ts
CHANGED
|
@@ -62,6 +62,9 @@ const parseChord = (raw: string): ParsedChord => {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
const chordMatches = (chord: ParsedChord, key: KeyMatch): boolean => {
|
|
65
|
+
if (chord.key === "tab" && key.name === "i" && key.ctrl && !key.shift && !key.meta) {
|
|
66
|
+
return true
|
|
67
|
+
}
|
|
65
68
|
if (chord.key !== key.name) return false
|
|
66
69
|
if (chord.shift !== Boolean(key.shift)) return false
|
|
67
70
|
if (chord.ctrl !== Boolean(key.ctrl)) return false
|
package/src/serve/server.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Local HTML preview server for a single markdown file.
|
|
3
3
|
*
|
|
4
4
|
* One long-lived `Bun.serve` instance. The served file path is swappable
|
|
5
|
-
* via `setTarget(path)` — used by the TUI's `
|
|
5
|
+
* via `setTarget(path)` — used by the TUI's `O` binding so pressing it on a
|
|
6
6
|
* new file retargets the existing server (live-reload fires) instead of
|
|
7
7
|
* spawning a second one.
|
|
8
8
|
*
|
package/src/theme/colors.ts
CHANGED
|
@@ -6,52 +6,20 @@ import type { ColorPalette, ResolvedTheme, ThemeDefinition, Tone } from "./types
|
|
|
6
6
|
* Adapt a resolved theme (opencode-shaped flat tokens) to the
|
|
7
7
|
* {@link ColorPalette} shape consumed by Browser / HelpOverlay / index.
|
|
8
8
|
*
|
|
9
|
-
* - UI tokens map name-for-name
|
|
10
|
-
* - `surface` ← `backgroundPanel`, `selectedBg` ← `backgroundElement`,
|
|
11
|
-
* `selectedBgInactive` ← `borderSubtle`.
|
|
12
|
-
* - `textStrong` ← `primary` (opencode's convention for emphasized brand
|
|
13
|
-
* text in UI chrome). Markdown rendering still reads `markdownStrong`
|
|
14
|
-
* directly via the syntax map.
|
|
9
|
+
* - UI tokens map name-for-name with OpenCode's TUI theme semantics.
|
|
15
10
|
* - `syntax` is a fully populated opentui tree-sitter scope map built from
|
|
16
11
|
* `markdown*` and `syntax*` tokens.
|
|
17
12
|
*/
|
|
18
|
-
/** Relative luminance of a `#rrggbb` color (0..1). Sufficient for ordering
|
|
19
|
-
* two near-neutral chrome colors — not a full WCAG contrast calculation.
|
|
20
|
-
* Non-hex inputs fall back to 0.5 so the caller's ordering is a no-op. */
|
|
21
|
-
const luminance = (hex: string): number => {
|
|
22
|
-
const m = /^#([0-9a-fA-F]{6})$/.exec(hex)
|
|
23
|
-
if (!m) return 0.5
|
|
24
|
-
const h = m[1]!
|
|
25
|
-
const r = parseInt(h.slice(0, 2), 16)
|
|
26
|
-
const g = parseInt(h.slice(2, 4), 16)
|
|
27
|
-
const b = parseInt(h.slice(4, 6), 16)
|
|
28
|
-
// Rec. 601 weighting — perceptual ordering, not the linearized WCAG variant.
|
|
29
|
-
return (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** Pane chrome assumes the active/raised pane sits on the darker of the two
|
|
33
|
-
* background tokens and the dim chrome on the lighter one. Most themes
|
|
34
|
-
* define `background` darker than `backgroundPanel` and this is a no-op;
|
|
35
|
-
* some (e.g. cursor) flip the polarity, in which case we swap so the
|
|
36
|
-
* active-pane convention stays consistent across themes. */
|
|
37
|
-
const orientChrome = (r: ResolvedTheme): { raised: string; dim: string } => {
|
|
38
|
-
const bg = r.background
|
|
39
|
-
const panel = r.backgroundPanel
|
|
40
|
-
return luminance(bg) <= luminance(panel) ? { raised: bg, dim: panel } : { raised: panel, dim: bg }
|
|
41
|
-
}
|
|
42
|
-
|
|
43
13
|
const buildPalette = (r: ResolvedTheme): ColorPalette => {
|
|
44
|
-
const { raised, dim } = orientChrome(r)
|
|
45
14
|
return {
|
|
46
|
-
background:
|
|
47
|
-
|
|
15
|
+
background: r.background,
|
|
16
|
+
backgroundPanel: r.backgroundPanel,
|
|
17
|
+
backgroundElement: r.backgroundElement,
|
|
48
18
|
text: r.text,
|
|
49
|
-
textStrong: r.primary,
|
|
50
19
|
textMuted: r.textMuted,
|
|
51
20
|
border: r.border,
|
|
52
21
|
borderActive: r.borderActive,
|
|
53
|
-
|
|
54
|
-
selectedBgInactive: r.borderSubtle,
|
|
22
|
+
borderSubtle: r.borderSubtle,
|
|
55
23
|
selectedListItemText: r.selectedListItemText,
|
|
56
24
|
primary: r.primary,
|
|
57
25
|
secondary: r.secondary,
|
package/src/theme/types.ts
CHANGED
|
@@ -82,21 +82,20 @@ export type TokenName = keyof ThemeTokens
|
|
|
82
82
|
export type ResolvedTheme = Readonly<Record<TokenName, HexColor>>
|
|
83
83
|
|
|
84
84
|
/**
|
|
85
|
-
* The flat color object consumed by Browser/HelpOverlay/index.
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* `colors.ts`'s adapter, which maps `ResolvedTheme` → this shape.
|
|
85
|
+
* The flat color object consumed by Browser/HelpOverlay/index. Uses the
|
|
86
|
+
* OpenCode-aligned UI token names directly (`background`, `backgroundPanel`,
|
|
87
|
+
* `backgroundElement`, `borderSubtle`, `primary`, `secondary`, …). Values
|
|
88
|
+
* come from `colors.ts`'s adapter, which maps `ResolvedTheme` → this shape.
|
|
89
89
|
*/
|
|
90
90
|
export interface ColorPalette {
|
|
91
91
|
readonly background: string
|
|
92
|
-
readonly
|
|
92
|
+
readonly backgroundPanel: string
|
|
93
|
+
readonly backgroundElement: string
|
|
93
94
|
readonly text: string
|
|
94
|
-
readonly textStrong: string
|
|
95
95
|
readonly textMuted: string
|
|
96
96
|
readonly border: string
|
|
97
97
|
readonly borderActive: string
|
|
98
|
-
readonly
|
|
99
|
-
readonly selectedBgInactive: string
|
|
98
|
+
readonly borderSubtle: string
|
|
100
99
|
readonly selectedListItemText: string
|
|
101
100
|
readonly primary: string
|
|
102
101
|
readonly secondary: string
|
package/src/tips.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { BrowserCtx } from "./keymap/browser.ts"
|
|
2
|
+
import type { KeyBinding } from "./keymap/keymap.ts"
|
|
3
|
+
import { displayKey } from "./keymap/displayKey.ts"
|
|
4
|
+
|
|
5
|
+
export interface TipLine {
|
|
6
|
+
readonly id: string
|
|
7
|
+
readonly text: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface TipDefinition {
|
|
11
|
+
readonly id: string
|
|
12
|
+
readonly bindingId?: string
|
|
13
|
+
readonly render: (parts: { readonly key: string | null }) => string
|
|
14
|
+
readonly when?: (ctx: BrowserCtx) => boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const tipDefinitions: readonly TipDefinition[] = [
|
|
18
|
+
{
|
|
19
|
+
id: "filter.start",
|
|
20
|
+
bindingId: "filter.open",
|
|
21
|
+
render: ({ key }) => `Press ${key ?? "/"} to start filtering files by path.`,
|
|
22
|
+
when: (ctx) => ctx.filterQuery.length === 0 && !ctx.filterOpen,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: "filter.resume",
|
|
26
|
+
bindingId: "filter.open",
|
|
27
|
+
render: ({ key }) => `Press ${key ?? "/"} to reopen the current filter and keep refining it.`,
|
|
28
|
+
when: (ctx) => ctx.filterQuery.length > 0 && !ctx.filterOpen,
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: "filter.commit",
|
|
32
|
+
bindingId: "filter.open",
|
|
33
|
+
render: () => "Press Enter in the filter to open the selected match in the reader.",
|
|
34
|
+
when: (ctx) => ctx.filterQuery.length === 0,
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: "filter.clear",
|
|
38
|
+
bindingId: "filter.clearOrOpen",
|
|
39
|
+
render: ({ key }) => `Press ${key ?? "ctrl+\\"} to clear the current filter and start over.`,
|
|
40
|
+
when: (ctx) => ctx.filterQuery.length > 0,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: "filtered-navigation",
|
|
44
|
+
render: () => "Use [ and ] to move through files while the current filter stays applied.",
|
|
45
|
+
when: (ctx) => ctx.filterQuery.length > 0,
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: "focus.toggle",
|
|
49
|
+
bindingId: "focus.toggle",
|
|
50
|
+
render: ({ key }) => `Press ${key ?? "tab"} to switch between the sidebar and reader.`,
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: "sidebar.toggle",
|
|
54
|
+
bindingId: "sidebar.toggle",
|
|
55
|
+
render: ({ key }) =>
|
|
56
|
+
`Press ${key ?? "s"} to hide or show the sidebar without losing your place.`,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: "help.open",
|
|
60
|
+
bindingId: "help.toggle",
|
|
61
|
+
render: ({ key }) => `Press ${key ?? "?"} to open the full keyboard help at any time.`,
|
|
62
|
+
},
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
const firstKeyByBindingId = <C>(bindings: readonly KeyBinding<C>[]): ReadonlyMap<string, string> =>
|
|
66
|
+
new Map(
|
|
67
|
+
bindings.flatMap((binding) => {
|
|
68
|
+
const firstKey = binding.keys[0]
|
|
69
|
+
return firstKey ? [[binding.id, displayKey(firstKey)] as const] : []
|
|
70
|
+
}),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
export const buildReaderEmptyStateTips = (
|
|
74
|
+
bindings: readonly KeyBinding<BrowserCtx>[],
|
|
75
|
+
ctx: BrowserCtx,
|
|
76
|
+
): readonly TipLine[] => {
|
|
77
|
+
const keyByBindingId = firstKeyByBindingId(bindings)
|
|
78
|
+
|
|
79
|
+
return tipDefinitions
|
|
80
|
+
.filter((tip) => !tip.when || tip.when(ctx))
|
|
81
|
+
.map((tip) => ({
|
|
82
|
+
id: tip.id,
|
|
83
|
+
text: `Tip: ${tip.render({ key: tip.bindingId ? (keyByBindingId.get(tip.bindingId) ?? null) : null })}`,
|
|
84
|
+
}))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const pickTipByRotation = (
|
|
88
|
+
tips: readonly TipLine[],
|
|
89
|
+
rotationIndex: number,
|
|
90
|
+
): TipLine | null => {
|
|
91
|
+
if (tips.length === 0) return null
|
|
92
|
+
return tips[((rotationIndex % tips.length) + tips.length) % tips.length] ?? null
|
|
93
|
+
}
|