@carlesandres/house 0.3.1 → 0.4.0
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 +22 -1
- package/README.md +28 -0
- package/package.json +3 -2
- package/src/Browser.tsx +348 -53
- package/src/CommandPalette.tsx +126 -0
- package/src/Footer.tsx +61 -34
- package/src/cli/argv.ts +30 -2
- package/src/commands/buildCommands.ts +102 -0
- package/src/commands/paletteOnlyCommands.ts +16 -0
- package/src/commands/score.ts +78 -0
- package/src/commands/types.ts +26 -0
- package/src/config/load.ts +166 -0
- package/src/discovery/walk.ts +59 -20
- package/src/index.tsx +106 -28
- package/src/keymap/browser.ts +26 -11
- package/src/layout/resolve.ts +60 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CommandPalette — modal overlay for searching and running commands.
|
|
3
|
+
*
|
|
4
|
+
* Render-only: state lives in Browser.tsx alongside helpVisible / filterOpen
|
|
5
|
+
* (#70 design log §state location). Key handling sits in Browser.tsx's
|
|
6
|
+
* `useKeyboard` palette-branch, mirroring the filterOpen pattern.
|
|
7
|
+
*
|
|
8
|
+
* V1 ships a flat list in `browserBindings` order — no category headers,
|
|
9
|
+
* no mouse, no recency. See #91/#93/#94/#95/#96 for the follow-ups.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { colors } from "./theme/colors.ts"
|
|
13
|
+
import type { AppCommand } from "./commands/types.ts"
|
|
14
|
+
|
|
15
|
+
export interface CommandPaletteProps {
|
|
16
|
+
readonly commands: readonly AppCommand[]
|
|
17
|
+
readonly query: string
|
|
18
|
+
readonly selectedIndex: number
|
|
19
|
+
readonly viewportWidth: number
|
|
20
|
+
readonly viewportHeight: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const FOOTER_HINT = "↑↓ select enter run esc close"
|
|
24
|
+
|
|
25
|
+
export const CommandPalette = ({
|
|
26
|
+
commands,
|
|
27
|
+
query,
|
|
28
|
+
selectedIndex,
|
|
29
|
+
viewportWidth,
|
|
30
|
+
viewportHeight,
|
|
31
|
+
}: CommandPaletteProps) => {
|
|
32
|
+
const overlayWidth = Math.min(viewportWidth - 4, 64)
|
|
33
|
+
// Reserve: 2 for border (top+bottom), 1 query row, 1 spacer below query,
|
|
34
|
+
// 1 spacer above footer, 1 footer row. Body gets the rest.
|
|
35
|
+
const chrome = 2 + 1 + 1 + 1 + 1
|
|
36
|
+
const maxBody = Math.max(1, viewportHeight - 4 - chrome)
|
|
37
|
+
const desiredBody = Math.max(1, commands.length || 1)
|
|
38
|
+
const bodyHeight = Math.min(desiredBody, maxBody)
|
|
39
|
+
const overlayHeight = chrome + bodyHeight
|
|
40
|
+
const left = Math.max(0, Math.floor((viewportWidth - overlayWidth) / 2))
|
|
41
|
+
const top = Math.max(0, Math.floor((viewportHeight - overlayHeight) / 2))
|
|
42
|
+
|
|
43
|
+
// Inner content width: overlay minus 1-cell border + 1-cell padding on each side.
|
|
44
|
+
const rowWidth = Math.max(4, overlayWidth - 4)
|
|
45
|
+
|
|
46
|
+
// Window the visible slice around the selection. With 9 commands in v1
|
|
47
|
+
// this is usually a no-op (list fits), but the math is in place for the
|
|
48
|
+
// inevitable backlog growth.
|
|
49
|
+
const scrollTop = (() => {
|
|
50
|
+
if (commands.length <= bodyHeight) return 0
|
|
51
|
+
const maxScroll = commands.length - bodyHeight
|
|
52
|
+
let s = 0
|
|
53
|
+
if (selectedIndex >= bodyHeight) s = selectedIndex - bodyHeight + 1
|
|
54
|
+
return Math.max(0, Math.min(s, maxScroll))
|
|
55
|
+
})()
|
|
56
|
+
const visible = commands.slice(scrollTop, scrollTop + bodyHeight)
|
|
57
|
+
|
|
58
|
+
// Shortcut column width — long enough for `shift+t`-style chords but
|
|
59
|
+
// trimmed to prevent the title from being squeezed below ~16 cells.
|
|
60
|
+
const SHORTCUT_WIDTH = 10
|
|
61
|
+
const titleWidth = Math.max(8, rowWidth - 2 /* selector */ - SHORTCUT_WIDTH - 1 /* gap */)
|
|
62
|
+
|
|
63
|
+
const fit = (s: string, width: number): string =>
|
|
64
|
+
s.length === width
|
|
65
|
+
? s
|
|
66
|
+
: s.length > width
|
|
67
|
+
? s.slice(0, Math.max(0, width - 1)) + "…"
|
|
68
|
+
: s + " ".repeat(width - s.length)
|
|
69
|
+
|
|
70
|
+
const fitRight = (s: string, width: number): string =>
|
|
71
|
+
s.length >= width ? s.slice(0, width) : " ".repeat(width - s.length) + s
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<box
|
|
75
|
+
position="absolute"
|
|
76
|
+
left={left}
|
|
77
|
+
top={top}
|
|
78
|
+
width={overlayWidth}
|
|
79
|
+
height={overlayHeight}
|
|
80
|
+
zIndex={20}
|
|
81
|
+
title=" Commands "
|
|
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
|
+
}}
|
|
91
|
+
>
|
|
92
|
+
<text
|
|
93
|
+
wrapMode="none"
|
|
94
|
+
content={fit(`> ${query}▏`, rowWidth)}
|
|
95
|
+
style={{ fg: colors.textStrong }}
|
|
96
|
+
/>
|
|
97
|
+
<text content=" " />
|
|
98
|
+
{commands.length === 0 ? (
|
|
99
|
+
<text wrapMode="none" content=" (no matches)" style={{ fg: colors.textMuted }} />
|
|
100
|
+
) : (
|
|
101
|
+
visible.map((cmd, i) => {
|
|
102
|
+
const realIdx = scrollTop + i
|
|
103
|
+
const isSelected = realIdx === selectedIndex
|
|
104
|
+
const selector = isSelected ? "▸ " : " "
|
|
105
|
+
const titleText = fit(cmd.title, titleWidth)
|
|
106
|
+
const shortcutText = cmd.shortcut
|
|
107
|
+
? fitRight(cmd.shortcut, SHORTCUT_WIDTH)
|
|
108
|
+
: " ".repeat(SHORTCUT_WIDTH)
|
|
109
|
+
// Title and shortcut render as separate spans so the shortcut
|
|
110
|
+
// can use `textMuted` while the title uses `text`/`textStrong`.
|
|
111
|
+
// Same trick opencode pulls with `--text-weak` — the theme
|
|
112
|
+
// guarantees the contrast, we just pick the right role.
|
|
113
|
+
const titleFg = isSelected ? colors.textStrong : colors.text
|
|
114
|
+
return (
|
|
115
|
+
<text key={cmd.id} wrapMode="none" style={isSelected ? { bg: colors.selectedBg } : {}}>
|
|
116
|
+
<span style={{ fg: titleFg }}>{`${selector}${titleText} `}</span>
|
|
117
|
+
<span style={{ fg: colors.textMuted }}>{shortcutText}</span>
|
|
118
|
+
</text>
|
|
119
|
+
)
|
|
120
|
+
})
|
|
121
|
+
)}
|
|
122
|
+
<text content=" " />
|
|
123
|
+
<text wrapMode="none" content={fit(FOOTER_HINT, rowWidth)} style={{ fg: colors.textMuted }} />
|
|
124
|
+
</box>
|
|
125
|
+
)
|
|
126
|
+
}
|
package/src/Footer.tsx
CHANGED
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
* were discovered. Intentional — `q:quit` and `?:help` are exactly what an
|
|
12
12
|
* empty-vault user needs as an exit and discoverability anchor.
|
|
13
13
|
*
|
|
14
|
+
* The filter input does not live here — it renders as a row inside the
|
|
15
|
+
* sidebar (see Browser.tsx). The pattern mirrors ghui's PR list, where the
|
|
16
|
+
* filter is part of the list it filters.
|
|
17
|
+
*
|
|
14
18
|
* Width math assumes hint labels are ASCII plus a small set of single-cell
|
|
15
19
|
* BMP glyphs (see `displayKey`). `fitHints` and notice clipping use string
|
|
16
20
|
* length as a proxy for cell count; introducing a CJK or emoji label would
|
|
@@ -29,10 +33,15 @@ export interface FooterProps<C> {
|
|
|
29
33
|
readonly ctx: C
|
|
30
34
|
readonly width: number
|
|
31
35
|
readonly notice?: string | null
|
|
32
|
-
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
readonly
|
|
36
|
+
/** Persistent status line (e.g. "indexing… 42"). Distinct from `notice`:
|
|
37
|
+
* no TTL, cleared by the caller when the underlying activity finishes.
|
|
38
|
+
* Loses to `notice` when both are set so transient toasts still surface. */
|
|
39
|
+
readonly discoveryStatus?: string | null
|
|
40
|
+
/** When a filter is applied but the input is closed, surface a chip in the
|
|
41
|
+
* hint row so the user remembers `[`/`]` walks the filtered set. Pass null
|
|
42
|
+
* while the filter input is open (the sidebar already shows the query) or
|
|
43
|
+
* when no filter is applied. */
|
|
44
|
+
readonly filterQuery?: string | null
|
|
36
45
|
}
|
|
37
46
|
|
|
38
47
|
const HINT_SEPARATOR = " "
|
|
@@ -88,13 +97,16 @@ const fitHints = (hints: readonly string[], width: number): string => {
|
|
|
88
97
|
return firstKey.slice(0, width)
|
|
89
98
|
}
|
|
90
99
|
|
|
91
|
-
|
|
92
|
-
* handler in `Browser.tsx`; if those bindings change, update both. */
|
|
93
|
-
const FILTER_HINTS = "↵:open esc:cancel"
|
|
94
|
-
/** Minimum gap between the filter input and its hints on the same row. */
|
|
95
|
-
const FILTER_HINT_GAP = 2
|
|
100
|
+
const STATUS_SEPARATOR = " · "
|
|
96
101
|
|
|
97
|
-
export const Footer = <C,>({
|
|
102
|
+
export const Footer = <C,>({
|
|
103
|
+
bindings,
|
|
104
|
+
ctx,
|
|
105
|
+
width,
|
|
106
|
+
notice,
|
|
107
|
+
discoveryStatus,
|
|
108
|
+
filterQuery,
|
|
109
|
+
}: FooterProps<C>) => {
|
|
98
110
|
const usableWidth = Math.max(0, width - 2) // 1-cell horizontal padding each side
|
|
99
111
|
|
|
100
112
|
const rowStyle = {
|
|
@@ -107,45 +119,60 @@ export const Footer = <C,>({ bindings, ctx, width, notice, filter }: FooterProps
|
|
|
107
119
|
backgroundColor: colors.background,
|
|
108
120
|
} as const
|
|
109
121
|
|
|
110
|
-
// Filter mode is two-column: input left, hints right, separated by a
|
|
111
|
-
// flex-grow spacer. Hints drop entirely on narrow viewports rather than
|
|
112
|
-
// pushing the input off-screen — the input is the primary surface.
|
|
113
|
-
if (filter) {
|
|
114
|
-
const input = `/${filter.query}▏`
|
|
115
|
-
const showHints = input.length + FILTER_HINT_GAP + FILTER_HINTS.length <= usableWidth
|
|
116
|
-
return (
|
|
117
|
-
<box style={rowStyle}>
|
|
118
|
-
<text content={input} wrapMode="none" style={{ fg: colors.textStrong }} />
|
|
119
|
-
{showHints && (
|
|
120
|
-
<>
|
|
121
|
-
<box style={{ flexGrow: 1 }} />
|
|
122
|
-
<text content={FILTER_HINTS} wrapMode="none" style={{ fg: colors.textMuted }} />
|
|
123
|
-
</>
|
|
124
|
-
)}
|
|
125
|
-
</box>
|
|
126
|
-
)
|
|
127
|
-
}
|
|
128
|
-
|
|
129
122
|
const hints: string[] = []
|
|
123
|
+
// The filter chip prepends to the hint row when a filter is applied and the
|
|
124
|
+
// input is closed. Bracketed to avoid looking like a `key:hint` binding —
|
|
125
|
+
// "filter" is not a key. Surfaces the otherwise-invisible invariant that
|
|
126
|
+
// `[`/`]` walks the filtered set. See DESIGN.md §7.1 Q1.
|
|
127
|
+
if (filterQuery && filterQuery.length > 0) {
|
|
128
|
+
hints.push(`[filter: ${filterQuery}]`)
|
|
129
|
+
}
|
|
130
130
|
for (const b of bindings) {
|
|
131
131
|
if (b.when && !b.when(ctx)) continue
|
|
132
132
|
const h = formatHint(b)
|
|
133
133
|
if (h !== null) hints.push(h)
|
|
134
134
|
}
|
|
135
|
-
|
|
135
|
+
|
|
136
|
+
// Discovery status sits left of the hints, separated by " · ". On tight
|
|
137
|
+
// viewports it claims its budget first; hints fit into the remainder so
|
|
138
|
+
// the indicator stays visible while less-essential hints drop off.
|
|
139
|
+
const status = discoveryStatus && discoveryStatus.length > 0 ? discoveryStatus : null
|
|
140
|
+
const statusBudget = status ? Math.min(status.length + STATUS_SEPARATOR.length, usableWidth) : 0
|
|
141
|
+
const hintsWidth = Math.max(0, usableWidth - statusBudget)
|
|
142
|
+
const hintContent = fitHints(hints, hintsWidth)
|
|
143
|
+
const statusContent = status
|
|
144
|
+
? status.slice(0, Math.max(0, statusBudget - STATUS_SEPARATOR.length))
|
|
145
|
+
: ""
|
|
146
|
+
|
|
136
147
|
const noticeContent = notice
|
|
137
148
|
? notice.length > usableWidth
|
|
138
149
|
? notice.slice(0, usableWidth)
|
|
139
150
|
: notice
|
|
140
151
|
: null
|
|
141
152
|
|
|
142
|
-
//
|
|
143
|
-
|
|
144
|
-
|
|
153
|
+
// Priority: notice > (status + hints). Notice fg is strong; status sits
|
|
154
|
+
// at the muted level so it reads as ambient state, not an event.
|
|
155
|
+
if (noticeContent !== null) {
|
|
156
|
+
return (
|
|
157
|
+
<box style={rowStyle}>
|
|
158
|
+
<text content={noticeContent} wrapMode="none" style={{ fg: colors.textStrong }} />
|
|
159
|
+
</box>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (status !== null) {
|
|
164
|
+
return (
|
|
165
|
+
<box style={rowStyle}>
|
|
166
|
+
<text content={statusContent} wrapMode="none" style={{ fg: colors.textMuted }} />
|
|
167
|
+
<text content={STATUS_SEPARATOR} wrapMode="none" style={{ fg: colors.textMuted }} />
|
|
168
|
+
<text content={hintContent} wrapMode="none" style={{ fg: colors.textMuted }} />
|
|
169
|
+
</box>
|
|
170
|
+
)
|
|
171
|
+
}
|
|
145
172
|
|
|
146
173
|
return (
|
|
147
174
|
<box style={rowStyle}>
|
|
148
|
-
<text content={
|
|
175
|
+
<text content={hintContent} wrapMode="none" style={{ fg: colors.textMuted }} />
|
|
149
176
|
</box>
|
|
150
177
|
)
|
|
151
178
|
}
|
package/src/cli/argv.ts
CHANGED
|
@@ -21,6 +21,10 @@ export interface ParsedArgs {
|
|
|
21
21
|
readonly help: boolean
|
|
22
22
|
/** True when `--version` was passed. */
|
|
23
23
|
readonly version: boolean
|
|
24
|
+
/** True when `--config-path` was passed: print resolved config path and exit. */
|
|
25
|
+
readonly configPath: boolean
|
|
26
|
+
/** Value of `--sidebar <mode>` (`auto`, `on`, `off`), or null. Validated by the boot layer. */
|
|
27
|
+
readonly sidebar: string | null
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
/**
|
|
@@ -41,6 +45,8 @@ export const parseArgv = (argv: readonly string[]): ParsedArgs => {
|
|
|
41
45
|
let port: string | null = null
|
|
42
46
|
let help = false
|
|
43
47
|
let version = false
|
|
48
|
+
let configPath = false
|
|
49
|
+
let sidebar: string | null = null
|
|
44
50
|
|
|
45
51
|
for (let i = 0; i < argv.length; i++) {
|
|
46
52
|
const arg = argv[i]!
|
|
@@ -79,13 +85,27 @@ export const parseArgv = (argv: readonly string[]): ParsedArgs => {
|
|
|
79
85
|
case "-v":
|
|
80
86
|
version = true
|
|
81
87
|
continue
|
|
88
|
+
case "--config-path":
|
|
89
|
+
configPath = true
|
|
90
|
+
continue
|
|
91
|
+
case "--sidebar": {
|
|
92
|
+
// Don't swallow the following flag as the sidebar value.
|
|
93
|
+
// `--sidebar --width 80` should leave sidebar=null (the boot
|
|
94
|
+
// layer reports a missing value) without losing --width.
|
|
95
|
+
const next = argv[i + 1]
|
|
96
|
+
if (next !== undefined && !next.startsWith("-")) {
|
|
97
|
+
sidebar = next
|
|
98
|
+
i++
|
|
99
|
+
}
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
82
102
|
}
|
|
83
103
|
if (path === null && !arg.startsWith("-")) {
|
|
84
104
|
path = arg
|
|
85
105
|
}
|
|
86
106
|
}
|
|
87
107
|
|
|
88
|
-
return { path, theme, tone, width, all, sort, serve, port, help, version }
|
|
108
|
+
return { path, theme, tone, width, all, sort, serve, port, help, version, configPath, sidebar }
|
|
89
109
|
}
|
|
90
110
|
|
|
91
111
|
const themeList = themeDefinitions.map((t) => t.id).join(", ")
|
|
@@ -100,7 +120,15 @@ options:
|
|
|
100
120
|
--width <N> cap rendered markdown width at N columns
|
|
101
121
|
--all include hidden and gitignored files in discovery
|
|
102
122
|
--sort <mode> sidebar order: dirs-first (default) or files-first
|
|
123
|
+
--sidebar <m> initial sidebar visibility: auto (default), on, or off
|
|
103
124
|
--serve serve the given file as HTML in the browser (skips TUI)
|
|
104
125
|
--port <N> port for --serve (default: OS-assigned)
|
|
105
126
|
-h, --help show this help and exit
|
|
106
|
-
-v, --version print version and exit
|
|
127
|
+
-v, --version print version and exit
|
|
128
|
+
--config-path print path to the config file and exit
|
|
129
|
+
|
|
130
|
+
configuration:
|
|
131
|
+
file: $XDG_CONFIG_HOME/house/config.toml (default ~/.config/house/config.toml)
|
|
132
|
+
keys: theme, tone
|
|
133
|
+
env: HOUSE_THEME, HOUSE_TONE
|
|
134
|
+
precedence (high → low): flags → env → file → defaults`
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derive palette commands from `browserBindings` plus the annotation map.
|
|
3
|
+
*
|
|
4
|
+
* The annotation map is the *only* hand-written list keyed by binding id:
|
|
5
|
+
* it carries (a) hide flags for bindings that are pure keystroke nav, and
|
|
6
|
+
* (b) title rewrites for bindings whose `description` reads as a help-row
|
|
7
|
+
* entry rather than a palette command. Every other binding is exposed
|
|
8
|
+
* verbatim — its `description` becomes the palette `title`, its first key
|
|
9
|
+
* becomes the `shortcut`.
|
|
10
|
+
*
|
|
11
|
+
* Commands close over the per-render `BrowserCtx`, same shape the keymap
|
|
12
|
+
* dispatcher uses, so the palette and the keymap fire the same action via
|
|
13
|
+
* different surfaces. See #70 design log §list construction (option 3b)
|
|
14
|
+
* for why this beat (a) a fully hand-written palette list and (b) deriving
|
|
15
|
+
* everything via #92's atom-driven registry.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { browserBindings, type BrowserCtx } from "../keymap/browser.ts"
|
|
19
|
+
import { paletteOnlyCommands } from "./paletteOnlyCommands.ts"
|
|
20
|
+
import type { AppCommand } from "./types.ts"
|
|
21
|
+
|
|
22
|
+
interface Annotation {
|
|
23
|
+
/** Override the binding's `description` for palette display. */
|
|
24
|
+
readonly title?: string
|
|
25
|
+
/** Carried for #91's category headers; unused in v1's flat list. */
|
|
26
|
+
readonly category?: string
|
|
27
|
+
/** If true, the binding does not appear in the palette. */
|
|
28
|
+
readonly hidden?: boolean
|
|
29
|
+
readonly keywords?: readonly string[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Pure-keystroke nav (j/k/space/b/[/]…) is intentionally hidden — those
|
|
34
|
+
* bindings have no command-shaped meaning. Reader prev/next file (`[`/`]`)
|
|
35
|
+
* and sidebar `open` (Return/l) are the borderline cases from #70 Q6a;
|
|
36
|
+
* hidden in v1, reconsider if users ask. Title rewrites convert
|
|
37
|
+
* help-overlay phrasing ("Toggle sidebar visibility") into imperative
|
|
38
|
+
* palette phrasing ("Toggle sidebar"). See #70 design log §6.
|
|
39
|
+
*/
|
|
40
|
+
const annotations: Record<string, Annotation> = {
|
|
41
|
+
// --- Keep, with title rewrites where the binding description reads awkwardly as a command ---
|
|
42
|
+
quit: { category: "App" },
|
|
43
|
+
"focus.toggle": { title: "Toggle focus", category: "View" },
|
|
44
|
+
"sidebar.toggle": { title: "Toggle sidebar", category: "View" },
|
|
45
|
+
"help.toggle": { title: "Show help", category: "App" },
|
|
46
|
+
"filter.open": { title: "Filter files…", category: "Navigation" },
|
|
47
|
+
"serve.current": { title: "Open in browser", category: "File" },
|
|
48
|
+
"theme.next": { category: "Appearance" },
|
|
49
|
+
"theme.prev": { category: "Appearance" },
|
|
50
|
+
"theme.toneToggle": { title: "Toggle dark/light tone", category: "Appearance" },
|
|
51
|
+
|
|
52
|
+
// --- Hide: pure keystroke navigation (j/k/space/b/g/G…) ---
|
|
53
|
+
"sidebar.down": { hidden: true },
|
|
54
|
+
"sidebar.up": { hidden: true },
|
|
55
|
+
"sidebar.jumpDown": { hidden: true },
|
|
56
|
+
"sidebar.jumpUp": { hidden: true },
|
|
57
|
+
"sidebar.pageDown": { hidden: true },
|
|
58
|
+
"sidebar.pageUp": { hidden: true },
|
|
59
|
+
"sidebar.top": { hidden: true },
|
|
60
|
+
"sidebar.bottom": { hidden: true },
|
|
61
|
+
|
|
62
|
+
// --- Hide: borderline reader nav. `[`/`]` and Return-to-open feel command-shaped
|
|
63
|
+
// but are pure keystroke navigation under the hood. #70 Q6a — reconsider
|
|
64
|
+
// if user feedback expects them in the palette.
|
|
65
|
+
"sidebar.open": { hidden: true },
|
|
66
|
+
"reader.back": { hidden: true },
|
|
67
|
+
"reader.prevFile": { hidden: true },
|
|
68
|
+
"reader.nextFile": { hidden: true },
|
|
69
|
+
|
|
70
|
+
// --- Hide: the palette opener itself shouldn't appear in the palette ---
|
|
71
|
+
"palette.open": { hidden: true },
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Build the AppCommand list for a given render. Iterates `browserBindings`
|
|
76
|
+
* in array order (the empty-query palette renders in this order, by design
|
|
77
|
+
* — see #70 design log §empty-state ordering), drops hidden entries and
|
|
78
|
+
* those whose `when` predicate currently returns false, and resolves
|
|
79
|
+
* annotations to populate title / category / keywords.
|
|
80
|
+
*/
|
|
81
|
+
export const buildCommands = (ctx: BrowserCtx): readonly AppCommand[] => {
|
|
82
|
+
const out: AppCommand[] = []
|
|
83
|
+
for (const binding of browserBindings) {
|
|
84
|
+
const ann = annotations[binding.id]
|
|
85
|
+
if (ann?.hidden) continue
|
|
86
|
+
// Same gating the keymap dispatcher uses. Disabled bindings get
|
|
87
|
+
// hidden from the palette (per #70 Q5b — see #96 for the show-with-
|
|
88
|
+
// reason follow-up after the atom-driven migration).
|
|
89
|
+
if (binding.when && !binding.when(ctx)) continue
|
|
90
|
+
const cmd: AppCommand = {
|
|
91
|
+
id: binding.id,
|
|
92
|
+
title: ann?.title ?? binding.description,
|
|
93
|
+
...(ann?.category !== undefined && { category: ann.category }),
|
|
94
|
+
...(ann?.keywords !== undefined && { keywords: ann.keywords }),
|
|
95
|
+
...(binding.keys[0] !== undefined && { shortcut: binding.keys[0] }),
|
|
96
|
+
run: () => binding.run(ctx),
|
|
97
|
+
}
|
|
98
|
+
out.push(cmd)
|
|
99
|
+
}
|
|
100
|
+
for (const cmd of paletteOnlyCommands(ctx)) out.push(cmd)
|
|
101
|
+
return out
|
|
102
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Commands that exist *only* in the palette — no `browserBindings` entry.
|
|
3
|
+
*
|
|
4
|
+
* Empty in v1 by design (#70 Q6c). This file exists as scaffolding so
|
|
5
|
+
* future palette-only commands (#93 reveal-in-OS, #94 copy-path, …) have
|
|
6
|
+
* an obvious home that's already wired into `buildCommands.ts`.
|
|
7
|
+
*
|
|
8
|
+
* Commands here follow the same shape as keymap-derived ones: they take
|
|
9
|
+
* the per-render `BrowserCtx` so they can read selection state, mutate
|
|
10
|
+
* focus, surface notices, etc.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { BrowserCtx } from "../keymap/browser.ts"
|
|
14
|
+
import type { AppCommand } from "./types.ts"
|
|
15
|
+
|
|
16
|
+
export const paletteOnlyCommands = (_ctx: BrowserCtx): readonly AppCommand[] => []
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command palette scorer — ported from ghui's pre-May `commands.ts`.
|
|
3
|
+
*
|
|
4
|
+
* Tiered ranking: title-prefix → text-prefix → title-includes →
|
|
5
|
+
* text-includes → acronym → fuzzy-includes. Lower score wins. Ties broken
|
|
6
|
+
* by `browserBindings` array order (the `index` field) so the empty-query
|
|
7
|
+
* palette renders in the keymap's natural order. See #70 design log for
|
|
8
|
+
* why we didn't reach for `fuzzysort`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { AppCommand } from "./types.ts"
|
|
12
|
+
|
|
13
|
+
const normalize = (text: string): string =>
|
|
14
|
+
text
|
|
15
|
+
.toLowerCase()
|
|
16
|
+
.replace(/[^a-z0-9#]+/g, " ")
|
|
17
|
+
.trim()
|
|
18
|
+
|
|
19
|
+
const acronym = (text: string): string =>
|
|
20
|
+
normalize(text)
|
|
21
|
+
.split(" ")
|
|
22
|
+
.filter(Boolean)
|
|
23
|
+
.map((word) => word[0])
|
|
24
|
+
.join("")
|
|
25
|
+
|
|
26
|
+
const fuzzyIncludes = (text: string, query: string): boolean => {
|
|
27
|
+
let index = 0
|
|
28
|
+
for (const char of text) {
|
|
29
|
+
if (char === query[index]) index++
|
|
30
|
+
if (index >= query.length) return true
|
|
31
|
+
}
|
|
32
|
+
return query.length === 0
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const searchText = (command: AppCommand): string =>
|
|
36
|
+
normalize(
|
|
37
|
+
[command.title, command.category, command.shortcut, ...(command.keywords ?? [])]
|
|
38
|
+
.filter((s): s is string => Boolean(s))
|
|
39
|
+
.join(" "),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
/** Returns the tier (lower = better) or `null` if the query doesn't match. */
|
|
43
|
+
const score = (command: AppCommand, query: string): number | null => {
|
|
44
|
+
const q = normalize(query)
|
|
45
|
+
if (q.length === 0) return 0
|
|
46
|
+
const title = normalize(command.title)
|
|
47
|
+
const text = searchText(command)
|
|
48
|
+
if (title.startsWith(q)) return 0
|
|
49
|
+
if (text.startsWith(q)) return 1
|
|
50
|
+
if (title.includes(q)) return 2
|
|
51
|
+
if (text.includes(q)) return 3
|
|
52
|
+
if (acronym(command.title).startsWith(q)) return 4
|
|
53
|
+
if (fuzzyIncludes(text, q.replaceAll(" ", ""))) return 5
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Filter and sort commands against a query. With an empty query, every
|
|
59
|
+
* command tiers to 0 and the original index breaks ties — so the result is
|
|
60
|
+
* the input list in its original order (which v1 uses as `browserBindings`
|
|
61
|
+
* order). See #70 design log §empty-state ordering.
|
|
62
|
+
*/
|
|
63
|
+
export const filterCommands = (
|
|
64
|
+
commands: readonly AppCommand[],
|
|
65
|
+
query: string,
|
|
66
|
+
): readonly AppCommand[] =>
|
|
67
|
+
commands
|
|
68
|
+
.flatMap((command, index) => {
|
|
69
|
+
const tier = score(command, query)
|
|
70
|
+
return tier === null ? [] : [{ command, index, tier }]
|
|
71
|
+
})
|
|
72
|
+
.sort((a, b) => a.tier - b.tier || a.index - b.index)
|
|
73
|
+
.map(({ command }) => command)
|
|
74
|
+
|
|
75
|
+
export const clampSelectedIndex = (index: number, commands: readonly AppCommand[]): number => {
|
|
76
|
+
if (commands.length === 0) return 0
|
|
77
|
+
return Math.max(0, Math.min(commands.length - 1, index))
|
|
78
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Palette command shape.
|
|
3
|
+
*
|
|
4
|
+
* Commands are derived at render time from `browserBindings` via the
|
|
5
|
+
* annotation map (`buildCommands.ts`). The `run` field closes over the
|
|
6
|
+
* per-render `BrowserCtx` so dispatching a command is equivalent to firing
|
|
7
|
+
* the corresponding key binding — single source of truth for the action,
|
|
8
|
+
* two surfaces (keymap + palette) that can invoke it.
|
|
9
|
+
*
|
|
10
|
+
* See issue #70 (and its design-log comments) for the rejected alternatives
|
|
11
|
+
* — atom-driven registry (#92), fully hand-written list, fuzzysort dep, etc.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface AppCommand {
|
|
15
|
+
/** Stable id; matches the binding's id for keymap-derived commands. */
|
|
16
|
+
readonly id: string
|
|
17
|
+
/** Imperative-phrased label shown in the palette row. */
|
|
18
|
+
readonly title: string
|
|
19
|
+
/** Optional category for #91's grouped headers. Carried but unused in v1. */
|
|
20
|
+
readonly category?: string
|
|
21
|
+
/** Extra match terms (synonyms, alt phrasings). */
|
|
22
|
+
readonly keywords?: readonly string[]
|
|
23
|
+
/** Display-only shortcut hint (first key of the binding, if any). */
|
|
24
|
+
readonly shortcut?: string
|
|
25
|
+
readonly run: () => void
|
|
26
|
+
}
|