@carlesandres/house 0.4.13 → 0.5.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.
Files changed (86) hide show
  1. package/CHANGELOG.md +32 -2
  2. package/README.md +10 -8
  3. package/dist/bin.js +92 -0
  4. package/dist/index.js +10005 -0
  5. package/package.json +15 -3
  6. package/src/Browser.tsx +0 -1210
  7. package/src/CommandPalette.tsx +0 -214
  8. package/src/Footer.tsx +0 -258
  9. package/src/Header.tsx +0 -71
  10. package/src/PromptRow.tsx +0 -51
  11. package/src/Spinner.tsx +0 -39
  12. package/src/StatusIndicator.tsx +0 -55
  13. package/src/StatusPopover.tsx +0 -166
  14. package/src/brand.ts +0 -7
  15. package/src/cli/argv.ts +0 -179
  16. package/src/commands/buildCommands.ts +0 -98
  17. package/src/commands/paletteOnlyCommands.ts +0 -27
  18. package/src/commands/score.ts +0 -78
  19. package/src/commands/types.ts +0 -26
  20. package/src/config/load.ts +0 -381
  21. package/src/config/save.ts +0 -95
  22. package/src/discovery/filter.ts +0 -148
  23. package/src/discovery/show.ts +0 -48
  24. package/src/discovery/walk.ts +0 -209
  25. package/src/index.tsx +0 -464
  26. package/src/io/clipboard.ts +0 -53
  27. package/src/io/editor.ts +0 -162
  28. package/src/io/readFile.ts +0 -14
  29. package/src/keymap/browser.ts +0 -335
  30. package/src/keymap/displayKey.ts +0 -17
  31. package/src/keymap/keymap.ts +0 -95
  32. package/src/layout/resolve.ts +0 -52
  33. package/src/layout/sidebarEmptyState.ts +0 -7
  34. package/src/layout/sidebarRow.ts +0 -69
  35. package/src/markdown/frontmatter.ts +0 -62
  36. package/src/serve/css.ts +0 -120
  37. package/src/serve/openBrowser.ts +0 -19
  38. package/src/serve/render.ts +0 -56
  39. package/src/serve/server.ts +0 -166
  40. package/src/theme/atom.ts +0 -23
  41. package/src/theme/colors.ts +0 -86
  42. package/src/theme/loader.ts +0 -110
  43. package/src/theme/registry.ts +0 -12
  44. package/src/theme/resolve.ts +0 -168
  45. package/src/theme/themes/aura.json +0 -58
  46. package/src/theme/themes/ayu.json +0 -69
  47. package/src/theme/themes/carbonfox.json +0 -201
  48. package/src/theme/themes/catppuccin-frappe.json +0 -186
  49. package/src/theme/themes/catppuccin-macchiato.json +0 -186
  50. package/src/theme/themes/catppuccin.json +0 -212
  51. package/src/theme/themes/cobalt2.json +0 -181
  52. package/src/theme/themes/cursor.json +0 -202
  53. package/src/theme/themes/dracula.json +0 -172
  54. package/src/theme/themes/everforest.json +0 -194
  55. package/src/theme/themes/flexoki.json +0 -190
  56. package/src/theme/themes/github.json +0 -186
  57. package/src/theme/themes/gruvbox.json +0 -195
  58. package/src/theme/themes/kanagawa.json +0 -180
  59. package/src/theme/themes/lucent-orng.json +0 -186
  60. package/src/theme/themes/material.json +0 -188
  61. package/src/theme/themes/matrix.json +0 -180
  62. package/src/theme/themes/mercury.json +0 -198
  63. package/src/theme/themes/monokai.json +0 -174
  64. package/src/theme/themes/nightowl.json +0 -174
  65. package/src/theme/themes/nord.json +0 -176
  66. package/src/theme/themes/one-dark.json +0 -184
  67. package/src/theme/themes/opencode.json +0 -198
  68. package/src/theme/themes/orng.json +0 -202
  69. package/src/theme/themes/osaka-jade.json +0 -193
  70. package/src/theme/themes/palenight.json +0 -175
  71. package/src/theme/themes/rosepine.json +0 -187
  72. package/src/theme/themes/solarized.json +0 -180
  73. package/src/theme/themes/synthwave84.json +0 -179
  74. package/src/theme/themes/tokyonight.json +0 -196
  75. package/src/theme/themes/vercel.json +0 -198
  76. package/src/theme/themes/vesper.json +0 -171
  77. package/src/theme/themes/zenburn.json +0 -176
  78. package/src/theme/types.ts +0 -115
  79. package/src/tips.ts +0 -88
  80. package/src/ui/middleTruncate.ts +0 -27
  81. package/src/update/cache.ts +0 -77
  82. package/src/update/check.ts +0 -165
  83. package/src/update/compare.ts +0 -41
  84. package/src/update/notice.ts +0 -29
  85. package/src/update/runtime.ts +0 -48
  86. package/src/update/useUpdateNotice.ts +0 -24
