@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.
- package/CHANGELOG.md +32 -2
- package/README.md +10 -8
- package/dist/bin.js +92 -0
- package/dist/index.js +10005 -0
- package/package.json +15 -3
- package/src/Browser.tsx +0 -1210
- package/src/CommandPalette.tsx +0 -214
- package/src/Footer.tsx +0 -258
- package/src/Header.tsx +0 -71
- package/src/PromptRow.tsx +0 -51
- package/src/Spinner.tsx +0 -39
- package/src/StatusIndicator.tsx +0 -55
- package/src/StatusPopover.tsx +0 -166
- package/src/brand.ts +0 -7
- package/src/cli/argv.ts +0 -179
- package/src/commands/buildCommands.ts +0 -98
- package/src/commands/paletteOnlyCommands.ts +0 -27
- package/src/commands/score.ts +0 -78
- package/src/commands/types.ts +0 -26
- package/src/config/load.ts +0 -381
- package/src/config/save.ts +0 -95
- package/src/discovery/filter.ts +0 -148
- package/src/discovery/show.ts +0 -48
- package/src/discovery/walk.ts +0 -209
- package/src/index.tsx +0 -464
- package/src/io/clipboard.ts +0 -53
- package/src/io/editor.ts +0 -162
- package/src/io/readFile.ts +0 -14
- package/src/keymap/browser.ts +0 -335
- package/src/keymap/displayKey.ts +0 -17
- package/src/keymap/keymap.ts +0 -95
- package/src/layout/resolve.ts +0 -52
- package/src/layout/sidebarEmptyState.ts +0 -7
- package/src/layout/sidebarRow.ts +0 -69
- package/src/markdown/frontmatter.ts +0 -62
- package/src/serve/css.ts +0 -120
- package/src/serve/openBrowser.ts +0 -19
- package/src/serve/render.ts +0 -56
- package/src/serve/server.ts +0 -166
- package/src/theme/atom.ts +0 -23
- package/src/theme/colors.ts +0 -86
- package/src/theme/loader.ts +0 -110
- package/src/theme/registry.ts +0 -12
- package/src/theme/resolve.ts +0 -168
- package/src/theme/themes/aura.json +0 -58
- package/src/theme/themes/ayu.json +0 -69
- package/src/theme/themes/carbonfox.json +0 -201
- package/src/theme/themes/catppuccin-frappe.json +0 -186
- package/src/theme/themes/catppuccin-macchiato.json +0 -186
- package/src/theme/themes/catppuccin.json +0 -212
- package/src/theme/themes/cobalt2.json +0 -181
- package/src/theme/themes/cursor.json +0 -202
- package/src/theme/themes/dracula.json +0 -172
- package/src/theme/themes/everforest.json +0 -194
- package/src/theme/themes/flexoki.json +0 -190
- package/src/theme/themes/github.json +0 -186
- package/src/theme/themes/gruvbox.json +0 -195
- package/src/theme/themes/kanagawa.json +0 -180
- package/src/theme/themes/lucent-orng.json +0 -186
- package/src/theme/themes/material.json +0 -188
- package/src/theme/themes/matrix.json +0 -180
- package/src/theme/themes/mercury.json +0 -198
- package/src/theme/themes/monokai.json +0 -174
- package/src/theme/themes/nightowl.json +0 -174
- package/src/theme/themes/nord.json +0 -176
- package/src/theme/themes/one-dark.json +0 -184
- package/src/theme/themes/opencode.json +0 -198
- package/src/theme/themes/orng.json +0 -202
- package/src/theme/themes/osaka-jade.json +0 -193
- package/src/theme/themes/palenight.json +0 -175
- package/src/theme/themes/rosepine.json +0 -187
- package/src/theme/themes/solarized.json +0 -180
- package/src/theme/themes/synthwave84.json +0 -179
- package/src/theme/themes/tokyonight.json +0 -196
- package/src/theme/themes/vercel.json +0 -198
- package/src/theme/themes/vesper.json +0 -171
- package/src/theme/themes/zenburn.json +0 -176
- package/src/theme/types.ts +0 -115
- package/src/tips.ts +0 -88
- package/src/ui/middleTruncate.ts +0 -27
- package/src/update/cache.ts +0 -77
- package/src/update/check.ts +0 -165
- package/src/update/compare.ts +0 -41
- package/src/update/notice.ts +0 -29
- package/src/update/runtime.ts +0 -48
- package/src/update/useUpdateNotice.ts +0 -24
package/src/io/clipboard.ts
DELETED
|
@@ -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
|
-
}
|
package/src/io/readFile.ts
DELETED
|
@@ -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
|
-
})
|
package/src/keymap/browser.ts
DELETED
|
@@ -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
|
-
]
|
package/src/keymap/displayKey.ts
DELETED
|
@@ -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
|
-
}
|
package/src/keymap/keymap.ts
DELETED
|
@@ -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
|
-
}
|