@carlesandres/house 0.4.5 → 0.4.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -4
- package/README.md +11 -8
- package/package.json +2 -1
- package/src/Browser.tsx +208 -56
- package/src/CommandPalette.tsx +4 -4
- package/src/Footer.tsx +33 -31
- package/src/Header.tsx +1 -1
- package/src/HelpOverlay.tsx +1 -1
- package/src/PromptRow.tsx +7 -5
- package/src/Spinner.tsx +39 -0
- package/src/cli/argv.ts +92 -108
- package/src/config/load.ts +29 -15
- package/src/discovery/filter.ts +44 -12
- package/src/index.tsx +61 -12
- package/src/keymap/browser.ts +11 -4
- package/src/keymap/displayKey.ts +17 -0
- package/src/keymap/keymap.ts +3 -0
- package/src/serve/server.ts +1 -1
- package/src/theme/colors.ts +5 -37
- package/src/theme/types.ts +7 -8
- package/src/tips.ts +93 -0
package/src/Footer.tsx
CHANGED
|
@@ -21,7 +21,10 @@
|
|
|
21
21
|
* require a real cell-width counter (e.g. East Asian Width).
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
+
import type React from "react"
|
|
24
25
|
import type { KeyBinding } from "./keymap/keymap.ts"
|
|
26
|
+
import { displayKey } from "./keymap/displayKey.ts"
|
|
27
|
+
import { Spinner } from "./Spinner.tsx"
|
|
25
28
|
import { colors } from "./theme/colors.ts"
|
|
26
29
|
|
|
27
30
|
/** Rows the Footer occupies. Importers use it for layout math so a future
|
|
@@ -42,35 +45,16 @@ export interface FooterProps<C> {
|
|
|
42
45
|
* while the filter input is open (the sidebar already shows the query) or
|
|
43
46
|
* when no filter is applied. */
|
|
44
47
|
readonly filterQuery?: string | null
|
|
48
|
+
/** Test seam: override spinner tick speed so tests don't sleep on the full
|
|
49
|
+
* production interval. Ignored when discoveryStatus is null. */
|
|
50
|
+
readonly discoverySpinnerIntervalMs?: number
|
|
51
|
+
readonly discoverySpinnerInitialFrameIndex?: number
|
|
52
|
+
/** Test seam: deterministic footer spinner driver. */
|
|
53
|
+
readonly discoverySpinnerRegisterTick?: ((tick: () => void) => void) | null
|
|
45
54
|
}
|
|
46
55
|
|
|
47
56
|
const HINT_SEPARATOR = " "
|
|
48
57
|
|
|
49
|
-
/** Display form for the first key of a binding. Picks the first chord and
|
|
50
|
-
* rewrites a few names to terminal-friendly shorthands.
|
|
51
|
-
*
|
|
52
|
-
* Footer policy: only the first key is shown, even when a binding has
|
|
53
|
-
* aliases (e.g. `sidebar.open` accepts `return`/`right`/`l`). The footer
|
|
54
|
-
* is a narrow real-estate budget, and listing every alias would push out
|
|
55
|
-
* other bindings on tight viewports. The full alias list lives in the
|
|
56
|
-
* help overlay (`?`). */
|
|
57
|
-
const displayKey = (raw: string): string => {
|
|
58
|
-
switch (raw) {
|
|
59
|
-
case "return":
|
|
60
|
-
return "↵"
|
|
61
|
-
case "escape":
|
|
62
|
-
return "esc"
|
|
63
|
-
case "space":
|
|
64
|
-
return "␣"
|
|
65
|
-
case "pageup":
|
|
66
|
-
return "pgup"
|
|
67
|
-
case "pagedown":
|
|
68
|
-
return "pgdn"
|
|
69
|
-
default:
|
|
70
|
-
return raw
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
58
|
/** Hint row entries. `key === null` is a standalone chip (e.g. the filter
|
|
75
59
|
* chip) and renders as muted text without the key/label split. */
|
|
76
60
|
interface Hint {
|
|
@@ -116,6 +100,9 @@ export const Footer = <C,>({
|
|
|
116
100
|
notice,
|
|
117
101
|
discoveryStatus,
|
|
118
102
|
filterQuery,
|
|
103
|
+
discoverySpinnerIntervalMs,
|
|
104
|
+
discoverySpinnerInitialFrameIndex,
|
|
105
|
+
discoverySpinnerRegisterTick,
|
|
119
106
|
}: FooterProps<C>) => {
|
|
120
107
|
const usableWidth = Math.max(0, width - 2) // 1-cell horizontal padding each side
|
|
121
108
|
|
|
@@ -126,7 +113,7 @@ export const Footer = <C,>({
|
|
|
126
113
|
flexDirection: "row",
|
|
127
114
|
paddingLeft: 1,
|
|
128
115
|
paddingRight: 1,
|
|
129
|
-
backgroundColor: colors.
|
|
116
|
+
backgroundColor: colors.backgroundPanel,
|
|
130
117
|
} as const
|
|
131
118
|
|
|
132
119
|
const hints: Hint[] = []
|
|
@@ -183,7 +170,7 @@ export const Footer = <C,>({
|
|
|
183
170
|
if (h.key === null) {
|
|
184
171
|
return [
|
|
185
172
|
...sep,
|
|
186
|
-
<text key={`l${i}`} content={h.label} wrapMode="none" style={{ fg: colors.
|
|
173
|
+
<text key={`l${i}`} content={h.label} wrapMode="none" style={{ fg: colors.secondary }} />,
|
|
187
174
|
]
|
|
188
175
|
}
|
|
189
176
|
return [
|
|
@@ -198,20 +185,35 @@ export const Footer = <C,>({
|
|
|
198
185
|
]
|
|
199
186
|
})
|
|
200
187
|
|
|
201
|
-
// Priority: notice > (status + hints).
|
|
202
|
-
//
|
|
188
|
+
// Priority: notice > (status + hints). Discovery status uses the secondary
|
|
189
|
+
// token: active metadata, but not a warning/error event.
|
|
203
190
|
if (noticeContent !== null) {
|
|
204
191
|
return (
|
|
205
192
|
<box style={rowStyle}>
|
|
206
|
-
<text content={noticeContent} wrapMode="none" style={{ fg: colors.
|
|
193
|
+
<text content={noticeContent} wrapMode="none" style={{ fg: colors.primary }} />
|
|
207
194
|
</box>
|
|
208
195
|
)
|
|
209
196
|
}
|
|
210
197
|
|
|
211
198
|
if (status !== null) {
|
|
199
|
+
const spinnerProps = {
|
|
200
|
+
fg: colors.secondary,
|
|
201
|
+
...(discoverySpinnerIntervalMs === undefined
|
|
202
|
+
? {}
|
|
203
|
+
: { intervalMs: discoverySpinnerIntervalMs }),
|
|
204
|
+
...(discoverySpinnerInitialFrameIndex === undefined
|
|
205
|
+
? {}
|
|
206
|
+
: { initialFrameIndex: discoverySpinnerInitialFrameIndex }),
|
|
207
|
+
...(discoverySpinnerRegisterTick === undefined
|
|
208
|
+
? {}
|
|
209
|
+
: { registerTick: discoverySpinnerRegisterTick }),
|
|
210
|
+
} satisfies React.ComponentProps<typeof Spinner>
|
|
211
|
+
|
|
212
212
|
return (
|
|
213
213
|
<box style={rowStyle}>
|
|
214
|
-
<
|
|
214
|
+
<Spinner {...spinnerProps} />
|
|
215
|
+
<text content=" " wrapMode="none" style={{ fg: colors.textMuted }} />
|
|
216
|
+
<text content={statusContent} wrapMode="none" style={{ fg: colors.secondary }} />
|
|
215
217
|
<text content={STATUS_SEPARATOR} wrapMode="none" style={{ fg: colors.textMuted }} />
|
|
216
218
|
{renderHints()}
|
|
217
219
|
</box>
|
package/src/Header.tsx
CHANGED
|
@@ -58,7 +58,7 @@ export const Header = ({ width, currentFile, version = pkg.version }: HeaderProp
|
|
|
58
58
|
justifyContent: "space-between",
|
|
59
59
|
paddingLeft: 1,
|
|
60
60
|
paddingRight: 1,
|
|
61
|
-
backgroundColor: colors.
|
|
61
|
+
backgroundColor: colors.backgroundPanel,
|
|
62
62
|
}}
|
|
63
63
|
>
|
|
64
64
|
<text content={left} wrapMode="none" style={{ fg: colors.text }} />
|
package/src/HelpOverlay.tsx
CHANGED
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/Spinner.tsx
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react"
|
|
2
|
+
import { colors } from "./theme/colors.ts"
|
|
3
|
+
|
|
4
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const
|
|
5
|
+
const FRAME_COUNT = FRAMES.length
|
|
6
|
+
const normalizeFrameIndex = (index: number) => ((index % FRAME_COUNT) + FRAME_COUNT) % FRAME_COUNT
|
|
7
|
+
|
|
8
|
+
export interface SpinnerProps {
|
|
9
|
+
readonly fg?: string
|
|
10
|
+
readonly intervalMs?: number
|
|
11
|
+
readonly initialFrameIndex?: number
|
|
12
|
+
/** Test seam: deterministic driver for frame advancement. When present,
|
|
13
|
+
* Spinner registers its tick callback here instead of starting an interval. */
|
|
14
|
+
readonly registerTick?: ((tick: () => void) => void) | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const Spinner = ({
|
|
18
|
+
fg = colors.textMuted,
|
|
19
|
+
intervalMs = 100,
|
|
20
|
+
initialFrameIndex = 0,
|
|
21
|
+
registerTick = null,
|
|
22
|
+
}: SpinnerProps) => {
|
|
23
|
+
const [index, setIndex] = useState(() => normalizeFrameIndex(initialFrameIndex))
|
|
24
|
+
const tickRef = useRef<() => void>(() => undefined)
|
|
25
|
+
tickRef.current = () => setIndex((prev) => (prev + 1) % FRAME_COUNT)
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (registerTick) {
|
|
29
|
+
registerTick(() => tickRef.current())
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
const id = setInterval(() => {
|
|
33
|
+
tickRef.current()
|
|
34
|
+
}, intervalMs)
|
|
35
|
+
return () => clearInterval(id)
|
|
36
|
+
}, [intervalMs, registerTick])
|
|
37
|
+
|
|
38
|
+
return <text content={FRAMES[index] ?? FRAMES[0]} wrapMode="none" style={{ fg }} />
|
|
39
|
+
}
|
package/src/cli/argv.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { Command } from "commander"
|
|
1
2
|
import { themeDefinitions } from "../theme/registry.ts"
|
|
2
3
|
|
|
3
4
|
export interface ParsedArgs {
|
|
4
5
|
/** First positional argument, or null if none was given. */
|
|
5
6
|
readonly path: string | null
|
|
7
|
+
/** Value of `--root <dir>`, or null. */
|
|
8
|
+
readonly root: string | null
|
|
6
9
|
/** Value of `--theme <id>`, or null. Validated by the boot layer against the registry. */
|
|
7
10
|
readonly theme: string | null
|
|
8
11
|
/** Value of `--tone dark|light`, or null. Validated by the boot layer. */
|
|
@@ -29,9 +32,9 @@ export interface ParsedArgs {
|
|
|
29
32
|
readonly noUpdateCheck: boolean
|
|
30
33
|
/** True when `--no-mdx` was passed: exclude `.mdx` files from discovery. */
|
|
31
34
|
readonly noMdx: boolean
|
|
32
|
-
/**
|
|
33
|
-
*
|
|
34
|
-
readonly
|
|
35
|
+
/** Value of `--focus <mode>` (`sidebar`, `reader`, `filter`), or null.
|
|
36
|
+
* Validated by the boot layer. */
|
|
37
|
+
readonly focus: string | null
|
|
35
38
|
/** Raw value of `--show <list>`, or null if the flag wasn't passed.
|
|
36
39
|
* Comma-separated list of category names; the boot layer validates
|
|
37
40
|
* tokens against the known vocabulary (see `discovery/show.ts`).
|
|
@@ -39,6 +42,67 @@ export interface ParsedArgs {
|
|
|
39
42
|
readonly show: string | null
|
|
40
43
|
}
|
|
41
44
|
|
|
45
|
+
const createProgram = () =>
|
|
46
|
+
new Command()
|
|
47
|
+
.allowUnknownOption(true)
|
|
48
|
+
.allowExcessArguments(true)
|
|
49
|
+
.exitOverride()
|
|
50
|
+
.helpOption(false)
|
|
51
|
+
.option("--theme [id]")
|
|
52
|
+
.option("--tone [mode]")
|
|
53
|
+
.option("--width [N]")
|
|
54
|
+
.option("--sort [mode]")
|
|
55
|
+
.option("--serve")
|
|
56
|
+
.option("--port [N]")
|
|
57
|
+
.option("--config-path")
|
|
58
|
+
.option("--sidebar [mode]")
|
|
59
|
+
.option("--no-update-check")
|
|
60
|
+
.option("--no-mdx")
|
|
61
|
+
.option("--focus [mode]")
|
|
62
|
+
.option("--show [list]")
|
|
63
|
+
.option("--root [dir]")
|
|
64
|
+
.option("-h, --help")
|
|
65
|
+
.option("-v, --version")
|
|
66
|
+
.argument("[path]")
|
|
67
|
+
|
|
68
|
+
const VALUE_FLAGS: ReadonlySet<string> = new Set([
|
|
69
|
+
"--theme",
|
|
70
|
+
"--tone",
|
|
71
|
+
"--width",
|
|
72
|
+
"--sort",
|
|
73
|
+
"--port",
|
|
74
|
+
"--sidebar",
|
|
75
|
+
"--focus",
|
|
76
|
+
"--show",
|
|
77
|
+
"--root",
|
|
78
|
+
])
|
|
79
|
+
|
|
80
|
+
const BOOLEAN_FLAGS: ReadonlySet<string> = new Set([
|
|
81
|
+
"--serve",
|
|
82
|
+
"--config-path",
|
|
83
|
+
"--no-update-check",
|
|
84
|
+
"--no-mdx",
|
|
85
|
+
"--help",
|
|
86
|
+
"-h",
|
|
87
|
+
"--version",
|
|
88
|
+
"-v",
|
|
89
|
+
])
|
|
90
|
+
|
|
91
|
+
const findPathArg = (argv: readonly string[]): string | null => {
|
|
92
|
+
for (let i = 0; i < argv.length; i++) {
|
|
93
|
+
const arg = argv[i]!
|
|
94
|
+
if (VALUE_FLAGS.has(arg)) {
|
|
95
|
+
const next = argv[i + 1]
|
|
96
|
+
if (next !== undefined && !next.startsWith("-")) i++
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
if (BOOLEAN_FLAGS.has(arg)) continue
|
|
100
|
+
if (arg.startsWith("-")) continue
|
|
101
|
+
return arg
|
|
102
|
+
}
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
|
|
42
106
|
/**
|
|
43
107
|
* Minimal argv parser.
|
|
44
108
|
*
|
|
@@ -47,110 +111,29 @@ export interface ParsedArgs {
|
|
|
47
111
|
* without coupling the parser to it.
|
|
48
112
|
*/
|
|
49
113
|
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
|
-
}
|
|
114
|
+
const program = createProgram()
|
|
115
|
+
program.parse([...argv], { from: "user" })
|
|
116
|
+
const opts = program.opts<Record<string, unknown>>()
|
|
117
|
+
const pathArg = findPathArg(argv)
|
|
118
|
+
const stringOrNull = (value: unknown): string | null => (typeof value === "string" ? value : null)
|
|
137
119
|
|
|
138
120
|
return {
|
|
139
|
-
path,
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
121
|
+
path: typeof pathArg === "string" ? pathArg : null,
|
|
122
|
+
root: stringOrNull(opts["root"]),
|
|
123
|
+
theme: stringOrNull(opts["theme"]),
|
|
124
|
+
tone: stringOrNull(opts["tone"]),
|
|
125
|
+
width: stringOrNull(opts["width"]),
|
|
126
|
+
sort: stringOrNull(opts["sort"]),
|
|
127
|
+
serve: opts["serve"] === true,
|
|
128
|
+
port: stringOrNull(opts["port"]),
|
|
129
|
+
help: opts["help"] === true,
|
|
130
|
+
version: opts["version"] === true,
|
|
131
|
+
configPath: opts["configPath"] === true,
|
|
132
|
+
sidebar: stringOrNull(opts["sidebar"]),
|
|
133
|
+
noUpdateCheck: opts["noUpdateCheck"] === true,
|
|
134
|
+
noMdx: opts["mdx"] === false,
|
|
135
|
+
show: stringOrNull(opts["show"]),
|
|
136
|
+
focus: stringOrNull(opts["focus"]),
|
|
154
137
|
}
|
|
155
138
|
}
|
|
156
139
|
|
|
@@ -166,8 +149,10 @@ options:
|
|
|
166
149
|
--width <N> cap rendered markdown width at N columns
|
|
167
150
|
--show <list> reveal normally-skipped entries; comma-separated subset of:
|
|
168
151
|
hidden, gitignored. Use --show "" to clear.
|
|
152
|
+
--root <dir> discovery root to walk (overrides defaultRoot config)
|
|
169
153
|
--sort <mode> sidebar order: dirs-first (default) or files-first
|
|
170
154
|
--sidebar <m> initial sidebar visibility: auto (default), on, or off
|
|
155
|
+
--focus <m> startup focus: sidebar, reader, or filter (default: filter)
|
|
171
156
|
--serve serve the given file as HTML in the browser (skips TUI)
|
|
172
157
|
--port <N> port for --serve (default: OS-assigned)
|
|
173
158
|
-h, --help show this help and exit
|
|
@@ -175,10 +160,9 @@ options:
|
|
|
175
160
|
--config-path print path to the config file and exit
|
|
176
161
|
--no-update-check suppress the "newer version available" check (also via NO_UPDATE_NOTIFIER=1)
|
|
177
162
|
--no-mdx exclude .mdx files from discovery (default: included)
|
|
178
|
-
--start-in-filter open the sidebar filter prompt on launch
|
|
179
163
|
|
|
180
164
|
configuration:
|
|
181
165
|
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,
|
|
166
|
+
keys: theme, tone, mdx, show, focus, defaultRoot
|
|
167
|
+
env: HOUSE_THEME, HOUSE_TONE, HOUSE_MDX, HOUSE_SHOW, HOUSE_FOCUS, HOUSE_DEFAULT_ROOT
|
|
184
168
|
precedence (high → low): flags → env → file → defaults`
|
package/src/config/load.ts
CHANGED
|
@@ -20,14 +20,15 @@ export interface HouseConfig {
|
|
|
20
20
|
readonly theme: string
|
|
21
21
|
readonly tone: "dark" | "light"
|
|
22
22
|
readonly mdx: boolean
|
|
23
|
+
/** Default discovery-root strategy when no explicit `--root` flag is passed. */
|
|
24
|
+
readonly defaultRoot: "cwd" | "git"
|
|
23
25
|
/** Categories of normally-skipped entries to opt into. See
|
|
24
26
|
* `src/discovery/show.ts` for the vocabulary. Empty array (the
|
|
25
27
|
* default) yields the conservative discovery set. */
|
|
26
28
|
readonly show: readonly ShowCategory[]
|
|
27
|
-
/**
|
|
28
|
-
* immediately.
|
|
29
|
-
|
|
30
|
-
readonly startInFilter: boolean
|
|
29
|
+
/** Startup pane/input target. `filter` opens the sidebar filter prompt and
|
|
30
|
+
* focuses it immediately. */
|
|
31
|
+
readonly focus: "sidebar" | "reader" | "filter"
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
export interface CliOverrides {
|
|
@@ -38,14 +39,15 @@ export interface CliOverrides {
|
|
|
38
39
|
* (no per-category merging — sets compose by replacement, like every
|
|
39
40
|
* other CLI override here). `--show ""` sets the empty set. */
|
|
40
41
|
readonly show: readonly ShowCategory[] | null
|
|
41
|
-
readonly
|
|
42
|
+
readonly focus: "sidebar" | "reader" | "filter" | null
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
const DEFAULT_THEME = "opencode"
|
|
45
46
|
const DEFAULT_TONE: "dark" | "light" = "dark"
|
|
46
47
|
const DEFAULT_MDX = true
|
|
48
|
+
const DEFAULT_ROOT: "cwd" | "git" = "cwd"
|
|
47
49
|
const DEFAULT_SHOW = ""
|
|
48
|
-
const
|
|
50
|
+
const DEFAULT_FOCUS: "sidebar" | "reader" | "filter" = "filter"
|
|
49
51
|
|
|
50
52
|
const themeIds = themeDefinitions.map((t) => t.id)
|
|
51
53
|
|
|
@@ -60,12 +62,14 @@ const KNOWN_FILE_KEYS: ReadonlySet<string> = new Set([
|
|
|
60
62
|
"tone",
|
|
61
63
|
"mdx",
|
|
62
64
|
"show",
|
|
63
|
-
"
|
|
65
|
+
"focus",
|
|
66
|
+
"defaultRoot",
|
|
64
67
|
])
|
|
65
68
|
|
|
66
69
|
const schema = Config.all({
|
|
67
70
|
theme: Config.schema(Schema.Literals(themeIds), "theme"),
|
|
68
71
|
tone: Config.schema(Schema.Literals(["dark", "light"] as const), "tone"),
|
|
72
|
+
defaultRoot: Config.schema(Schema.String, "defaultRoot"),
|
|
69
73
|
// Boolean stored as string literal because providers stringify values
|
|
70
74
|
// (TOML bools, env vars, CLI flags all flow through as text). Mapped to
|
|
71
75
|
// a real boolean in `loadConfig` below.
|
|
@@ -75,16 +79,17 @@ const schema = Config.all({
|
|
|
75
79
|
// `"hidden,gitignored"`). Token-level validation happens in `loadConfig`
|
|
76
80
|
// so the error message can list valid categories at the field's path.
|
|
77
81
|
show: Config.schema(Schema.String, "show"),
|
|
78
|
-
|
|
82
|
+
focus: Config.schema(Schema.Literals(["sidebar", "reader", "filter"] as const), "focus"),
|
|
79
83
|
})
|
|
80
84
|
|
|
81
85
|
const defaultsProvider = (): ConfigProvider.ConfigProvider =>
|
|
82
86
|
ConfigProvider.fromUnknown({
|
|
83
87
|
theme: DEFAULT_THEME,
|
|
84
88
|
tone: DEFAULT_TONE,
|
|
89
|
+
defaultRoot: DEFAULT_ROOT,
|
|
85
90
|
mdx: String(DEFAULT_MDX),
|
|
86
91
|
show: DEFAULT_SHOW,
|
|
87
|
-
|
|
92
|
+
focus: DEFAULT_FOCUS,
|
|
88
93
|
})
|
|
89
94
|
|
|
90
95
|
/**
|
|
@@ -199,14 +204,16 @@ const envProvider = (env: Record<string, string | undefined>): ConfigProvider.Co
|
|
|
199
204
|
const entries: Array<[string, string]> = []
|
|
200
205
|
const theme = env["HOUSE_THEME"]
|
|
201
206
|
const tone = env["HOUSE_TONE"]
|
|
207
|
+
const defaultRoot = env["HOUSE_DEFAULT_ROOT"]
|
|
202
208
|
const mdx = env["HOUSE_MDX"]
|
|
203
209
|
const show = env["HOUSE_SHOW"]
|
|
204
|
-
const
|
|
210
|
+
const focus = env["HOUSE_FOCUS"]
|
|
205
211
|
if (theme !== undefined) entries.push(["theme", theme])
|
|
206
212
|
if (tone !== undefined) entries.push(["tone", tone])
|
|
213
|
+
if (defaultRoot !== undefined) entries.push(["defaultRoot", defaultRoot])
|
|
207
214
|
if (mdx !== undefined) entries.push(["mdx", mdx])
|
|
208
215
|
if (show !== undefined) entries.push(["show", show])
|
|
209
|
-
if (
|
|
216
|
+
if (focus !== undefined) entries.push(["focus", focus])
|
|
210
217
|
return ConfigProvider.fromUnknown(Object.fromEntries(entries))
|
|
211
218
|
}
|
|
212
219
|
|
|
@@ -216,8 +223,7 @@ const cliProvider = (overrides: CliOverrides): ConfigProvider.ConfigProvider =>
|
|
|
216
223
|
if (overrides.tone !== null) entries.push(["tone", overrides.tone])
|
|
217
224
|
if (overrides.mdx !== null) entries.push(["mdx", String(overrides.mdx)])
|
|
218
225
|
if (overrides.show !== null) entries.push(["show", overrides.show.join(",")])
|
|
219
|
-
if (overrides.
|
|
220
|
-
entries.push(["start_in_filter", String(overrides.startInFilter)])
|
|
226
|
+
if (overrides.focus !== null) entries.push(["focus", overrides.focus])
|
|
221
227
|
return ConfigProvider.fromUnknown(Object.fromEntries(entries))
|
|
222
228
|
}
|
|
223
229
|
|
|
@@ -260,7 +266,7 @@ export const loadConfig = (
|
|
|
260
266
|
tone: null,
|
|
261
267
|
mdx: null,
|
|
262
268
|
show: null,
|
|
263
|
-
|
|
269
|
+
focus: null,
|
|
264
270
|
}
|
|
265
271
|
const onWarning = options.onWarning ?? ((msg) => process.stderr.write(`${msg}\n`))
|
|
266
272
|
const provider = cliProvider(cli).pipe(
|
|
@@ -270,6 +276,13 @@ export const loadConfig = (
|
|
|
270
276
|
)
|
|
271
277
|
return schema.parse(provider).pipe(
|
|
272
278
|
Effect.flatMap((raw) => {
|
|
279
|
+
const defaultRoot =
|
|
280
|
+
raw.defaultRoot === "cwd" || raw.defaultRoot === "git" ? raw.defaultRoot : DEFAULT_ROOT
|
|
281
|
+
if (raw.defaultRoot !== defaultRoot) {
|
|
282
|
+
onWarning(
|
|
283
|
+
`house: ignoring invalid value ${JSON.stringify(raw.defaultRoot)} for defaultRoot in config/env; using "${DEFAULT_ROOT}"`,
|
|
284
|
+
)
|
|
285
|
+
}
|
|
273
286
|
const parsed = parseShowList(raw.show)
|
|
274
287
|
if (!parsed.ok) {
|
|
275
288
|
// Effect's `Config.ConfigError` requires a `SchemaError` or
|
|
@@ -286,9 +299,10 @@ export const loadConfig = (
|
|
|
286
299
|
return Effect.succeed({
|
|
287
300
|
theme: raw.theme,
|
|
288
301
|
tone: raw.tone,
|
|
302
|
+
defaultRoot,
|
|
289
303
|
mdx: raw.mdx === "true",
|
|
290
304
|
show: parsed.value,
|
|
291
|
-
|
|
305
|
+
focus: raw.focus,
|
|
292
306
|
})
|
|
293
307
|
}),
|
|
294
308
|
)
|
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
|
}
|