@carlesandres/house 0.4.7 → 0.4.8
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 +27 -3
- package/README.md +21 -9
- package/package.json +1 -1
- package/src/Browser.tsx +235 -160
- package/src/CommandPalette.tsx +78 -14
- package/src/Footer.tsx +41 -33
- package/src/Header.tsx +4 -1
- package/src/StatusPopover.tsx +189 -0
- package/src/cli/argv.ts +12 -5
- package/src/commands/buildCommands.ts +22 -33
- package/src/commands/score.ts +1 -1
- package/src/discovery/walk.ts +20 -3
- package/src/index.tsx +120 -155
- package/src/keymap/browser.ts +14 -20
- package/src/keymap/keymap.ts +4 -4
- package/src/layout/sidebarEmptyState.ts +7 -0
- package/src/markdown/frontmatter.ts +62 -0
- package/src/theme/resolve.ts +1 -1
- package/src/theme/themes/aura.json +1 -1
- package/src/theme/themes/carbonfox.json +1 -1
- package/src/theme/themes/lucent-orng.json +4 -4
- package/src/theme/themes/nightowl.json +2 -2
- package/src/theme/themes/orng.json +2 -2
- package/src/theme/themes/solarized.json +6 -2
- package/src/theme/themes/vesper.json +2 -2
- package/src/tips.ts +0 -5
- package/src/update/check.ts +1 -1
- package/src/update/runtime.ts +1 -1
- package/src/HelpOverlay.tsx +0 -148
package/src/discovery/walk.ts
CHANGED
|
@@ -13,6 +13,11 @@ export interface FileEntry {
|
|
|
13
13
|
readonly name: string
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
export interface DiscoveryWarning {
|
|
17
|
+
readonly path: string
|
|
18
|
+
readonly cause: unknown
|
|
19
|
+
}
|
|
20
|
+
|
|
16
21
|
export type SortOrder = "dirs-first" | "files-first"
|
|
17
22
|
|
|
18
23
|
export interface WalkOptions {
|
|
@@ -25,6 +30,8 @@ export interface WalkOptions {
|
|
|
25
30
|
readonly sort?: SortOrder
|
|
26
31
|
/** Include `.mdx` files alongside `.md`/`.markdown`. Default `true`. */
|
|
27
32
|
readonly mdx?: boolean
|
|
33
|
+
/** Non-fatal subtree read errors. Root-level failures still error the walk. */
|
|
34
|
+
readonly onWarning?: ((warning: DiscoveryWarning) => void) | null
|
|
28
35
|
}
|
|
29
36
|
|
|
30
37
|
export class DiscoveryError extends Data.TaggedError("DiscoveryError")<{
|
|
@@ -93,9 +100,11 @@ async function* walkDirGen(
|
|
|
93
100
|
rootPath: string,
|
|
94
101
|
parentLevels: readonly IgnoreLevel[],
|
|
95
102
|
opts: { showHidden: boolean; showGitignored: boolean; sort: SortOrder; mdx: boolean },
|
|
103
|
+
onWarning: ((warning: DiscoveryWarning) => void) | null,
|
|
96
104
|
signal: AbortSignal,
|
|
97
105
|
): AsyncGenerator<FileEntry, void, void> {
|
|
98
106
|
if (signal.aborted) return
|
|
107
|
+
const isRoot = dirPath === rootPath
|
|
99
108
|
|
|
100
109
|
let levels = parentLevels
|
|
101
110
|
if (!opts.showGitignored) {
|
|
@@ -104,7 +113,14 @@ async function* walkDirGen(
|
|
|
104
113
|
if (ig) levels = [...parentLevels, { dir: dirPath, ig }]
|
|
105
114
|
}
|
|
106
115
|
|
|
107
|
-
|
|
116
|
+
let raw
|
|
117
|
+
try {
|
|
118
|
+
raw = await readdir(dirPath, { withFileTypes: true })
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (isRoot) throw error
|
|
121
|
+
onWarning?.({ path: dirPath, cause: error })
|
|
122
|
+
return
|
|
123
|
+
}
|
|
108
124
|
if (signal.aborted) return
|
|
109
125
|
|
|
110
126
|
for (const entry of sortEntries(raw, opts.sort)) {
|
|
@@ -120,7 +136,7 @@ async function* walkDirGen(
|
|
|
120
136
|
if (HARD_SKIP_DIRS.has(entry.name)) continue
|
|
121
137
|
if (!opts.showHidden && entry.name.startsWith(".")) continue
|
|
122
138
|
if (!opts.showGitignored && isIgnored(entryPath, true, levels)) continue
|
|
123
|
-
yield* walkDirGen(entryPath, rootPath, levels, opts, signal)
|
|
139
|
+
yield* walkDirGen(entryPath, rootPath, levels, opts, onWarning, signal)
|
|
124
140
|
continue
|
|
125
141
|
}
|
|
126
142
|
|
|
@@ -165,10 +181,11 @@ export const walk = (
|
|
|
165
181
|
sort: options.sort ?? ("dirs-first" as SortOrder),
|
|
166
182
|
mdx: options.mdx ?? true,
|
|
167
183
|
}
|
|
184
|
+
const onWarning = options.onWarning ?? null
|
|
168
185
|
const controller = new AbortController()
|
|
169
186
|
const iterable: AsyncIterable<FileEntry> = {
|
|
170
187
|
[Symbol.asyncIterator]() {
|
|
171
|
-
const gen = walkDirGen(absRoot, absRoot, [], opts, controller.signal)
|
|
188
|
+
const gen = walkDirGen(absRoot, absRoot, [], opts, onWarning, controller.signal)
|
|
172
189
|
return {
|
|
173
190
|
next: () => gen.next(),
|
|
174
191
|
return: async (value?: void) => {
|
package/src/index.tsx
CHANGED
|
@@ -1,51 +1,30 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
/**
|
|
3
|
-
* house — entry point.
|
|
4
|
-
*
|
|
5
|
-
* Reads a markdown file path from argv and renders it via opentui's built-in
|
|
6
|
-
* <markdown> component inside a scrollbox. q / ctrl+c to quit.
|
|
7
|
-
*
|
|
8
|
-
* Discovery, sidebar, theming, and richer Effect wiring all land after this.
|
|
9
|
-
*/
|
|
2
|
+
/** house — entry point. Boots the browser TUI or `--serve` preview. */
|
|
10
3
|
|
|
11
4
|
import { stat } from "node:fs/promises"
|
|
12
|
-
import { dirname, resolve } from "node:path"
|
|
13
|
-
import { createCliRenderer
|
|
14
|
-
import
|
|
15
|
-
import {
|
|
16
|
-
import { RegistryProvider, useAtomSet, useAtomValue } from "@effect/atom-react"
|
|
5
|
+
import { dirname, isAbsolute, relative, resolve } from "node:path"
|
|
6
|
+
import { createCliRenderer } from "@opentui/core"
|
|
7
|
+
import { createRoot } from "@opentui/react"
|
|
8
|
+
import { RegistryProvider } from "@effect/atom-react"
|
|
17
9
|
import { Cause, Duration, Effect, Fiber, Stream } from "effect"
|
|
18
|
-
import { useEffect,
|
|
10
|
+
import { useEffect, useRef, useState } from "react"
|
|
19
11
|
import pkg from "../package.json" with { type: "json" }
|
|
20
12
|
import { Browser, type StartupFocus } from "./Browser.tsx"
|
|
21
13
|
import { parseArgv, usage } from "./cli/argv.ts"
|
|
22
14
|
import { defaultConfigPath, formatConfigError, loadConfig } from "./config/load.ts"
|
|
23
15
|
import { parseShowList, SHOW_CATEGORIES, type ShowCategory } from "./discovery/show.ts"
|
|
24
16
|
import { walk, type FileEntry, type SortOrder } from "./discovery/walk.ts"
|
|
25
|
-
import { Header } from "./Header.tsx"
|
|
26
|
-
import { readFileText } from "./io/readFile.ts"
|
|
27
17
|
import { openInBrowser } from "./serve/openBrowser.ts"
|
|
28
18
|
import { startServer } from "./serve/server.ts"
|
|
29
|
-
import {
|
|
19
|
+
import { setActiveTheme } from "./theme/colors.ts"
|
|
30
20
|
import { themeAtom, type ThemeState } from "./theme/atom.ts"
|
|
31
|
-
import { getThemeDefinition
|
|
21
|
+
import { getThemeDefinition } from "./theme/registry.ts"
|
|
32
22
|
import { formatQuitNotice } from "./update/notice.ts"
|
|
33
23
|
import { currentUpdateInfo, startUpdateProbe } from "./update/runtime.ts"
|
|
34
24
|
import { useUpdateNotice } from "./update/useUpdateNotice.ts"
|
|
35
25
|
|
|
36
|
-
export interface AppProps {
|
|
37
|
-
/** Markdown source to render. */
|
|
38
|
-
readonly content: string
|
|
39
|
-
/** Optional title shown in the header's current-file slot. Defaults to a generic label. */
|
|
40
|
-
readonly title?: string
|
|
41
|
-
/** Cap the rendered markdown's width at N columns (left-aligned). Null = fill the pane. */
|
|
42
|
-
readonly maxWidth?: number | null
|
|
43
|
-
/** Override quit behavior. Tests pass a spy; the binary uses the default. */
|
|
44
|
-
readonly onQuit?: () => void
|
|
45
|
-
}
|
|
46
|
-
|
|
47
26
|
/**
|
|
48
|
-
* DiscoverShell — owns the streaming walk for
|
|
27
|
+
* DiscoverShell — owns the streaming walk for the Browser. Mounts Browser
|
|
49
28
|
* immediately with `files=[]` and pushes entries as the stream emits.
|
|
50
29
|
*
|
|
51
30
|
* Batching: `Stream.groupedWithin(64, 60ms)` coalesces bursts so we don't
|
|
@@ -92,8 +71,36 @@ export const resolveDiscoveryRoot = async ({
|
|
|
92
71
|
return cwd
|
|
93
72
|
}
|
|
94
73
|
|
|
74
|
+
export const validateDiscoveryRoot = async (root: string): Promise<void> => {
|
|
75
|
+
let stats: Awaited<ReturnType<typeof stat>>
|
|
76
|
+
try {
|
|
77
|
+
stats = await stat(root)
|
|
78
|
+
} catch (err) {
|
|
79
|
+
throw new Error(`cannot access discovery root ${root}: ${String(err)}`)
|
|
80
|
+
}
|
|
81
|
+
if (!stats.isDirectory()) {
|
|
82
|
+
throw new Error(`discovery root must be a directory, got file ${root}`)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const formatPartialDiscoveryStatus = ({
|
|
87
|
+
skippedCount,
|
|
88
|
+
lastSkippedPath,
|
|
89
|
+
}: {
|
|
90
|
+
readonly skippedCount: number
|
|
91
|
+
readonly lastSkippedPath: string | null
|
|
92
|
+
}): string | null => {
|
|
93
|
+
if (skippedCount <= 0) return null
|
|
94
|
+
const noun = skippedCount === 1 ? "directory" : "directories"
|
|
95
|
+
const suffix = lastSkippedPath && skippedCount === 1 ? `: ${lastSkippedPath}` : ""
|
|
96
|
+
return `scan incomplete: skipped ${skippedCount} ${noun}${suffix}`
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const formatFatalDiscoveryStatus = (): string => "scan failed: unable to read discovery root"
|
|
100
|
+
|
|
95
101
|
interface DiscoverShellProps {
|
|
96
102
|
readonly target: string
|
|
103
|
+
readonly initialQuery: string
|
|
97
104
|
/** Resolved discovery vocabulary from the config layer. The shift+a
|
|
98
105
|
* toggle (#145) is session-only sugar that flips between this set
|
|
99
106
|
* and the full vocabulary; the underlying categories remain
|
|
@@ -106,8 +113,9 @@ interface DiscoverShellProps {
|
|
|
106
113
|
readonly startupFocus: StartupFocus
|
|
107
114
|
}
|
|
108
115
|
|
|
109
|
-
const DiscoverShell = ({
|
|
116
|
+
export const DiscoverShell = ({
|
|
110
117
|
target,
|
|
118
|
+
initialQuery,
|
|
111
119
|
initialShow,
|
|
112
120
|
sort,
|
|
113
121
|
mdx,
|
|
@@ -120,6 +128,8 @@ const DiscoverShell = ({
|
|
|
120
128
|
const [files, setFiles] = useState<readonly FileEntry[]>([])
|
|
121
129
|
const [scanning, setScanning] = useState<boolean>(true)
|
|
122
130
|
const [scanError, setScanError] = useState<string | null>(null)
|
|
131
|
+
const [skippedDirCount, setSkippedDirCount] = useState<number>(0)
|
|
132
|
+
const [lastSkippedDir, setLastSkippedDir] = useState<string | null>(null)
|
|
123
133
|
// Files arrive in a ref-tracked count so the status string can show
|
|
124
134
|
// "indexing… N" even when React hasn't yet flushed the latest setFiles.
|
|
125
135
|
const countRef = useRef(0)
|
|
@@ -132,8 +142,23 @@ const DiscoverShell = ({
|
|
|
132
142
|
setFiles([])
|
|
133
143
|
setScanning(true)
|
|
134
144
|
setScanError(null)
|
|
145
|
+
setSkippedDirCount(0)
|
|
146
|
+
setLastSkippedDir(null)
|
|
135
147
|
countRef.current = 0
|
|
136
|
-
const
|
|
148
|
+
const warnedProgram = walk(target, {
|
|
149
|
+
show,
|
|
150
|
+
sort,
|
|
151
|
+
mdx,
|
|
152
|
+
onWarning: ({ path }) => {
|
|
153
|
+
const relativePath = relative(resolve(target), path)
|
|
154
|
+
setSkippedDirCount((prev) => prev + 1)
|
|
155
|
+
setLastSkippedDir(
|
|
156
|
+
relativePath.length > 0 && !relativePath.startsWith("..") && !isAbsolute(relativePath)
|
|
157
|
+
? relativePath
|
|
158
|
+
: path,
|
|
159
|
+
)
|
|
160
|
+
},
|
|
161
|
+
}).pipe(
|
|
137
162
|
Stream.groupedWithin(64, Duration.millis(60)),
|
|
138
163
|
Stream.runForEach((chunk) =>
|
|
139
164
|
Effect.sync(() => {
|
|
@@ -147,25 +172,33 @@ const DiscoverShell = ({
|
|
|
147
172
|
onSuccess: () => Effect.sync(() => setScanning(false)),
|
|
148
173
|
onFailure: (cause) =>
|
|
149
174
|
Effect.sync(() => {
|
|
150
|
-
// Interruption is the normal teardown path — don't surface it.
|
|
151
175
|
if (Cause.hasInterrupts(cause)) return
|
|
152
|
-
setScanError(
|
|
176
|
+
setScanError(formatFatalDiscoveryStatus())
|
|
153
177
|
setScanning(false)
|
|
154
178
|
}),
|
|
155
179
|
}),
|
|
156
180
|
)
|
|
157
|
-
const fiber = Effect.runFork(
|
|
181
|
+
const fiber = Effect.runFork(warnedProgram)
|
|
158
182
|
return () => {
|
|
159
183
|
Effect.runFork(Fiber.interrupt(fiber))
|
|
160
184
|
}
|
|
161
185
|
}, [target, show, sort, mdx])
|
|
162
186
|
|
|
163
|
-
const discoveryStatus =
|
|
187
|
+
const discoveryStatus =
|
|
188
|
+
scanError ??
|
|
189
|
+
(scanning
|
|
190
|
+
? `indexing… ${countRef.current}`
|
|
191
|
+
: formatPartialDiscoveryStatus({
|
|
192
|
+
skippedCount: skippedDirCount,
|
|
193
|
+
lastSkippedPath: lastSkippedDir,
|
|
194
|
+
}))
|
|
164
195
|
|
|
165
196
|
return (
|
|
166
197
|
<Browser
|
|
167
198
|
files={files}
|
|
199
|
+
initialQuery={initialQuery}
|
|
168
200
|
maxWidth={maxWidth}
|
|
201
|
+
emptyRootLabel={target}
|
|
169
202
|
discoveryStatus={discoveryStatus}
|
|
170
203
|
sidebarMode={sidebarMode}
|
|
171
204
|
startupFocus={startupFocus}
|
|
@@ -183,79 +216,22 @@ const DiscoverShell = ({
|
|
|
183
216
|
)
|
|
184
217
|
}
|
|
185
218
|
|
|
186
|
-
export const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const nextTone = theme.tone === "dark" ? "light" : "dark"
|
|
203
|
-
const def = getThemeDefinition(theme.id)
|
|
204
|
-
if (def) setActiveTheme(def, nextTone)
|
|
205
|
-
setTheme({ id: theme.id, tone: nextTone })
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
useKeyboard((key) => {
|
|
209
|
-
if (key.name === "q" || (key.ctrl && key.name === "c")) {
|
|
210
|
-
if (onQuit) {
|
|
211
|
-
onQuit()
|
|
212
|
-
return
|
|
213
|
-
}
|
|
214
|
-
renderer?.destroy()
|
|
215
|
-
process.exit(0)
|
|
216
|
-
}
|
|
217
|
-
if (key.name === "t" && !key.shift) cycleTheme(1)
|
|
218
|
-
if (key.name === "t" && key.shift) cycleTheme(-1)
|
|
219
|
-
if (key.name === "l" && key.shift) toggleTone()
|
|
220
|
-
})
|
|
221
|
-
|
|
222
|
-
const paneBorderSides: BorderSides[] = ["top", "bottom"]
|
|
223
|
-
|
|
224
|
-
return (
|
|
225
|
-
<box style={{ width, height, flexDirection: "column", backgroundColor: colors.background }}>
|
|
226
|
-
<Header width={width} currentFile={title} />
|
|
227
|
-
<box
|
|
228
|
-
style={{
|
|
229
|
-
border: paneBorderSides,
|
|
230
|
-
borderColor: colors.border,
|
|
231
|
-
padding: 1,
|
|
232
|
-
flexGrow: 1,
|
|
233
|
-
flexShrink: 1,
|
|
234
|
-
backgroundColor: colors.background,
|
|
235
|
-
}}
|
|
236
|
-
>
|
|
237
|
-
<scrollbox
|
|
238
|
-
style={{
|
|
239
|
-
scrollY: true,
|
|
240
|
-
scrollX: false,
|
|
241
|
-
flexGrow: 1,
|
|
242
|
-
flexShrink: 1,
|
|
243
|
-
backgroundColor: colors.background,
|
|
244
|
-
}}
|
|
245
|
-
focused
|
|
246
|
-
>
|
|
247
|
-
<markdown
|
|
248
|
-
content={content}
|
|
249
|
-
syntaxStyle={syntaxStyle}
|
|
250
|
-
fg={colors.text}
|
|
251
|
-
bg={colors.background}
|
|
252
|
-
conceal
|
|
253
|
-
style={{ width: maxWidth ?? "100%" }}
|
|
254
|
-
/>
|
|
255
|
-
</scrollbox>
|
|
256
|
-
</box>
|
|
257
|
-
</box>
|
|
258
|
-
)
|
|
219
|
+
export const resolveInitialQuery = ({
|
|
220
|
+
pathArg,
|
|
221
|
+
discoveryRoot,
|
|
222
|
+
cwd,
|
|
223
|
+
}: {
|
|
224
|
+
readonly pathArg: string | null
|
|
225
|
+
readonly discoveryRoot: string
|
|
226
|
+
readonly cwd: string
|
|
227
|
+
}): string => {
|
|
228
|
+
if (pathArg === null) return ""
|
|
229
|
+
const resolvedPath = resolve(cwd, pathArg)
|
|
230
|
+
const rel = relative(discoveryRoot, resolvedPath)
|
|
231
|
+
if (rel.length === 0) return ""
|
|
232
|
+
if (!rel.startsWith("..") && !isAbsolute(rel)) return rel
|
|
233
|
+
if (pathArg.startsWith("./")) return pathArg.slice(2)
|
|
234
|
+
return pathArg
|
|
259
235
|
}
|
|
260
236
|
|
|
261
237
|
let updateExitHookRegistered = false
|
|
@@ -331,10 +307,15 @@ if (import.meta.main) {
|
|
|
331
307
|
}
|
|
332
308
|
|
|
333
309
|
const cwd = process.cwd()
|
|
334
|
-
const target = args.path ?? "."
|
|
335
310
|
const discoveryRoot = await resolveDiscoveryRoot({ cliRoot: args.root, defaultRoot, cwd })
|
|
311
|
+
const initialQuery = resolveInitialQuery({ pathArg: args.path, discoveryRoot, cwd })
|
|
336
312
|
|
|
337
313
|
if (args.serve) {
|
|
314
|
+
const target = args.path
|
|
315
|
+
if (target === null) {
|
|
316
|
+
console.error("house: --serve requires a file path")
|
|
317
|
+
process.exit(2)
|
|
318
|
+
}
|
|
338
319
|
let stats: Awaited<ReturnType<typeof stat>>
|
|
339
320
|
try {
|
|
340
321
|
stats = await stat(target)
|
|
@@ -392,8 +373,8 @@ if (import.meta.main) {
|
|
|
392
373
|
}
|
|
393
374
|
}
|
|
394
375
|
await runTui({
|
|
395
|
-
target,
|
|
396
376
|
discoveryRoot,
|
|
377
|
+
initialQuery,
|
|
397
378
|
themeId,
|
|
398
379
|
tone,
|
|
399
380
|
maxWidth,
|
|
@@ -408,8 +389,8 @@ if (import.meta.main) {
|
|
|
408
389
|
}
|
|
409
390
|
|
|
410
391
|
interface TuiBootOptions {
|
|
411
|
-
readonly target: string
|
|
412
392
|
readonly discoveryRoot: string
|
|
393
|
+
readonly initialQuery: string
|
|
413
394
|
readonly themeId: string
|
|
414
395
|
readonly tone: "dark" | "light"
|
|
415
396
|
readonly maxWidth: number | null
|
|
@@ -424,8 +405,8 @@ interface TuiBootOptions {
|
|
|
424
405
|
}
|
|
425
406
|
|
|
426
407
|
async function runTui({
|
|
427
|
-
target,
|
|
428
408
|
discoveryRoot,
|
|
409
|
+
initialQuery,
|
|
429
410
|
themeId,
|
|
430
411
|
tone,
|
|
431
412
|
maxWidth,
|
|
@@ -436,14 +417,6 @@ async function runTui({
|
|
|
436
417
|
startupFocus,
|
|
437
418
|
updateCheck,
|
|
438
419
|
}: TuiBootOptions): Promise<void> {
|
|
439
|
-
let stats: Awaited<ReturnType<typeof stat>>
|
|
440
|
-
try {
|
|
441
|
-
stats = await stat(target)
|
|
442
|
-
} catch (err) {
|
|
443
|
-
console.error(`house: cannot access ${target}: ${String(err)}`)
|
|
444
|
-
process.exit(1)
|
|
445
|
-
}
|
|
446
|
-
|
|
447
420
|
if (updateCheck) {
|
|
448
421
|
// Fire the npm-registry probe in the background. Result lands in a
|
|
449
422
|
// module singleton; the React tree picks it up via `useUpdateNotice`
|
|
@@ -462,40 +435,32 @@ async function runTui({
|
|
|
462
435
|
}
|
|
463
436
|
}
|
|
464
437
|
|
|
465
|
-
const renderer = await createCliRenderer({
|
|
438
|
+
const renderer = await createCliRenderer({
|
|
439
|
+
exitOnCtrlC: false,
|
|
440
|
+
useMouse: true,
|
|
441
|
+
enableMouseMovement: true,
|
|
442
|
+
screenMode: "alternate-screen",
|
|
443
|
+
})
|
|
444
|
+
renderer.useMouse = true
|
|
466
445
|
const initialTheme: ThemeState = { id: themeId, tone }
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
target={discoveryRoot}
|
|
473
|
-
initialShow={show}
|
|
474
|
-
sort={sort}
|
|
475
|
-
mdx={mdx}
|
|
476
|
-
maxWidth={maxWidth}
|
|
477
|
-
sidebarMode={sidebarMode}
|
|
478
|
-
startupFocus={startupFocus}
|
|
479
|
-
/>
|
|
480
|
-
</RegistryProvider>,
|
|
481
|
-
)
|
|
482
|
-
} else {
|
|
483
|
-
const content = await Effect.runPromise(
|
|
484
|
-
readFileText(target).pipe(
|
|
485
|
-
Effect.tapError((err) =>
|
|
486
|
-
Effect.sync(() => {
|
|
487
|
-
console.error(`house: cannot read ${err.path}: ${String(err.cause)}`)
|
|
488
|
-
}),
|
|
489
|
-
),
|
|
490
|
-
),
|
|
491
|
-
).catch(() => {
|
|
492
|
-
process.exit(1)
|
|
493
|
-
})
|
|
494
|
-
if (typeof content !== "string") process.exit(1)
|
|
495
|
-
createRoot(renderer).render(
|
|
496
|
-
<RegistryProvider initialValues={[[themeAtom, initialTheme]]}>
|
|
497
|
-
<App content={content} title={target} maxWidth={maxWidth} />
|
|
498
|
-
</RegistryProvider>,
|
|
499
|
-
)
|
|
446
|
+
try {
|
|
447
|
+
await validateDiscoveryRoot(discoveryRoot)
|
|
448
|
+
} catch (err) {
|
|
449
|
+
console.error(`house: ${err instanceof Error ? err.message : String(err)}`)
|
|
450
|
+
process.exit(1)
|
|
500
451
|
}
|
|
452
|
+
createRoot(renderer).render(
|
|
453
|
+
<RegistryProvider initialValues={[[themeAtom, initialTheme]]}>
|
|
454
|
+
<DiscoverShell
|
|
455
|
+
target={discoveryRoot}
|
|
456
|
+
initialQuery={initialQuery}
|
|
457
|
+
initialShow={show}
|
|
458
|
+
sort={sort}
|
|
459
|
+
mdx={mdx}
|
|
460
|
+
maxWidth={maxWidth}
|
|
461
|
+
sidebarMode={sidebarMode}
|
|
462
|
+
startupFocus={startupFocus}
|
|
463
|
+
/>
|
|
464
|
+
</RegistryProvider>,
|
|
465
|
+
)
|
|
501
466
|
}
|
package/src/keymap/browser.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Browser keymap — the data backing `Browser.tsx`'s `useKeyboard` handler
|
|
3
|
-
* and (next iteration) the `?` help overlay.
|
|
2
|
+
* Browser keymap — the data backing `Browser.tsx`'s `useKeyboard` handler.
|
|
4
3
|
*/
|
|
5
4
|
|
|
6
5
|
import type { FileEntry } from "../discovery/walk.ts"
|
|
@@ -18,7 +17,6 @@ export interface BrowserCtx {
|
|
|
18
17
|
readonly focus: BrowserFocus
|
|
19
18
|
/** User's sticky sidebar preference. Visibility is `shown || focus==="sidebar"`. */
|
|
20
19
|
readonly sidebarShown: boolean
|
|
21
|
-
readonly helpVisible: boolean
|
|
22
20
|
readonly filterOpen: boolean
|
|
23
21
|
readonly restoreFilterOnSidebarFocus: boolean
|
|
24
22
|
/** Current applied/edited filter query. Used by `filter.clearOrOpen`'s
|
|
@@ -29,7 +27,6 @@ export interface BrowserCtx {
|
|
|
29
27
|
readonly setSelectedIndex: (updater: (prev: number) => number) => void
|
|
30
28
|
/** Toggle `shown` and adjust focus per DESIGN.md §7.1 (see s-behavior table). */
|
|
31
29
|
readonly toggleShown: () => void
|
|
32
|
-
readonly setHelpVisible: (updater: (prev: boolean) => boolean) => void
|
|
33
30
|
readonly openFilter: () => void
|
|
34
31
|
/** Clear the current filter query and open the filter modal in a single
|
|
35
32
|
* action. Bound to `\` so users can reset a stranded zero-match filter
|
|
@@ -65,6 +62,7 @@ const stepBy = (c: BrowserCtx, delta: number) =>
|
|
|
65
62
|
const inSidebar = (c: BrowserCtx) => c.focus === "sidebar"
|
|
66
63
|
const filterClosed = (c: BrowserCtx) => !c.filterOpen
|
|
67
64
|
const paletteClosed = (c: BrowserCtx) => !c.paletteOpen
|
|
65
|
+
const inputClosed = (c: BrowserCtx) => filterClosed(c) && paletteClosed(c)
|
|
68
66
|
const inReader = (c: BrowserCtx) => c.focus === "reader"
|
|
69
67
|
const inSidebarWithFiles = (c: BrowserCtx) => inSidebar(c) && haveFiles(c)
|
|
70
68
|
/** Reader-only sibling-step gate: needs a current selection plus a sibling
|
|
@@ -80,6 +78,7 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
|
|
|
80
78
|
description: "Quit",
|
|
81
79
|
hint: "quit",
|
|
82
80
|
keys: ["q", "ctrl+c"],
|
|
81
|
+
hintWhen: inputClosed,
|
|
83
82
|
run: (c) => c.quit(),
|
|
84
83
|
},
|
|
85
84
|
{
|
|
@@ -102,16 +101,9 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
|
|
|
102
101
|
description: "Toggle sidebar visibility",
|
|
103
102
|
hint: "sidebar",
|
|
104
103
|
keys: ["s"],
|
|
104
|
+
hintWhen: inputClosed,
|
|
105
105
|
run: (c) => c.toggleShown(),
|
|
106
106
|
},
|
|
107
|
-
{
|
|
108
|
-
id: "help.toggle",
|
|
109
|
-
group: "Global",
|
|
110
|
-
description: "Show / dismiss help",
|
|
111
|
-
hint: "help",
|
|
112
|
-
keys: ["?"],
|
|
113
|
-
run: (c) => c.setHelpVisible((v) => !v),
|
|
114
|
-
},
|
|
115
107
|
{
|
|
116
108
|
id: "filter.open",
|
|
117
109
|
group: "Sidebar",
|
|
@@ -133,17 +125,16 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
|
|
|
133
125
|
// Fires from anywhere outside the filter modal via the keymap.
|
|
134
126
|
// Inside the filter modal it's intercepted directly in Browser.tsx
|
|
135
127
|
// (the filter mode owns key handling), but the action is the same —
|
|
136
|
-
// clear input, keep modal open. Palette
|
|
128
|
+
// clear input, keep modal open. Palette branches short-circuit
|
|
137
129
|
// dispatch in Browser.tsx, so we don't need to gate on them for
|
|
138
|
-
// behavior; the `hintWhen` gate keeps the footer
|
|
130
|
+
// behavior; the `hintWhen` gate keeps the footer hint from showing
|
|
139
131
|
// when there's nothing to clear or when a modal owns the input.
|
|
140
132
|
// Chord chosen over single `\` so the binding works inside the
|
|
141
133
|
// filter input without colliding with the typed character; ctrl+u
|
|
142
134
|
// is deliberately left to its reader/sidebar half-page-up role to
|
|
143
135
|
// avoid overload.
|
|
144
136
|
when: filterClosed,
|
|
145
|
-
hintWhen: (c) =>
|
|
146
|
-
filterClosed(c) && !c.paletteOpen && !c.helpVisible && c.filterQuery.length > 0,
|
|
137
|
+
hintWhen: (c) => filterClosed(c) && !c.paletteOpen && c.filterQuery.length > 0,
|
|
147
138
|
run: (c) => c.clearAndOpenFilter(),
|
|
148
139
|
},
|
|
149
140
|
{
|
|
@@ -152,10 +143,9 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
|
|
|
152
143
|
description: "Command palette",
|
|
153
144
|
hint: "palette",
|
|
154
145
|
keys: ["ctrl+p"],
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
// closes help on its way in (handled in Browser.tsx).
|
|
146
|
+
// The filter modal handles this chord directly so the palette can open
|
|
147
|
+
// while filter input owns the rest of the keyboard. This gate keeps the
|
|
148
|
+
// normal dispatcher from reopening an already-open palette.
|
|
159
149
|
when: paletteClosed,
|
|
160
150
|
run: (c) => c.openPalette(),
|
|
161
151
|
},
|
|
@@ -178,6 +168,7 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
|
|
|
178
168
|
description: "Next theme",
|
|
179
169
|
hint: "theme",
|
|
180
170
|
keys: ["t"],
|
|
171
|
+
hintWhen: inputClosed,
|
|
181
172
|
run: (c) => c.cycleTheme(1),
|
|
182
173
|
},
|
|
183
174
|
{
|
|
@@ -267,6 +258,7 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
|
|
|
267
258
|
hint: "open",
|
|
268
259
|
keys: ["return", "right", "l"],
|
|
269
260
|
when: inSidebar,
|
|
261
|
+
hintWhen: (c) => inSidebar(c) && hasSelected(c),
|
|
270
262
|
run: (c) => c.setFocus("reader"),
|
|
271
263
|
},
|
|
272
264
|
|
|
@@ -280,6 +272,7 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
|
|
|
280
272
|
hint: "html",
|
|
281
273
|
keys: ["shift+o"],
|
|
282
274
|
when: hasSelected,
|
|
275
|
+
hintWhen: (c) => inputClosed(c) && hasSelected(c),
|
|
283
276
|
run: (c) => c.serveCurrent(),
|
|
284
277
|
},
|
|
285
278
|
{
|
|
@@ -289,6 +282,7 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
|
|
|
289
282
|
hint: "edit",
|
|
290
283
|
keys: ["shift+e"],
|
|
291
284
|
when: hasSelected,
|
|
285
|
+
hintWhen: (c) => inputClosed(c) && hasSelected(c),
|
|
292
286
|
run: (c) => c.editCurrent(),
|
|
293
287
|
},
|
|
294
288
|
{
|
package/src/keymap/keymap.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Bindings are values: `{ id, description, keys, when?, run }`. A pure
|
|
5
5
|
* `dispatch` looks up the first matching, enabled binding for a key event
|
|
6
|
-
* and runs it. The same array drives
|
|
7
|
-
* source of truth.
|
|
6
|
+
* and runs it. The same array also drives footer hints and command-palette
|
|
7
|
+
* derivation, so there is one source of truth.
|
|
8
8
|
*
|
|
9
9
|
* Deliberately *not* a port of ghui's `@ghui/keymap`: no chord sequences,
|
|
10
10
|
* no count prefixes, no scope contramaps. See DESIGN.md §12 for the full
|
|
@@ -22,11 +22,11 @@ export interface KeyMatch {
|
|
|
22
22
|
export interface KeyBinding<C> {
|
|
23
23
|
/** Stable id; used for tests and (later) command-palette routing. */
|
|
24
24
|
readonly id: string
|
|
25
|
-
/** Human-readable summary
|
|
25
|
+
/** Human-readable summary used by docs and palette command titles. */
|
|
26
26
|
readonly description: string
|
|
27
27
|
/** Key chords that trigger this binding, e.g. ["j", "down"], ["shift+k"], ["ctrl+c"]. */
|
|
28
28
|
readonly keys: readonly string[]
|
|
29
|
-
/** Optional grouping label for
|
|
29
|
+
/** Optional grouping label for derived UI/documentation surfaces. */
|
|
30
30
|
readonly group?: string
|
|
31
31
|
/** Optional compact label for the footer hint row, e.g. "help" for `?:help`.
|
|
32
32
|
* Bindings without a hint are excluded from the footer. Order in the bindings
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
const HEAD_ELISION_PREFIX = "…"
|
|
2
|
+
|
|
3
|
+
export const fitSidebarEmptyValue = (value: string, width: number): string => {
|
|
4
|
+
if (value.length <= width) return value
|
|
5
|
+
if (width <= 1) return value.slice(value.length - 1)
|
|
6
|
+
return HEAD_ELISION_PREFIX + value.slice(value.length - width + 1)
|
|
7
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export interface FrontmatterField {
|
|
2
|
+
readonly key: string
|
|
3
|
+
readonly value: string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface FrontmatterRenderModel {
|
|
7
|
+
readonly body: string
|
|
8
|
+
readonly fields: readonly FrontmatterField[]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const FRONTMATTER_OPEN = "---"
|
|
12
|
+
const FRONTMATTER_CLOSE = "---"
|
|
13
|
+
|
|
14
|
+
const normalizeValue = (raw: string): string => {
|
|
15
|
+
const value = raw.trim()
|
|
16
|
+
if (
|
|
17
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
18
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
19
|
+
) {
|
|
20
|
+
return value.slice(1, -1)
|
|
21
|
+
}
|
|
22
|
+
return value
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const formatKey = (key: string): string => key.replace(/[-_]+/g, " ")
|
|
26
|
+
|
|
27
|
+
const toDisplayField = (key: string, value: string): FrontmatterField => ({
|
|
28
|
+
key: formatKey(key),
|
|
29
|
+
value: normalizeValue(value),
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
export const parseFrontmatter = (content: string): FrontmatterRenderModel => {
|
|
33
|
+
if (
|
|
34
|
+
!content.startsWith(`${FRONTMATTER_OPEN}\n`) &&
|
|
35
|
+
!content.startsWith(`${FRONTMATTER_OPEN}\r\n`)
|
|
36
|
+
) {
|
|
37
|
+
return { body: content, fields: [] }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const lines = content.split(/\r?\n/)
|
|
41
|
+
if (lines[0] !== FRONTMATTER_OPEN) return { body: content, fields: [] }
|
|
42
|
+
|
|
43
|
+
const closeIndex = lines.indexOf(FRONTMATTER_CLOSE, 1)
|
|
44
|
+
if (closeIndex <= 0) return { body: content, fields: [] }
|
|
45
|
+
|
|
46
|
+
const fields: FrontmatterField[] = []
|
|
47
|
+
for (const line of lines.slice(1, closeIndex)) {
|
|
48
|
+
if (line.trim().length === 0) continue
|
|
49
|
+
const colon = line.indexOf(":")
|
|
50
|
+
if (colon <= 0) return { body: content, fields: [] }
|
|
51
|
+
const key = line.slice(0, colon).trim()
|
|
52
|
+
const value = line.slice(colon + 1)
|
|
53
|
+
if (key.length === 0) return { body: content, fields: [] }
|
|
54
|
+
fields.push(toDisplayField(key, value))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const body = lines
|
|
58
|
+
.slice(closeIndex + 1)
|
|
59
|
+
.join("\n")
|
|
60
|
+
.replace(/^\n+/, "")
|
|
61
|
+
return { body, fields }
|
|
62
|
+
}
|