@carlesandres/house 0.4.6 → 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 +57 -5
- package/README.md +23 -11
- package/package.json +1 -1
- package/src/Browser.tsx +321 -184
- package/src/CommandPalette.tsx +78 -14
- package/src/Footer.tsx +65 -31
- package/src/Header.tsx +4 -1
- package/src/Spinner.tsx +39 -0
- package/src/StatusPopover.tsx +189 -0
- package/src/cli/argv.ts +19 -6
- package/src/commands/buildCommands.ts +22 -33
- package/src/commands/score.ts +1 -1
- package/src/config/load.ts +23 -1
- package/src/discovery/walk.ts +20 -3
- package/src/index.tsx +159 -155
- package/src/keymap/browser.ts +17 -23
- package/src/keymap/keymap.ts +4 -4
- package/src/layout/sidebarEmptyState.ts +7 -0
- package/src/markdown/frontmatter.ts +62 -0
- package/src/serve/server.ts +1 -1
- 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/index.tsx
CHANGED
|
@@ -1,50 +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 {
|
|
13
|
-
import
|
|
14
|
-
import { createRoot
|
|
15
|
-
import { RegistryProvider
|
|
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"
|
|
16
9
|
import { Cause, Duration, Effect, Fiber, Stream } from "effect"
|
|
17
|
-
import { useEffect,
|
|
10
|
+
import { useEffect, useRef, useState } from "react"
|
|
18
11
|
import pkg from "../package.json" with { type: "json" }
|
|
19
12
|
import { Browser, type StartupFocus } from "./Browser.tsx"
|
|
20
13
|
import { parseArgv, usage } from "./cli/argv.ts"
|
|
21
14
|
import { defaultConfigPath, formatConfigError, loadConfig } from "./config/load.ts"
|
|
22
15
|
import { parseShowList, SHOW_CATEGORIES, type ShowCategory } from "./discovery/show.ts"
|
|
23
16
|
import { walk, type FileEntry, type SortOrder } from "./discovery/walk.ts"
|
|
24
|
-
import { Header } from "./Header.tsx"
|
|
25
|
-
import { readFileText } from "./io/readFile.ts"
|
|
26
17
|
import { openInBrowser } from "./serve/openBrowser.ts"
|
|
27
18
|
import { startServer } from "./serve/server.ts"
|
|
28
|
-
import {
|
|
19
|
+
import { setActiveTheme } from "./theme/colors.ts"
|
|
29
20
|
import { themeAtom, type ThemeState } from "./theme/atom.ts"
|
|
30
|
-
import { getThemeDefinition
|
|
21
|
+
import { getThemeDefinition } from "./theme/registry.ts"
|
|
31
22
|
import { formatQuitNotice } from "./update/notice.ts"
|
|
32
23
|
import { currentUpdateInfo, startUpdateProbe } from "./update/runtime.ts"
|
|
33
24
|
import { useUpdateNotice } from "./update/useUpdateNotice.ts"
|
|
34
25
|
|
|
35
|
-
export interface AppProps {
|
|
36
|
-
/** Markdown source to render. */
|
|
37
|
-
readonly content: string
|
|
38
|
-
/** Optional title shown in the header's current-file slot. Defaults to a generic label. */
|
|
39
|
-
readonly title?: string
|
|
40
|
-
/** Cap the rendered markdown's width at N columns (left-aligned). Null = fill the pane. */
|
|
41
|
-
readonly maxWidth?: number | null
|
|
42
|
-
/** Override quit behavior. Tests pass a spy; the binary uses the default. */
|
|
43
|
-
readonly onQuit?: () => void
|
|
44
|
-
}
|
|
45
|
-
|
|
46
26
|
/**
|
|
47
|
-
* DiscoverShell — owns the streaming walk for
|
|
27
|
+
* DiscoverShell — owns the streaming walk for the Browser. Mounts Browser
|
|
48
28
|
* immediately with `files=[]` and pushes entries as the stream emits.
|
|
49
29
|
*
|
|
50
30
|
* Batching: `Stream.groupedWithin(64, 60ms)` coalesces bursts so we don't
|
|
@@ -58,8 +38,69 @@ export interface AppProps {
|
|
|
58
38
|
*/
|
|
59
39
|
export type SidebarMode = "auto" | "on" | "off"
|
|
60
40
|
|
|
41
|
+
const pathIsDirectory = async (path: string): Promise<boolean> => {
|
|
42
|
+
try {
|
|
43
|
+
return (await stat(path)).isDirectory()
|
|
44
|
+
} catch {
|
|
45
|
+
return false
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const findGitRoot = async (cwd: string): Promise<string> => {
|
|
50
|
+
const start = resolve(cwd)
|
|
51
|
+
let current = start
|
|
52
|
+
for (;;) {
|
|
53
|
+
if (await pathIsDirectory(resolve(current, ".git"))) return current
|
|
54
|
+
const parent = dirname(current)
|
|
55
|
+
if (parent === current) return start
|
|
56
|
+
current = parent
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const resolveDiscoveryRoot = async ({
|
|
61
|
+
cliRoot,
|
|
62
|
+
defaultRoot,
|
|
63
|
+
cwd,
|
|
64
|
+
}: {
|
|
65
|
+
readonly cliRoot: string | null
|
|
66
|
+
readonly defaultRoot: "cwd" | "git"
|
|
67
|
+
readonly cwd: string
|
|
68
|
+
}): Promise<string> => {
|
|
69
|
+
if (cliRoot !== null) return cliRoot
|
|
70
|
+
if (defaultRoot === "git") return findGitRoot(cwd)
|
|
71
|
+
return cwd
|
|
72
|
+
}
|
|
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
|
+
|
|
61
101
|
interface DiscoverShellProps {
|
|
62
102
|
readonly target: string
|
|
103
|
+
readonly initialQuery: string
|
|
63
104
|
/** Resolved discovery vocabulary from the config layer. The shift+a
|
|
64
105
|
* toggle (#145) is session-only sugar that flips between this set
|
|
65
106
|
* and the full vocabulary; the underlying categories remain
|
|
@@ -72,8 +113,9 @@ interface DiscoverShellProps {
|
|
|
72
113
|
readonly startupFocus: StartupFocus
|
|
73
114
|
}
|
|
74
115
|
|
|
75
|
-
const DiscoverShell = ({
|
|
116
|
+
export const DiscoverShell = ({
|
|
76
117
|
target,
|
|
118
|
+
initialQuery,
|
|
77
119
|
initialShow,
|
|
78
120
|
sort,
|
|
79
121
|
mdx,
|
|
@@ -86,6 +128,8 @@ const DiscoverShell = ({
|
|
|
86
128
|
const [files, setFiles] = useState<readonly FileEntry[]>([])
|
|
87
129
|
const [scanning, setScanning] = useState<boolean>(true)
|
|
88
130
|
const [scanError, setScanError] = useState<string | null>(null)
|
|
131
|
+
const [skippedDirCount, setSkippedDirCount] = useState<number>(0)
|
|
132
|
+
const [lastSkippedDir, setLastSkippedDir] = useState<string | null>(null)
|
|
89
133
|
// Files arrive in a ref-tracked count so the status string can show
|
|
90
134
|
// "indexing… N" even when React hasn't yet flushed the latest setFiles.
|
|
91
135
|
const countRef = useRef(0)
|
|
@@ -98,8 +142,23 @@ const DiscoverShell = ({
|
|
|
98
142
|
setFiles([])
|
|
99
143
|
setScanning(true)
|
|
100
144
|
setScanError(null)
|
|
145
|
+
setSkippedDirCount(0)
|
|
146
|
+
setLastSkippedDir(null)
|
|
101
147
|
countRef.current = 0
|
|
102
|
-
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(
|
|
103
162
|
Stream.groupedWithin(64, Duration.millis(60)),
|
|
104
163
|
Stream.runForEach((chunk) =>
|
|
105
164
|
Effect.sync(() => {
|
|
@@ -113,25 +172,33 @@ const DiscoverShell = ({
|
|
|
113
172
|
onSuccess: () => Effect.sync(() => setScanning(false)),
|
|
114
173
|
onFailure: (cause) =>
|
|
115
174
|
Effect.sync(() => {
|
|
116
|
-
// Interruption is the normal teardown path — don't surface it.
|
|
117
175
|
if (Cause.hasInterrupts(cause)) return
|
|
118
|
-
setScanError(
|
|
176
|
+
setScanError(formatFatalDiscoveryStatus())
|
|
119
177
|
setScanning(false)
|
|
120
178
|
}),
|
|
121
179
|
}),
|
|
122
180
|
)
|
|
123
|
-
const fiber = Effect.runFork(
|
|
181
|
+
const fiber = Effect.runFork(warnedProgram)
|
|
124
182
|
return () => {
|
|
125
183
|
Effect.runFork(Fiber.interrupt(fiber))
|
|
126
184
|
}
|
|
127
185
|
}, [target, show, sort, mdx])
|
|
128
186
|
|
|
129
|
-
const discoveryStatus =
|
|
187
|
+
const discoveryStatus =
|
|
188
|
+
scanError ??
|
|
189
|
+
(scanning
|
|
190
|
+
? `indexing… ${countRef.current}`
|
|
191
|
+
: formatPartialDiscoveryStatus({
|
|
192
|
+
skippedCount: skippedDirCount,
|
|
193
|
+
lastSkippedPath: lastSkippedDir,
|
|
194
|
+
}))
|
|
130
195
|
|
|
131
196
|
return (
|
|
132
197
|
<Browser
|
|
133
198
|
files={files}
|
|
199
|
+
initialQuery={initialQuery}
|
|
134
200
|
maxWidth={maxWidth}
|
|
201
|
+
emptyRootLabel={target}
|
|
135
202
|
discoveryStatus={discoveryStatus}
|
|
136
203
|
sidebarMode={sidebarMode}
|
|
137
204
|
startupFocus={startupFocus}
|
|
@@ -149,79 +216,22 @@ const DiscoverShell = ({
|
|
|
149
216
|
)
|
|
150
217
|
}
|
|
151
218
|
|
|
152
|
-
export const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const nextTone = theme.tone === "dark" ? "light" : "dark"
|
|
169
|
-
const def = getThemeDefinition(theme.id)
|
|
170
|
-
if (def) setActiveTheme(def, nextTone)
|
|
171
|
-
setTheme({ id: theme.id, tone: nextTone })
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
useKeyboard((key) => {
|
|
175
|
-
if (key.name === "q" || (key.ctrl && key.name === "c")) {
|
|
176
|
-
if (onQuit) {
|
|
177
|
-
onQuit()
|
|
178
|
-
return
|
|
179
|
-
}
|
|
180
|
-
renderer?.destroy()
|
|
181
|
-
process.exit(0)
|
|
182
|
-
}
|
|
183
|
-
if (key.name === "t" && !key.shift) cycleTheme(1)
|
|
184
|
-
if (key.name === "t" && key.shift) cycleTheme(-1)
|
|
185
|
-
if (key.name === "l" && key.shift) toggleTone()
|
|
186
|
-
})
|
|
187
|
-
|
|
188
|
-
const paneBorderSides: BorderSides[] = ["top", "bottom"]
|
|
189
|
-
|
|
190
|
-
return (
|
|
191
|
-
<box style={{ width, height, flexDirection: "column", backgroundColor: colors.background }}>
|
|
192
|
-
<Header width={width} currentFile={title} />
|
|
193
|
-
<box
|
|
194
|
-
style={{
|
|
195
|
-
border: paneBorderSides,
|
|
196
|
-
borderColor: colors.border,
|
|
197
|
-
padding: 1,
|
|
198
|
-
flexGrow: 1,
|
|
199
|
-
flexShrink: 1,
|
|
200
|
-
backgroundColor: colors.background,
|
|
201
|
-
}}
|
|
202
|
-
>
|
|
203
|
-
<scrollbox
|
|
204
|
-
style={{
|
|
205
|
-
scrollY: true,
|
|
206
|
-
scrollX: false,
|
|
207
|
-
flexGrow: 1,
|
|
208
|
-
flexShrink: 1,
|
|
209
|
-
backgroundColor: colors.background,
|
|
210
|
-
}}
|
|
211
|
-
focused
|
|
212
|
-
>
|
|
213
|
-
<markdown
|
|
214
|
-
content={content}
|
|
215
|
-
syntaxStyle={syntaxStyle}
|
|
216
|
-
fg={colors.text}
|
|
217
|
-
bg={colors.background}
|
|
218
|
-
conceal
|
|
219
|
-
style={{ width: maxWidth ?? "100%" }}
|
|
220
|
-
/>
|
|
221
|
-
</scrollbox>
|
|
222
|
-
</box>
|
|
223
|
-
</box>
|
|
224
|
-
)
|
|
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
|
|
225
235
|
}
|
|
226
236
|
|
|
227
237
|
let updateExitHookRegistered = false
|
|
@@ -277,7 +287,7 @@ if (import.meta.main) {
|
|
|
277
287
|
console.error(`house: ${formatConfigError(err)}`)
|
|
278
288
|
process.exit(2)
|
|
279
289
|
})
|
|
280
|
-
const { theme: themeId, tone, mdx, show, focus: startupFocus } = config
|
|
290
|
+
const { theme: themeId, tone, mdx, show, focus: startupFocus, defaultRoot } = config
|
|
281
291
|
const themeDef = getThemeDefinition(themeId)
|
|
282
292
|
if (themeDef === undefined) {
|
|
283
293
|
// Unreachable: Config.schema validated themeId against themeDefinitions.
|
|
@@ -296,9 +306,16 @@ if (import.meta.main) {
|
|
|
296
306
|
maxWidth = n
|
|
297
307
|
}
|
|
298
308
|
|
|
299
|
-
const
|
|
309
|
+
const cwd = process.cwd()
|
|
310
|
+
const discoveryRoot = await resolveDiscoveryRoot({ cliRoot: args.root, defaultRoot, cwd })
|
|
311
|
+
const initialQuery = resolveInitialQuery({ pathArg: args.path, discoveryRoot, cwd })
|
|
300
312
|
|
|
301
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
|
+
}
|
|
302
319
|
let stats: Awaited<ReturnType<typeof stat>>
|
|
303
320
|
try {
|
|
304
321
|
stats = await stat(target)
|
|
@@ -356,7 +373,8 @@ if (import.meta.main) {
|
|
|
356
373
|
}
|
|
357
374
|
}
|
|
358
375
|
await runTui({
|
|
359
|
-
|
|
376
|
+
discoveryRoot,
|
|
377
|
+
initialQuery,
|
|
360
378
|
themeId,
|
|
361
379
|
tone,
|
|
362
380
|
maxWidth,
|
|
@@ -371,7 +389,8 @@ if (import.meta.main) {
|
|
|
371
389
|
}
|
|
372
390
|
|
|
373
391
|
interface TuiBootOptions {
|
|
374
|
-
readonly
|
|
392
|
+
readonly discoveryRoot: string
|
|
393
|
+
readonly initialQuery: string
|
|
375
394
|
readonly themeId: string
|
|
376
395
|
readonly tone: "dark" | "light"
|
|
377
396
|
readonly maxWidth: number | null
|
|
@@ -386,7 +405,8 @@ interface TuiBootOptions {
|
|
|
386
405
|
}
|
|
387
406
|
|
|
388
407
|
async function runTui({
|
|
389
|
-
|
|
408
|
+
discoveryRoot,
|
|
409
|
+
initialQuery,
|
|
390
410
|
themeId,
|
|
391
411
|
tone,
|
|
392
412
|
maxWidth,
|
|
@@ -397,14 +417,6 @@ async function runTui({
|
|
|
397
417
|
startupFocus,
|
|
398
418
|
updateCheck,
|
|
399
419
|
}: TuiBootOptions): Promise<void> {
|
|
400
|
-
let stats: Awaited<ReturnType<typeof stat>>
|
|
401
|
-
try {
|
|
402
|
-
stats = await stat(target)
|
|
403
|
-
} catch (err) {
|
|
404
|
-
console.error(`house: cannot access ${target}: ${String(err)}`)
|
|
405
|
-
process.exit(1)
|
|
406
|
-
}
|
|
407
|
-
|
|
408
420
|
if (updateCheck) {
|
|
409
421
|
// Fire the npm-registry probe in the background. Result lands in a
|
|
410
422
|
// module singleton; the React tree picks it up via `useUpdateNotice`
|
|
@@ -423,40 +435,32 @@ async function runTui({
|
|
|
423
435
|
}
|
|
424
436
|
}
|
|
425
437
|
|
|
426
|
-
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
|
|
427
445
|
const initialTheme: ThemeState = { id: themeId, tone }
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
target={target}
|
|
434
|
-
initialShow={show}
|
|
435
|
-
sort={sort}
|
|
436
|
-
mdx={mdx}
|
|
437
|
-
maxWidth={maxWidth}
|
|
438
|
-
sidebarMode={sidebarMode}
|
|
439
|
-
startupFocus={startupFocus}
|
|
440
|
-
/>
|
|
441
|
-
</RegistryProvider>,
|
|
442
|
-
)
|
|
443
|
-
} else {
|
|
444
|
-
const content = await Effect.runPromise(
|
|
445
|
-
readFileText(target).pipe(
|
|
446
|
-
Effect.tapError((err) =>
|
|
447
|
-
Effect.sync(() => {
|
|
448
|
-
console.error(`house: cannot read ${err.path}: ${String(err.cause)}`)
|
|
449
|
-
}),
|
|
450
|
-
),
|
|
451
|
-
),
|
|
452
|
-
).catch(() => {
|
|
453
|
-
process.exit(1)
|
|
454
|
-
})
|
|
455
|
-
if (typeof content !== "string") process.exit(1)
|
|
456
|
-
createRoot(renderer).render(
|
|
457
|
-
<RegistryProvider initialValues={[[themeAtom, initialTheme]]}>
|
|
458
|
-
<App content={content} title={target} maxWidth={maxWidth} />
|
|
459
|
-
</RegistryProvider>,
|
|
460
|
-
)
|
|
446
|
+
try {
|
|
447
|
+
await validateDiscoveryRoot(discoveryRoot)
|
|
448
|
+
} catch (err) {
|
|
449
|
+
console.error(`house: ${err instanceof Error ? err.message : String(err)}`)
|
|
450
|
+
process.exit(1)
|
|
461
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
|
+
)
|
|
462
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"
|
|
@@ -11,14 +10,13 @@ export type BrowserFocus = "sidebar" | "reader"
|
|
|
11
10
|
export interface BrowserCtx {
|
|
12
11
|
readonly files: readonly FileEntry[]
|
|
13
12
|
/** True iff `files[selectedIndex]` resolves to an entry. The honest
|
|
14
|
-
* predicate for File-group actions (`
|
|
13
|
+
* predicate for File-group actions (`O`, `E`, `[`, `]`): with debounced
|
|
15
14
|
* filter and sticky auto-select, `files.length > 0` can be true while
|
|
16
15
|
* `selectedIndex` is invalid for the displayed list. See #115. */
|
|
17
16
|
readonly hasSelected: boolean
|
|
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
|
|
|
@@ -278,8 +270,9 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
|
|
|
278
270
|
group: "File",
|
|
279
271
|
description: "Open current file in browser as HTML",
|
|
280
272
|
hint: "html",
|
|
281
|
-
keys: ["o"],
|
|
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
|
{
|
|
@@ -287,8 +280,9 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
|
|
|
287
280
|
group: "File",
|
|
288
281
|
description: "Open current file in $EDITOR",
|
|
289
282
|
hint: "edit",
|
|
290
|
-
keys: ["e"],
|
|
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
|
+
}
|
package/src/serve/server.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Local HTML preview server for a single markdown file.
|
|
3
3
|
*
|
|
4
4
|
* One long-lived `Bun.serve` instance. The served file path is swappable
|
|
5
|
-
* via `setTarget(path)` — used by the TUI's `
|
|
5
|
+
* via `setTarget(path)` — used by the TUI's `O` binding so pressing it on a
|
|
6
6
|
* new file retargets the existing server (live-reload fires) instead of
|
|
7
7
|
* spawning a second one.
|
|
8
8
|
*
|