@carlesandres/house 0.4.4 → 0.4.6
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 +45 -2
- package/README.md +17 -7
- package/package.json +5 -1
- package/src/Browser.tsx +119 -29
- package/src/CommandPalette.tsx +4 -4
- package/src/Footer.tsx +7 -31
- package/src/Header.tsx +1 -1
- package/src/HelpOverlay.tsx +1 -1
- package/src/PromptRow.tsx +7 -5
- package/src/cli/argv.ts +86 -108
- package/src/config/load.ts +13 -21
- package/src/discovery/filter.ts +44 -12
- package/src/index.tsx +21 -11
- package/src/keymap/browser.ts +8 -1
- package/src/keymap/displayKey.ts +17 -0
- package/src/keymap/keymap.ts +3 -0
- package/src/theme/colors.ts +5 -37
- package/src/theme/types.ts +7 -8
- package/src/tips.ts +93 -0
package/src/PromptRow.tsx
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
* the command palette query input.
|
|
4
4
|
*
|
|
5
5
|
* Render-only: the parent owns query state and the focus/editing flag.
|
|
6
|
-
* The `> ` prefix
|
|
6
|
+
* The `> ` prefix renders in `primary` or `secondary` so it
|
|
7
7
|
* reads as chrome, not placeholder text — only the body span shifts color
|
|
8
|
-
* (
|
|
8
|
+
* (primary while editing, text when applied, textMuted as placeholder).
|
|
9
9
|
*
|
|
10
10
|
* Overflow: when editing, an overflowing body anchors its right edge with a
|
|
11
11
|
* leading `…` so the cursor stays on screen; otherwise it anchors the left
|
|
@@ -16,7 +16,7 @@ import { colors } from "./theme/colors.ts"
|
|
|
16
16
|
|
|
17
17
|
export interface PromptRowProps {
|
|
18
18
|
readonly query: string
|
|
19
|
-
/** True while the input is focused — shows a cursor and uses
|
|
19
|
+
/** True while the input is focused — shows a cursor and uses primary fg. */
|
|
20
20
|
readonly editing: boolean
|
|
21
21
|
/** Body fallback when !editing && query === "". Pass without the `> ` prefix. */
|
|
22
22
|
readonly placeholder?: string
|
|
@@ -31,7 +31,7 @@ export const PromptRow = ({ query, editing, placeholder = "", width }: PromptRow
|
|
|
31
31
|
const bodyBudget = Math.max(1, width - PREFIX.length)
|
|
32
32
|
|
|
33
33
|
const rawBody = editing ? `${query}${CURSOR}` : query.length > 0 ? query : placeholder
|
|
34
|
-
const bodyFg = editing ? colors.
|
|
34
|
+
const bodyFg = editing ? colors.primary : query.length > 0 ? colors.text : colors.textMuted
|
|
35
35
|
|
|
36
36
|
const body =
|
|
37
37
|
rawBody.length <= bodyBudget
|
|
@@ -40,9 +40,11 @@ export const PromptRow = ({ query, editing, placeholder = "", width }: PromptRow
|
|
|
40
40
|
? "…" + rawBody.slice(rawBody.length - bodyBudget + 1)
|
|
41
41
|
: rawBody.slice(0, bodyBudget - 1) + "…"
|
|
42
42
|
|
|
43
|
+
const prefixFg = editing ? colors.secondary : colors.primary
|
|
44
|
+
|
|
43
45
|
return (
|
|
44
46
|
<text wrapMode="none">
|
|
45
|
-
<span style={{ fg:
|
|
47
|
+
<span style={{ fg: prefixFg }}>{PREFIX}</span>
|
|
46
48
|
<span style={{ fg: bodyFg }}>{body}</span>
|
|
47
49
|
</text>
|
|
48
50
|
)
|
package/src/cli/argv.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Command } from "commander"
|
|
1
2
|
import { themeDefinitions } from "../theme/registry.ts"
|
|
2
3
|
|
|
3
4
|
export interface ParsedArgs {
|
|
@@ -29,9 +30,9 @@ export interface ParsedArgs {
|
|
|
29
30
|
readonly noUpdateCheck: boolean
|
|
30
31
|
/** True when `--no-mdx` was passed: exclude `.mdx` files from discovery. */
|
|
31
32
|
readonly noMdx: boolean
|
|
32
|
-
/**
|
|
33
|
-
*
|
|
34
|
-
readonly
|
|
33
|
+
/** Value of `--focus <mode>` (`sidebar`, `reader`, `filter`), or null.
|
|
34
|
+
* Validated by the boot layer. */
|
|
35
|
+
readonly focus: string | null
|
|
35
36
|
/** Raw value of `--show <list>`, or null if the flag wasn't passed.
|
|
36
37
|
* Comma-separated list of category names; the boot layer validates
|
|
37
38
|
* tokens against the known vocabulary (see `discovery/show.ts`).
|
|
@@ -39,6 +40,65 @@ export interface ParsedArgs {
|
|
|
39
40
|
readonly show: string | null
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
const createProgram = () =>
|
|
44
|
+
new Command()
|
|
45
|
+
.allowUnknownOption(true)
|
|
46
|
+
.allowExcessArguments(true)
|
|
47
|
+
.exitOverride()
|
|
48
|
+
.helpOption(false)
|
|
49
|
+
.option("--theme [id]")
|
|
50
|
+
.option("--tone [mode]")
|
|
51
|
+
.option("--width [N]")
|
|
52
|
+
.option("--sort [mode]")
|
|
53
|
+
.option("--serve")
|
|
54
|
+
.option("--port [N]")
|
|
55
|
+
.option("--config-path")
|
|
56
|
+
.option("--sidebar [mode]")
|
|
57
|
+
.option("--no-update-check")
|
|
58
|
+
.option("--no-mdx")
|
|
59
|
+
.option("--focus [mode]")
|
|
60
|
+
.option("--show [list]")
|
|
61
|
+
.option("-h, --help")
|
|
62
|
+
.option("-v, --version")
|
|
63
|
+
.argument("[path]")
|
|
64
|
+
|
|
65
|
+
const VALUE_FLAGS: ReadonlySet<string> = new Set([
|
|
66
|
+
"--theme",
|
|
67
|
+
"--tone",
|
|
68
|
+
"--width",
|
|
69
|
+
"--sort",
|
|
70
|
+
"--port",
|
|
71
|
+
"--sidebar",
|
|
72
|
+
"--focus",
|
|
73
|
+
"--show",
|
|
74
|
+
])
|
|
75
|
+
|
|
76
|
+
const BOOLEAN_FLAGS: ReadonlySet<string> = new Set([
|
|
77
|
+
"--serve",
|
|
78
|
+
"--config-path",
|
|
79
|
+
"--no-update-check",
|
|
80
|
+
"--no-mdx",
|
|
81
|
+
"--help",
|
|
82
|
+
"-h",
|
|
83
|
+
"--version",
|
|
84
|
+
"-v",
|
|
85
|
+
])
|
|
86
|
+
|
|
87
|
+
const findPathArg = (argv: readonly string[]): string | null => {
|
|
88
|
+
for (let i = 0; i < argv.length; i++) {
|
|
89
|
+
const arg = argv[i]!
|
|
90
|
+
if (VALUE_FLAGS.has(arg)) {
|
|
91
|
+
const next = argv[i + 1]
|
|
92
|
+
if (next !== undefined && !next.startsWith("-")) i++
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
if (BOOLEAN_FLAGS.has(arg)) continue
|
|
96
|
+
if (arg.startsWith("-")) continue
|
|
97
|
+
return arg
|
|
98
|
+
}
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
|
|
42
102
|
/**
|
|
43
103
|
* Minimal argv parser.
|
|
44
104
|
*
|
|
@@ -47,110 +107,28 @@ export interface ParsedArgs {
|
|
|
47
107
|
* without coupling the parser to it.
|
|
48
108
|
*/
|
|
49
109
|
export const parseArgv = (argv: readonly string[]): ParsedArgs => {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
let serve = false
|
|
56
|
-
let port: string | null = null
|
|
57
|
-
let help = false
|
|
58
|
-
let version = false
|
|
59
|
-
let configPath = false
|
|
60
|
-
let sidebar: string | null = null
|
|
61
|
-
let noUpdateCheck = false
|
|
62
|
-
let noMdx = false
|
|
63
|
-
let show: string | null = null
|
|
64
|
-
let startInFilter = false
|
|
65
|
-
|
|
66
|
-
for (let i = 0; i < argv.length; i++) {
|
|
67
|
-
const arg = argv[i]!
|
|
68
|
-
switch (arg) {
|
|
69
|
-
case "--theme":
|
|
70
|
-
theme = argv[i + 1] ?? null
|
|
71
|
-
i++
|
|
72
|
-
continue
|
|
73
|
-
case "--tone":
|
|
74
|
-
tone = argv[i + 1] ?? null
|
|
75
|
-
i++
|
|
76
|
-
continue
|
|
77
|
-
case "--width":
|
|
78
|
-
width = argv[i + 1] ?? null
|
|
79
|
-
i++
|
|
80
|
-
continue
|
|
81
|
-
case "--sort":
|
|
82
|
-
sort = argv[i + 1] ?? null
|
|
83
|
-
i++
|
|
84
|
-
continue
|
|
85
|
-
case "--serve":
|
|
86
|
-
serve = true
|
|
87
|
-
continue
|
|
88
|
-
case "--port":
|
|
89
|
-
port = argv[i + 1] ?? null
|
|
90
|
-
i++
|
|
91
|
-
continue
|
|
92
|
-
case "--help":
|
|
93
|
-
case "-h":
|
|
94
|
-
help = true
|
|
95
|
-
continue
|
|
96
|
-
case "--version":
|
|
97
|
-
case "-v":
|
|
98
|
-
version = true
|
|
99
|
-
continue
|
|
100
|
-
case "--config-path":
|
|
101
|
-
configPath = true
|
|
102
|
-
continue
|
|
103
|
-
case "--sidebar": {
|
|
104
|
-
// Don't swallow the following flag as the sidebar value.
|
|
105
|
-
// `--sidebar --width 80` should leave sidebar=null (the boot
|
|
106
|
-
// layer reports a missing value) without losing --width.
|
|
107
|
-
const next = argv[i + 1]
|
|
108
|
-
if (next !== undefined && !next.startsWith("-")) {
|
|
109
|
-
sidebar = next
|
|
110
|
-
i++
|
|
111
|
-
}
|
|
112
|
-
continue
|
|
113
|
-
}
|
|
114
|
-
case "--no-update-check":
|
|
115
|
-
noUpdateCheck = true
|
|
116
|
-
continue
|
|
117
|
-
case "--no-mdx":
|
|
118
|
-
noMdx = true
|
|
119
|
-
continue
|
|
120
|
-
case "--start-in-filter":
|
|
121
|
-
startInFilter = true
|
|
122
|
-
continue
|
|
123
|
-
case "--show":
|
|
124
|
-
// Always consume the following arg, even when it looks like
|
|
125
|
-
// a flag — `--show ""` is meaningful (explicit empty set),
|
|
126
|
-
// and the empty-string regression matters more than the
|
|
127
|
-
// near-miss case of someone forgetting the value. Boot
|
|
128
|
-
// validates tokens.
|
|
129
|
-
show = argv[i + 1] ?? null
|
|
130
|
-
i++
|
|
131
|
-
continue
|
|
132
|
-
}
|
|
133
|
-
if (path === null && !arg.startsWith("-")) {
|
|
134
|
-
path = arg
|
|
135
|
-
}
|
|
136
|
-
}
|
|
110
|
+
const program = createProgram()
|
|
111
|
+
program.parse([...argv], { from: "user" })
|
|
112
|
+
const opts = program.opts<Record<string, unknown>>()
|
|
113
|
+
const pathArg = findPathArg(argv)
|
|
114
|
+
const stringOrNull = (value: unknown): string | null => (typeof value === "string" ? value : null)
|
|
137
115
|
|
|
138
116
|
return {
|
|
139
|
-
path,
|
|
140
|
-
theme,
|
|
141
|
-
tone,
|
|
142
|
-
width,
|
|
143
|
-
sort,
|
|
144
|
-
serve,
|
|
145
|
-
port,
|
|
146
|
-
help,
|
|
147
|
-
version,
|
|
148
|
-
configPath,
|
|
149
|
-
sidebar,
|
|
150
|
-
noUpdateCheck,
|
|
151
|
-
noMdx,
|
|
152
|
-
show,
|
|
153
|
-
|
|
117
|
+
path: typeof pathArg === "string" ? pathArg : null,
|
|
118
|
+
theme: stringOrNull(opts["theme"]),
|
|
119
|
+
tone: stringOrNull(opts["tone"]),
|
|
120
|
+
width: stringOrNull(opts["width"]),
|
|
121
|
+
sort: stringOrNull(opts["sort"]),
|
|
122
|
+
serve: opts["serve"] === true,
|
|
123
|
+
port: stringOrNull(opts["port"]),
|
|
124
|
+
help: opts["help"] === true,
|
|
125
|
+
version: opts["version"] === true,
|
|
126
|
+
configPath: opts["configPath"] === true,
|
|
127
|
+
sidebar: stringOrNull(opts["sidebar"]),
|
|
128
|
+
noUpdateCheck: opts["noUpdateCheck"] === true,
|
|
129
|
+
noMdx: opts["mdx"] === false,
|
|
130
|
+
show: stringOrNull(opts["show"]),
|
|
131
|
+
focus: stringOrNull(opts["focus"]),
|
|
154
132
|
}
|
|
155
133
|
}
|
|
156
134
|
|
|
@@ -168,6 +146,7 @@ options:
|
|
|
168
146
|
hidden, gitignored. Use --show "" to clear.
|
|
169
147
|
--sort <mode> sidebar order: dirs-first (default) or files-first
|
|
170
148
|
--sidebar <m> initial sidebar visibility: auto (default), on, or off
|
|
149
|
+
--focus <m> startup focus: sidebar, reader, or filter (default: filter)
|
|
171
150
|
--serve serve the given file as HTML in the browser (skips TUI)
|
|
172
151
|
--port <N> port for --serve (default: OS-assigned)
|
|
173
152
|
-h, --help show this help and exit
|
|
@@ -175,10 +154,9 @@ options:
|
|
|
175
154
|
--config-path print path to the config file and exit
|
|
176
155
|
--no-update-check suppress the "newer version available" check (also via NO_UPDATE_NOTIFIER=1)
|
|
177
156
|
--no-mdx exclude .mdx files from discovery (default: included)
|
|
178
|
-
--start-in-filter open the sidebar filter prompt on launch
|
|
179
157
|
|
|
180
158
|
configuration:
|
|
181
159
|
file: $XDG_CONFIG_HOME/house/config.toml (default ~/.config/house/config.toml)
|
|
182
|
-
keys: theme, tone, mdx, show,
|
|
183
|
-
env: HOUSE_THEME, HOUSE_TONE, HOUSE_MDX, HOUSE_SHOW,
|
|
160
|
+
keys: theme, tone, mdx, show, focus
|
|
161
|
+
env: HOUSE_THEME, HOUSE_TONE, HOUSE_MDX, HOUSE_SHOW, HOUSE_FOCUS
|
|
184
162
|
precedence (high → low): flags → env → file → defaults`
|
package/src/config/load.ts
CHANGED
|
@@ -24,10 +24,9 @@ export interface HouseConfig {
|
|
|
24
24
|
* `src/discovery/show.ts` for the vocabulary. Empty array (the
|
|
25
25
|
* default) yields the conservative discovery set. */
|
|
26
26
|
readonly show: readonly ShowCategory[]
|
|
27
|
-
/**
|
|
28
|
-
* immediately.
|
|
29
|
-
|
|
30
|
-
readonly startInFilter: boolean
|
|
27
|
+
/** Startup pane/input target. `filter` opens the sidebar filter prompt and
|
|
28
|
+
* focuses it immediately. */
|
|
29
|
+
readonly focus: "sidebar" | "reader" | "filter"
|
|
31
30
|
}
|
|
32
31
|
|
|
33
32
|
export interface CliOverrides {
|
|
@@ -38,14 +37,14 @@ export interface CliOverrides {
|
|
|
38
37
|
* (no per-category merging — sets compose by replacement, like every
|
|
39
38
|
* other CLI override here). `--show ""` sets the empty set. */
|
|
40
39
|
readonly show: readonly ShowCategory[] | null
|
|
41
|
-
readonly
|
|
40
|
+
readonly focus: "sidebar" | "reader" | "filter" | null
|
|
42
41
|
}
|
|
43
42
|
|
|
44
43
|
const DEFAULT_THEME = "opencode"
|
|
45
44
|
const DEFAULT_TONE: "dark" | "light" = "dark"
|
|
46
45
|
const DEFAULT_MDX = true
|
|
47
46
|
const DEFAULT_SHOW = ""
|
|
48
|
-
const
|
|
47
|
+
const DEFAULT_FOCUS: "sidebar" | "reader" | "filter" = "filter"
|
|
49
48
|
|
|
50
49
|
const themeIds = themeDefinitions.map((t) => t.id)
|
|
51
50
|
|
|
@@ -55,13 +54,7 @@ const themeIds = themeDefinitions.map((t) => t.id)
|
|
|
55
54
|
* Used by `fileProvider` to warn about unrecognized keys (with a
|
|
56
55
|
* did-you-mean hint when one is close) while still loading the rest.
|
|
57
56
|
*/
|
|
58
|
-
const KNOWN_FILE_KEYS: ReadonlySet<string> = new Set([
|
|
59
|
-
"theme",
|
|
60
|
-
"tone",
|
|
61
|
-
"mdx",
|
|
62
|
-
"show",
|
|
63
|
-
"start_in_filter",
|
|
64
|
-
])
|
|
57
|
+
const KNOWN_FILE_KEYS: ReadonlySet<string> = new Set(["theme", "tone", "mdx", "show", "focus"])
|
|
65
58
|
|
|
66
59
|
const schema = Config.all({
|
|
67
60
|
theme: Config.schema(Schema.Literals(themeIds), "theme"),
|
|
@@ -75,7 +68,7 @@ const schema = Config.all({
|
|
|
75
68
|
// `"hidden,gitignored"`). Token-level validation happens in `loadConfig`
|
|
76
69
|
// so the error message can list valid categories at the field's path.
|
|
77
70
|
show: Config.schema(Schema.String, "show"),
|
|
78
|
-
|
|
71
|
+
focus: Config.schema(Schema.Literals(["sidebar", "reader", "filter"] as const), "focus"),
|
|
79
72
|
})
|
|
80
73
|
|
|
81
74
|
const defaultsProvider = (): ConfigProvider.ConfigProvider =>
|
|
@@ -84,7 +77,7 @@ const defaultsProvider = (): ConfigProvider.ConfigProvider =>
|
|
|
84
77
|
tone: DEFAULT_TONE,
|
|
85
78
|
mdx: String(DEFAULT_MDX),
|
|
86
79
|
show: DEFAULT_SHOW,
|
|
87
|
-
|
|
80
|
+
focus: DEFAULT_FOCUS,
|
|
88
81
|
})
|
|
89
82
|
|
|
90
83
|
/**
|
|
@@ -201,12 +194,12 @@ const envProvider = (env: Record<string, string | undefined>): ConfigProvider.Co
|
|
|
201
194
|
const tone = env["HOUSE_TONE"]
|
|
202
195
|
const mdx = env["HOUSE_MDX"]
|
|
203
196
|
const show = env["HOUSE_SHOW"]
|
|
204
|
-
const
|
|
197
|
+
const focus = env["HOUSE_FOCUS"]
|
|
205
198
|
if (theme !== undefined) entries.push(["theme", theme])
|
|
206
199
|
if (tone !== undefined) entries.push(["tone", tone])
|
|
207
200
|
if (mdx !== undefined) entries.push(["mdx", mdx])
|
|
208
201
|
if (show !== undefined) entries.push(["show", show])
|
|
209
|
-
if (
|
|
202
|
+
if (focus !== undefined) entries.push(["focus", focus])
|
|
210
203
|
return ConfigProvider.fromUnknown(Object.fromEntries(entries))
|
|
211
204
|
}
|
|
212
205
|
|
|
@@ -216,8 +209,7 @@ const cliProvider = (overrides: CliOverrides): ConfigProvider.ConfigProvider =>
|
|
|
216
209
|
if (overrides.tone !== null) entries.push(["tone", overrides.tone])
|
|
217
210
|
if (overrides.mdx !== null) entries.push(["mdx", String(overrides.mdx)])
|
|
218
211
|
if (overrides.show !== null) entries.push(["show", overrides.show.join(",")])
|
|
219
|
-
if (overrides.
|
|
220
|
-
entries.push(["start_in_filter", String(overrides.startInFilter)])
|
|
212
|
+
if (overrides.focus !== null) entries.push(["focus", overrides.focus])
|
|
221
213
|
return ConfigProvider.fromUnknown(Object.fromEntries(entries))
|
|
222
214
|
}
|
|
223
215
|
|
|
@@ -260,7 +252,7 @@ export const loadConfig = (
|
|
|
260
252
|
tone: null,
|
|
261
253
|
mdx: null,
|
|
262
254
|
show: null,
|
|
263
|
-
|
|
255
|
+
focus: null,
|
|
264
256
|
}
|
|
265
257
|
const onWarning = options.onWarning ?? ((msg) => process.stderr.write(`${msg}\n`))
|
|
266
258
|
const provider = cliProvider(cli).pipe(
|
|
@@ -288,7 +280,7 @@ export const loadConfig = (
|
|
|
288
280
|
tone: raw.tone,
|
|
289
281
|
mdx: raw.mdx === "true",
|
|
290
282
|
show: parsed.value,
|
|
291
|
-
|
|
283
|
+
focus: raw.focus,
|
|
292
284
|
})
|
|
293
285
|
}),
|
|
294
286
|
)
|
package/src/discovery/filter.ts
CHANGED
|
@@ -1,18 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Fuzzy filter for the sidebar.
|
|
3
3
|
*
|
|
4
|
-
* Matching: case-insensitive subsequence on
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* -
|
|
9
|
-
*
|
|
10
|
-
* - +1 otherwise
|
|
4
|
+
* Matching stays intentionally small and pure: case-insensitive subsequence on
|
|
5
|
+
* the filename and full relative path. Ranking prefers what users usually mean
|
|
6
|
+
* in a sidebar:
|
|
7
|
+
* - filename matches above folder-only matches
|
|
8
|
+
* - files in the current folder above equally good nested matches
|
|
9
|
+
* - shallower paths above deeper ones as a soft tie-break
|
|
11
10
|
*
|
|
12
|
-
*
|
|
13
|
-
* "obvious" match for short queries against a few hundred paths. A full
|
|
14
|
-
* fzf-style scorer (with bonuses for camelCase, separators, etc.) is
|
|
15
|
-
* deferred until the simple version proves insufficient.
|
|
11
|
+
* Empty query preserves discovery/tree order.
|
|
16
12
|
*/
|
|
17
13
|
|
|
18
14
|
import type { FileEntry } from "./walk.ts"
|
|
@@ -36,6 +32,42 @@ export const fuzzyScore = (query: string, target: string): number | null => {
|
|
|
36
32
|
return score
|
|
37
33
|
}
|
|
38
34
|
|
|
35
|
+
const splitPath = (relativePath: string): { fileName: string; depth: number } => {
|
|
36
|
+
const slash = relativePath.lastIndexOf("/")
|
|
37
|
+
return {
|
|
38
|
+
fileName: slash >= 0 ? relativePath.slice(slash + 1) : relativePath,
|
|
39
|
+
depth: slash >= 0 ? relativePath.split("/").length - 1 : 0,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const fileStem = (fileName: string): string => {
|
|
44
|
+
const dot = fileName.lastIndexOf(".")
|
|
45
|
+
return dot > 0 ? fileName.slice(0, dot) : fileName
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const rankFile = (query: string, file: FileEntry): number | null => {
|
|
49
|
+
const pathScore = fuzzyScore(query, file.relativePath)
|
|
50
|
+
if (pathScore === null) return null
|
|
51
|
+
|
|
52
|
+
const { fileName, depth } = splitPath(file.relativePath)
|
|
53
|
+
const q = query.toLowerCase()
|
|
54
|
+
const name = fileName.toLowerCase()
|
|
55
|
+
const stem = fileStem(fileName).toLowerCase()
|
|
56
|
+
const nameScore = fuzzyScore(query, fileName) ?? 0
|
|
57
|
+
|
|
58
|
+
let score = pathScore * 10
|
|
59
|
+
score += nameScore * 100
|
|
60
|
+
|
|
61
|
+
if (name === q || stem === q) score += 5_000
|
|
62
|
+
else if (name.startsWith(q) || stem.startsWith(q)) score += 2_000
|
|
63
|
+
else if (name.includes(q)) score += 1_000
|
|
64
|
+
|
|
65
|
+
if (depth === 0) score += 300
|
|
66
|
+
score -= depth * 10
|
|
67
|
+
|
|
68
|
+
return score
|
|
69
|
+
}
|
|
70
|
+
|
|
39
71
|
/**
|
|
40
72
|
* Filter and re-rank a file list by a query. Empty query returns the input
|
|
41
73
|
* unchanged (preserves the discovery sort order). Non-empty query keeps
|
|
@@ -47,7 +79,7 @@ export const filterFiles = (files: readonly FileEntry[], query: string): readonl
|
|
|
47
79
|
const scored: { file: FileEntry; score: number; index: number }[] = []
|
|
48
80
|
for (let i = 0; i < files.length; i++) {
|
|
49
81
|
const file = files[i]!
|
|
50
|
-
const score =
|
|
82
|
+
const score = rankFile(query, file)
|
|
51
83
|
if (score === null) continue
|
|
52
84
|
scored.push({ file, score, index: i })
|
|
53
85
|
}
|
package/src/index.tsx
CHANGED
|
@@ -16,7 +16,7 @@ import { RegistryProvider, useAtomSet, useAtomValue } from "@effect/atom-react"
|
|
|
16
16
|
import { Cause, Duration, Effect, Fiber, Stream } from "effect"
|
|
17
17
|
import { useEffect, useMemo, useRef, useState } from "react"
|
|
18
18
|
import pkg from "../package.json" with { type: "json" }
|
|
19
|
-
import { Browser } from "./Browser.tsx"
|
|
19
|
+
import { Browser, type StartupFocus } from "./Browser.tsx"
|
|
20
20
|
import { parseArgv, usage } from "./cli/argv.ts"
|
|
21
21
|
import { defaultConfigPath, formatConfigError, loadConfig } from "./config/load.ts"
|
|
22
22
|
import { parseShowList, SHOW_CATEGORIES, type ShowCategory } from "./discovery/show.ts"
|
|
@@ -69,7 +69,7 @@ interface DiscoverShellProps {
|
|
|
69
69
|
readonly mdx: boolean
|
|
70
70
|
readonly maxWidth: number | null
|
|
71
71
|
readonly sidebarMode: SidebarMode
|
|
72
|
-
readonly
|
|
72
|
+
readonly startupFocus: StartupFocus
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
const DiscoverShell = ({
|
|
@@ -79,7 +79,7 @@ const DiscoverShell = ({
|
|
|
79
79
|
mdx,
|
|
80
80
|
maxWidth,
|
|
81
81
|
sidebarMode,
|
|
82
|
-
|
|
82
|
+
startupFocus,
|
|
83
83
|
}: DiscoverShellProps) => {
|
|
84
84
|
const updateNotice = useUpdateNotice()
|
|
85
85
|
const [show, setShow] = useState<readonly ShowCategory[]>(initialShow)
|
|
@@ -134,7 +134,7 @@ const DiscoverShell = ({
|
|
|
134
134
|
maxWidth={maxWidth}
|
|
135
135
|
discoveryStatus={discoveryStatus}
|
|
136
136
|
sidebarMode={sidebarMode}
|
|
137
|
-
|
|
137
|
+
startupFocus={startupFocus}
|
|
138
138
|
updateNotice={updateNotice}
|
|
139
139
|
onToggleAll={() => {
|
|
140
140
|
// shift+a is the only place the categories are treated as a
|
|
@@ -267,15 +267,17 @@ if (import.meta.main) {
|
|
|
267
267
|
// `--show` replaces env/file when present (set semantics —
|
|
268
268
|
// no per-category merge across sources). `null` falls through.
|
|
269
269
|
show: cliShow,
|
|
270
|
-
|
|
271
|
-
|
|
270
|
+
focus:
|
|
271
|
+
args.focus === "sidebar" || args.focus === "reader" || args.focus === "filter"
|
|
272
|
+
? args.focus
|
|
273
|
+
: null,
|
|
272
274
|
},
|
|
273
275
|
}),
|
|
274
276
|
).catch((err: unknown) => {
|
|
275
277
|
console.error(`house: ${formatConfigError(err)}`)
|
|
276
278
|
process.exit(2)
|
|
277
279
|
})
|
|
278
|
-
const { theme: themeId, tone, mdx, show,
|
|
280
|
+
const { theme: themeId, tone, mdx, show, focus: startupFocus } = config
|
|
279
281
|
const themeDef = getThemeDefinition(themeId)
|
|
280
282
|
if (themeDef === undefined) {
|
|
281
283
|
// Unreachable: Config.schema validated themeId against themeDefinitions.
|
|
@@ -345,6 +347,14 @@ if (import.meta.main) {
|
|
|
345
347
|
}
|
|
346
348
|
sidebarMode = args.sidebar
|
|
347
349
|
}
|
|
350
|
+
if (args.focus !== null) {
|
|
351
|
+
if (args.focus !== "sidebar" && args.focus !== "reader" && args.focus !== "filter") {
|
|
352
|
+
console.error(
|
|
353
|
+
`house: --focus must be "sidebar", "reader", or "filter", got "${args.focus}"`,
|
|
354
|
+
)
|
|
355
|
+
process.exit(2)
|
|
356
|
+
}
|
|
357
|
+
}
|
|
348
358
|
await runTui({
|
|
349
359
|
target,
|
|
350
360
|
themeId,
|
|
@@ -354,7 +364,7 @@ if (import.meta.main) {
|
|
|
354
364
|
sort,
|
|
355
365
|
mdx,
|
|
356
366
|
sidebarMode,
|
|
357
|
-
|
|
367
|
+
startupFocus,
|
|
358
368
|
updateCheck: !args.noUpdateCheck,
|
|
359
369
|
})
|
|
360
370
|
}
|
|
@@ -369,7 +379,7 @@ interface TuiBootOptions {
|
|
|
369
379
|
readonly sort: SortOrder
|
|
370
380
|
readonly mdx: boolean
|
|
371
381
|
readonly sidebarMode: SidebarMode
|
|
372
|
-
readonly
|
|
382
|
+
readonly startupFocus: StartupFocus
|
|
373
383
|
/** Run the npm-registry probe and surface the "update available" notice.
|
|
374
384
|
* False suppresses both the toast and the quit-time print. */
|
|
375
385
|
readonly updateCheck: boolean
|
|
@@ -384,7 +394,7 @@ async function runTui({
|
|
|
384
394
|
sort,
|
|
385
395
|
mdx,
|
|
386
396
|
sidebarMode,
|
|
387
|
-
|
|
397
|
+
startupFocus,
|
|
388
398
|
updateCheck,
|
|
389
399
|
}: TuiBootOptions): Promise<void> {
|
|
390
400
|
let stats: Awaited<ReturnType<typeof stat>>
|
|
@@ -426,7 +436,7 @@ async function runTui({
|
|
|
426
436
|
mdx={mdx}
|
|
427
437
|
maxWidth={maxWidth}
|
|
428
438
|
sidebarMode={sidebarMode}
|
|
429
|
-
|
|
439
|
+
startupFocus={startupFocus}
|
|
430
440
|
/>
|
|
431
441
|
</RegistryProvider>,
|
|
432
442
|
)
|
package/src/keymap/browser.ts
CHANGED
|
@@ -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",
|
|
@@ -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/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,
|