@@ -1,53 +0,0 @@
1
- /**
2
- * Best-effort clipboard write for macOS/Linux.
3
- *
4
- * The selected tool is fed through stdin so large or multiline markdown can be
5
- * copied without shell quoting issues.
6
- */
7
-
8
- export interface ClipboardCommand {
9
- readonly cmd: string
10
- readonly args: readonly string[]
11
- }
12
-
13
- type WhichFn = (command: string) => string | null | undefined
14
-
15
- interface CopyToClipboardDeps {
16
- readonly platform?: NodeJS.Platform
17
- readonly which?: WhichFn
18
- readonly spawn?: (command: ClipboardCommand, text: string) => Promise<number>
19
- }
20
-
21
- export const resolveClipboardCommand = (
22
- platform: NodeJS.Platform,
23
- which: WhichFn,
24
- ): ClipboardCommand | null => {
25
- if (platform === "darwin") return { cmd: "pbcopy", args: [] }
26
- if (platform !== "linux") return null
27
- if (which("wl-copy")) return { cmd: "wl-copy", args: [] }
28
- if (which("xclip")) return { cmd: "xclip", args: ["-selection", "clipboard"] }
29
- return null
30
- }
31
-
32
- const spawnClipboardCommand = async (command: ClipboardCommand, text: string): Promise<number> => {
33
- const proc = Bun.spawn([command.cmd, ...command.args], {
34
- stdin: new Response(text),
35
- stdout: "ignore",
36
- stderr: "ignore",
37
- })
38
- return await proc.exited
39
- }
40
-
41
- export const copyTextToClipboard = async (
42
- text: string,
43
- {
44
- platform = process.platform,
45
- which = Bun.which.bind(Bun),
46
- spawn = spawnClipboardCommand,
47
- }: CopyToClipboardDeps = {},
48
- ): Promise<void> => {
49
- const command = resolveClipboardCommand(platform, which)
50
- if (!command) throw new Error("no clipboard tool found")
51
- const exitCode = await spawn(command, text)
52
- if (exitCode !== 0) throw new Error("clipboard command failed")
53
- }
package/src/io/editor.ts DELETED
@@ -1,162 +0,0 @@
1
- /**
2
- * Resolve `$VISUAL` / `$EDITOR` into a spawnable `{ cmd, args }`.
3
- *
4
- * The string is POSIX shell-split so users with values like
5
- * `code --wait` or `"/Applications/Sublime Text/subl" --wait` get the
6
- * expected argv. We deliberately do *not* shell out via `sh -c`: the
7
- * caller appends the file path as a separate argv element, which avoids
8
- * command-injection risk for paths containing shell metacharacters.
9
- *
10
- * Resolution order: `$VISUAL` → `$EDITOR` → `null`. No silent fallback to
11
- * `vi`; the caller surfaces a footer notice when neither is set.
12
- */
13
-
14
- export interface ResolvedEditor {
15
- readonly cmd: string
16
- readonly args: readonly string[]
17
- }
18
-
19
- /**
20
- * Split a command string the way `sh` would for the simple cases users
21
- * actually put in `$EDITOR`: single quotes (literal), double quotes
22
- * (with `\"`, `\\` escapes), backslash escapes outside quotes, and
23
- * whitespace separation. No variable expansion, no globbing, no command
24
- * substitution — by design. If the input is unbalanced (an unclosed
25
- * quote), the partial token is emitted as-is so the caller's spawn
26
- * surfaces a real error instead of us throwing.
27
- */
28
- export const splitEditorString = (input: string): string[] => {
29
- const tokens: string[] = []
30
- let buf = ""
31
- let inSingle = false
32
- let inDouble = false
33
- let pendingToken = false
34
-
35
- for (let i = 0; i < input.length; i++) {
36
- const ch = input[i]!
37
- if (inSingle) {
38
- if (ch === "'") inSingle = false
39
- else buf += ch
40
- continue
41
- }
42
- if (inDouble) {
43
- if (ch === "\\" && i + 1 < input.length) {
44
- const next = input[i + 1]!
45
- // In double quotes, `sh` only treats `\` as an escape before
46
- // `$`, `` ` ``, `"`, `\`, or newline. Otherwise the backslash
47
- // is literal. We collapse it for `"` and `\` (the cases users
48
- // hit with Windows-style paths in double quotes) and keep it
49
- // literal otherwise.
50
- if (next === '"' || next === "\\") {
51
- buf += next
52
- i++
53
- continue
54
- }
55
- }
56
- if (ch === '"') {
57
- inDouble = false
58
- continue
59
- }
60
- buf += ch
61
- continue
62
- }
63
- if (ch === "'") {
64
- inSingle = true
65
- pendingToken = true
66
- continue
67
- }
68
- if (ch === '"') {
69
- inDouble = true
70
- pendingToken = true
71
- continue
72
- }
73
- if (ch === "\\" && i + 1 < input.length) {
74
- buf += input[i + 1]!
75
- i++
76
- pendingToken = true
77
- continue
78
- }
79
- if (ch === " " || ch === "\t") {
80
- if (pendingToken) {
81
- tokens.push(buf)
82
- buf = ""
83
- pendingToken = false
84
- }
85
- continue
86
- }
87
- buf += ch
88
- pendingToken = true
89
- }
90
- if (pendingToken) tokens.push(buf)
91
- return tokens
92
- }
93
-
94
- /**
95
- * Pick the user's editor from env vars. Returns `null` when neither
96
- * `$VISUAL` nor `$EDITOR` is set to a non-empty, non-whitespace value.
97
- *
98
- * `env` is parameterised so tests don't need to mutate `process.env`.
99
- */
100
- export const resolveEditor = (
101
- env: Readonly<Record<string, string | undefined>>,
102
- ): ResolvedEditor | null => {
103
- const raw = pickEnv(env["VISUAL"]) ?? pickEnv(env["EDITOR"])
104
- if (raw == null) return null
105
- const parts = splitEditorString(raw)
106
- if (parts.length === 0) return null
107
- const [cmd, ...args] = parts
108
- if (!cmd) return null
109
- return { cmd, args }
110
- }
111
-
112
- const pickEnv = (value: string | undefined): string | null => {
113
- if (value == null) return null
114
- const trimmed = value.trim()
115
- return trimmed.length === 0 ? null : trimmed
116
- }
117
-
118
- /** Result of a launched editor session. Never throws — every failure is
119
- * surfaced as a tagged value so the caller can decide UX. */
120
- export type EditorRunResult =
121
- | { readonly ok: true; readonly exitCode: number }
122
- | {
123
- readonly ok: false
124
- readonly reason: "spawn-failed" | "non-zero"
125
- readonly detail?: string
126
- }
127
-
128
- export interface OpenInEditorOptions {
129
- readonly editor: ResolvedEditor
130
- readonly filePath: string
131
- }
132
-
133
- /**
134
- * Launch the resolved editor on `filePath`, inheriting stdio so the
135
- * editor takes over the TTY. Caller is responsible for suspending /
136
- * resuming the renderer around this call (see Browser.tsx).
137
- *
138
- * The file path is passed as a separate argv element — never interpolated
139
- * into a shell string — so paths with shell metacharacters can't be
140
- * misinterpreted.
141
- *
142
- * Windows is unsupported (see #129). The PATHEXT gap that breaks `.cmd`
143
- * shims (`code.cmd`, `nvim.cmd`) is tracked specifically in #128.
144
- */
145
- export const openInEditor = async ({
146
- editor,
147
- filePath,
148
- }: OpenInEditorOptions): Promise<EditorRunResult> => {
149
- const argv = [editor.cmd, ...editor.args, filePath]
150
- try {
151
- const proc = Bun.spawn(argv, {
152
- stdin: "inherit",
153
- stdout: "inherit",
154
- stderr: "inherit",
155
- })
156
- const exitCode = await proc.exited
157
- if (exitCode === 0) return { ok: true, exitCode }
158
- return { ok: false, reason: "non-zero", detail: String(exitCode) }
159
- } catch (err) {
160
- return { ok: false, reason: "spawn-failed", detail: String(err) }
161
- }
162
- }
@@ -1,14 +0,0 @@
1
- import { readFile } from "node:fs/promises"
2
- import { Data, Effect } from "effect"
3
-
4
- export class FileReadError extends Data.TaggedError("FileReadError")<{
5
- readonly path: string
6
- readonly cause: unknown
7
- }> {}
8
-
9
- /** Read a UTF-8 text file. Errors as `FileReadError`. */
10
- export const readFileText = (path: string): Effect.Effect<string, FileReadError> =>
11
- Effect.tryPromise({
12
- try: () => readFile(path, "utf8"),
13
- catch: (cause) => new FileReadError({ path, cause }),
14
- })
@@ -1,335 +0,0 @@
1
- /**
2
- * Browser keymap — the data backing `Browser.tsx`'s `useKeyboard` handler.
3
- */
4
-
5
- import type { FileEntry } from "../discovery/walk.ts"
6
- import type { KeyBinding } from "./keymap.ts"
7
-
8
- export type BrowserFocus = "sidebar" | "reader"
9
-
10
- export interface BrowserCtx {
11
- readonly files: readonly FileEntry[]
12
- /** True iff `files[selectedIndex]` resolves to an entry. The honest
13
- * predicate for File-group actions (`O`, `E`, `[`, `]`): with debounced
14
- * filter and sticky auto-select, `files.length > 0` can be true while
15
- * `selectedIndex` is invalid for the displayed list. See #115. */
16
- readonly hasSelected: boolean
17
- readonly focus: BrowserFocus
18
- /** User's sticky sidebar preference. Visibility is `shown || focus==="sidebar"`. */
19
- readonly sidebarShown: boolean
20
- readonly filterOpen: boolean
21
- readonly restoreFilterOnSidebarFocus: boolean
22
- /** Current applied/edited filter query. Used by `filter.clearOrOpen`'s
23
- * hint gate so the hint only appears when there is something to clear. */
24
- readonly filterQuery: string
25
- readonly paletteOpen: boolean
26
- readonly wrapEnabled: boolean
27
- readonly setFocus: (next: BrowserFocus | ((prev: BrowserFocus) => BrowserFocus)) => void
28
- readonly setSelectedIndex: (updater: (prev: number) => number) => void
29
- /** Toggle `shown` and adjust focus per DESIGN.md §7.1 (see s-behavior table). */
30
- readonly toggleShown: () => void
31
- readonly openFilter: () => void
32
- /** Clear the current filter query and open the filter modal in a single
33
- * action. Bound to `\` so users can reset a stranded zero-match filter
34
- * without first reopening with `/` and backspacing. */
35
- readonly clearAndOpenFilter: () => void
36
- readonly openPalette: () => void
37
- readonly toggleWrap: () => void
38
- readonly cycleTheme: (delta: 1 | -1) => void
39
- readonly toggleTone: () => void
40
- readonly quit: () => void
41
- /** Start (or retarget) the HTML preview server on the focused file. */
42
- readonly serveCurrent: () => void
43
- /** Suspend the TUI, hand the TTY to `$EDITOR`, resume and re-read on
44
- * exit. No-op when nothing is selected; gating is the binding's job. */
45
- readonly editCurrent: () => void
46
- /** Copy the currently-selected file's raw contents to the system
47
- * clipboard. No-op when nothing is selected; palette gating is the
48
- * primary availability control. */
49
- readonly copyCurrentContents: () => void
50
- /** Toggle hidden + gitignored discovery axes together (#145 — UI sugar
51
- * for `shift+a`; the underlying flags stay independent everywhere
52
- * else). Snapshots the current selected path so it can be restored
53
- * once the re-walk completes. */
54
- readonly toggleAll: () => void
55
- }
56
-
57
- /** Step size for shift+j/k and the space/b/page keys. Constant for v1; could
58
- * later be derived from the visible window height. */
59
- const JUMP = 8
60
-
61
- const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n))
62
- const lastIndex = (c: BrowserCtx) => Math.max(0, c.files.length - 1)
63
- const haveFiles = (c: BrowserCtx) => c.files.length > 0
64
- const hasSelected = (c: BrowserCtx) => c.hasSelected
65
- const stepBy = (c: BrowserCtx, delta: number) =>
66
- c.setSelectedIndex((i) => clamp(i + delta, 0, lastIndex(c)))
67
-
68
- const inSidebar = (c: BrowserCtx) => c.focus === "sidebar"
69
- const filterClosed = (c: BrowserCtx) => !c.filterOpen
70
- const paletteClosed = (c: BrowserCtx) => !c.paletteOpen
71
- const inputClosed = (c: BrowserCtx) => filterClosed(c) && paletteClosed(c)
72
- const inReader = (c: BrowserCtx) => c.focus === "reader"
73
- const inSidebarWithFiles = (c: BrowserCtx) => inSidebar(c) && haveFiles(c)
74
- /** Reader-only sibling-step gate: needs a current selection plus a sibling
75
- * to step to. `hasSelected` implies `files.length >= 1`, so `>= 2` is the
76
- * meaningful extra condition. */
77
- const inReaderWithSibling = (c: BrowserCtx) => inReader(c) && hasSelected(c) && c.files.length >= 2
78
-
79
- export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
80
- // Global
81
- {
82
- id: "quit",
83
- group: "Global",
84
- description: "Quit",
85
- hint: "quit",
86
- keys: ["q", "ctrl+c"],
87
- hintWhen: inputClosed,
88
- run: (c) => c.quit(),
89
- },
90
- {
91
- id: "focus.toggle",
92
- group: "Global",
93
- description: "Toggle focus (sidebar ↔ reader)",
94
- hint: "focus",
95
- keys: ["tab"],
96
- run: (c) => {
97
- if (c.focus === "reader" && c.restoreFilterOnSidebarFocus) {
98
- c.openFilter()
99
- } else {
100
- c.setFocus((f) => (f === "sidebar" ? "reader" : "sidebar"))
101
- }
102
- },
103
- },
104
- {
105
- id: "sidebar.toggle",
106
- group: "Global",
107
- description: "Toggle sidebar visibility",
108
- hint: "sidebar",
109
- keys: ["s"],
110
- hintWhen: inputClosed,
111
- run: (c) => c.toggleShown(),
112
- },
113
- {
114
- id: "filter.open",
115
- group: "Sidebar",
116
- description: "Filter files (fuzzy match on path)",
117
- hint: "filter",
118
- keys: ["/"],
119
- // Fires from anywhere except inside an already-open filter. `openFilter`
120
- // itself force-opens the sidebar and moves focus there, so the binding
121
- // no longer needs to gate on focus or sidebar visibility.
122
- when: filterClosed,
123
- run: (c) => c.openFilter(),
124
- },
125
- {
126
- id: "filter.clearOrOpen",
127
- group: "Sidebar",
128
- description: "Clear filter",
129
- hint: "clear",
130
- keys: ["ctrl+\\"],
131
- // Fires from anywhere outside the filter modal via the keymap.
132
- // Inside the filter modal it's intercepted directly in Browser.tsx
133
- // (the filter mode owns key handling), but the action is the same —
134
- // clear input, keep modal open. Palette branches short-circuit
135
- // dispatch in Browser.tsx, so we don't need to gate on them for
136
- // behavior; the `hintWhen` gate keeps the footer hint from showing
137
- // when there's nothing to clear or when a modal owns the input.
138
- // Chord chosen over single `\` so the binding works inside the
139
- // filter input without colliding with the typed character; ctrl+u
140
- // is deliberately left to its reader/sidebar half-page-up role to
141
- // avoid overload.
142
- when: filterClosed,
143
- hintWhen: (c) => filterClosed(c) && !c.paletteOpen && c.filterQuery.length > 0,
144
- run: (c) => c.clearAndOpenFilter(),
145
- },
146
- {
147
- id: "palette.open",
148
- group: "Global",
149
- description: "Command palette",
150
- hint: "palette",
151
- keys: ["ctrl+p"],
152
- // The filter modal handles this chord directly so the palette can open
153
- // while filter input owns the rest of the keyboard. This gate keeps the
154
- // normal dispatcher from reopening an already-open palette.
155
- when: paletteClosed,
156
- run: (c) => c.openPalette(),
157
- },
158
- {
159
- id: "discovery.toggleAll",
160
- group: "Sidebar",
161
- description: "Show / hide hidden and gitignored files",
162
- // No footer hint: shift+a is help/palette only. The footer hint row is
163
- // already at width capacity on narrow viewports, and the toggle isn't
164
- // a per-row action you reach for constantly.
165
- keys: ["shift+a"],
166
- // Selection-preservation logic lives in Browser.tsx — see the
167
- // `pendingSelectionPath` ref. The toggle itself is session-only and
168
- // does not write back to the TOML config.
169
- run: (c) => c.toggleAll(),
170
- },
171
- {
172
- id: "reader.wrap.toggle",
173
- group: "Global",
174
- description: "Toggle reader wrap",
175
- hint: "wrap",
176
- keys: ["w"],
177
- hintWhen: inputClosed,
178
- run: (c) => c.toggleWrap(),
179
- },
180
- {
181
- id: "theme.next",
182
- group: "Global",
183
- description: "Next theme",
184
- hint: "theme",
185
- keys: ["t"],
186
- hintWhen: inputClosed,
187
- run: (c) => c.cycleTheme(1),
188
- },
189
- {
190
- id: "theme.prev",
191
- group: "Global",
192
- description: "Previous theme",
193
- keys: ["shift+t"],
194
- run: (c) => c.cycleTheme(-1),
195
- },
196
- {
197
- id: "theme.toneToggle",
198
- group: "Global",
199
- description: "Toggle dark / light tone",
200
- keys: ["shift+l"],
201
- run: (c) => c.toggleTone(),
202
- },
203
-
204
- // Sidebar
205
- {
206
- id: "sidebar.down",
207
- group: "Sidebar",
208
- description: "Move selection down",
209
- keys: ["j", "down"],
210
- when: inSidebarWithFiles,
211
- run: (c) => stepBy(c, 1),
212
- },
213
- {
214
- id: "sidebar.up",
215
- group: "Sidebar",
216
- description: "Move selection up",
217
- keys: ["k", "up"],
218
- when: inSidebarWithFiles,
219
- run: (c) => stepBy(c, -1),
220
- },
221
- {
222
- id: "sidebar.jumpDown",
223
- group: "Sidebar",
224
- description: `Jump down ${JUMP}`,
225
- keys: ["shift+j"],
226
- when: inSidebarWithFiles,
227
- run: (c) => stepBy(c, JUMP),
228
- },
229
- {
230
- id: "sidebar.jumpUp",
231
- group: "Sidebar",
232
- description: `Jump up ${JUMP}`,
233
- keys: ["shift+k"],
234
- when: inSidebarWithFiles,
235
- run: (c) => stepBy(c, -JUMP),
236
- },
237
- {
238
- id: "sidebar.pageDown",
239
- group: "Sidebar",
240
- description: "Page down",
241
- keys: ["space", "pagedown", "ctrl+d"],
242
- when: inSidebarWithFiles,
243
- run: (c) => stepBy(c, JUMP),
244
- },
245
- {
246
- id: "sidebar.pageUp",
247
- group: "Sidebar",
248
- description: "Page up",
249
- keys: ["b", "pageup", "ctrl+u"],
250
- when: inSidebarWithFiles,
251
- run: (c) => stepBy(c, -JUMP),
252
- },
253
- {
254
- id: "sidebar.top",
255
- group: "Sidebar",
256
- description: "Jump to first file",
257
- keys: ["g"],
258
- when: inSidebarWithFiles,
259
- run: (c) => c.setSelectedIndex(() => 0),
260
- },
261
- {
262
- id: "sidebar.bottom",
263
- group: "Sidebar",
264
- description: "Jump to last file",
265
- keys: ["shift+g"],
266
- when: inSidebarWithFiles,
267
- run: (c) => c.setSelectedIndex(() => lastIndex(c)),
268
- },
269
- {
270
- id: "sidebar.open",
271
- group: "Sidebar",
272
- description: "Open file (focus reader)",
273
- hint: "open",
274
- keys: ["return", "right", "l"],
275
- when: inSidebar,
276
- hintWhen: (c) => inSidebar(c) && hasSelected(c),
277
- run: (c) => c.setFocus("reader"),
278
- },
279
-
280
- // File — actions on the currently-selected file. Gated on `hasSelected`
281
- // (per #115) so they're available exactly when the reader has something
282
- // to act on, regardless of focus or filter state.
283
- {
284
- id: "serve.current",
285
- group: "File",
286
- description: "Open current file in browser as HTML",
287
- hint: "html",
288
- keys: ["shift+o"],
289
- when: hasSelected,
290
- hintWhen: (c) => inputClosed(c) && hasSelected(c),
291
- run: (c) => c.serveCurrent(),
292
- },
293
- {
294
- id: "file.edit",
295
- group: "File",
296
- description: "Open current file in $EDITOR",
297
- hint: "edit",
298
- keys: ["shift+e"],
299
- when: hasSelected,
300
- hintWhen: (c) => inputClosed(c) && hasSelected(c),
301
- run: (c) => c.editCurrent(),
302
- },
303
- {
304
- // `[`/`]` keep the `inReader` clause so they're only typed from the
305
- // reader (sidebar uses j/k for stepping). The File-group predicate is
306
- // additive: needs a selection *and* a sibling to step to.
307
- id: "reader.prevFile",
308
- group: "File",
309
- description: "Prev file",
310
- hint: "prev",
311
- keys: ["["],
312
- when: inReaderWithSibling,
313
- run: (c) => stepBy(c, -1),
314
- },
315
- {
316
- id: "reader.nextFile",
317
- group: "File",
318
- description: "Next file",
319
- hint: "next",
320
- keys: ["]"],
321
- when: inReaderWithSibling,
322
- run: (c) => stepBy(c, 1),
323
- },
324
-
325
- // Reader
326
- {
327
- id: "reader.back",
328
- group: "Reader",
329
- description: "Back to sidebar",
330
- hint: "back",
331
- keys: ["escape", "left", "h"],
332
- when: inReader,
333
- run: (c) => c.setFocus("sidebar"),
334
- },
335
- ]
@@ -1,17 +0,0 @@
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
- }
@@ -1,95 +0,0 @@
1
- /**
2
- * Tiny declarative keymap.
3
- *
4
- * Bindings are values: `{ id, description, keys, when?, run }`. A pure
5
- * `dispatch` looks up the first matching, enabled binding for a key event
6
- * and runs it. The same array also drives footer hints and command-palette
7
- * derivation, so there is one source of truth.
8
- *
9
- * Deliberately *not* a port of ghui's `@ghui/keymap`: no chord sequences,
10
- * no count prefixes, no scope contramaps. See DESIGN.md §12 for the full
11
- * trigger that would warrant adopting that machinery.
12
- */
13
-
14
- /** The shape of a parsed key event we care about. `KeyEvent` from opentui satisfies this. */
15
- export interface KeyMatch {
16
- readonly name: string
17
- readonly shift?: boolean
18
- readonly ctrl?: boolean
19
- readonly meta?: boolean
20
- }
21
-
22
- export interface KeyBinding<C> {
23
- /** Stable id; used for tests and (later) command-palette routing. */
24
- readonly id: string
25
- /** Human-readable summary used by docs and palette command titles. */
26
- readonly description: string
27
- /** Key chords that trigger this binding, e.g. ["j", "down"], ["shift+k"], ["ctrl+c"]. */
28
- readonly keys: readonly string[]
29
- /** Optional grouping label for derived UI/documentation surfaces. */
30
- readonly group?: string
31
- /** Optional compact label for the footer hint row, e.g. "help" for `?:help`.
32
- * Bindings without a hint are excluded from the footer. Order in the bindings
33
- * array drives overflow priority (later bindings get truncated first). */
34
- readonly hint?: string
35
- /** If present, the binding only fires when this returns true. */
36
- readonly when?: (ctx: C) => boolean
37
- /** If present, the footer hint is shown only when this returns true. When
38
- * absent, hint visibility falls back to `when`. Use when a binding's
39
- * dispatch gate is broader than the situations where the hint is useful
40
- * (e.g. "clear filter" fires from anywhere but only deserves a hint when
41
- * there is actually a filter to clear). */
42
- readonly hintWhen?: (ctx: C) => boolean
43
- readonly run: (ctx: C) => void
44
- }
45
-
46
- interface ParsedChord {
47
- readonly key: string
48
- readonly shift: boolean
49
- readonly ctrl: boolean
50
- readonly meta: boolean
51
- }
52
-
53
- const parseChord = (raw: string): ParsedChord => {
54
- const parts = raw.toLowerCase().split("+")
55
- const key = parts.at(-1) ?? ""
56
- return {
57
- key,
58
- shift: parts.includes("shift"),
59
- ctrl: parts.includes("ctrl"),
60
- meta: parts.includes("meta"),
61
- }
62
- }
63
-
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
- }
68
- if (chord.key !== key.name) return false
69
- if (chord.shift !== Boolean(key.shift)) return false
70
- if (chord.ctrl !== Boolean(key.ctrl)) return false
71
- if (chord.meta !== Boolean(key.meta)) return false
72
- return true
73
- }
74
-
75
- /**
76
- * Return the first binding that (a) is enabled for `ctx` and (b) has a key
77
- * that matches `key`. Runs the binding's action and returns it. Returns null
78
- * if nothing matched.
79
- */
80
- export const dispatch = <C>(
81
- bindings: readonly KeyBinding<C>[],
82
- ctx: C,
83
- key: KeyMatch,
84
- ): KeyBinding<C> | null => {
85
- for (const binding of bindings) {
86
- if (binding.when && !binding.when(ctx)) continue
87
- for (const raw of binding.keys) {
88
- if (chordMatches(parseChord(raw), key)) {
89
- binding.run(ctx)
90
- return binding
91
- }
92
- }
93
- }
94
- return null
95
- }