@carlesandres/house 0.4.7 → 0.4.9
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 +43 -4
- package/README.md +64 -53
- package/package.json +2 -1
- package/src/Browser.tsx +235 -160
- package/src/CommandPalette.tsx +78 -14
- package/src/Footer.tsx +44 -37
- package/src/Header.tsx +4 -1
- package/src/StatusPopover.tsx +189 -0
- package/src/cli/argv.ts +15 -12
- package/src/commands/buildCommands.ts +22 -33
- package/src/commands/score.ts +1 -1
- package/src/discovery/filter.ts +83 -23
- package/src/discovery/walk.ts +24 -15
- package/src/index.tsx +120 -170
- package/src/keymap/browser.ts +14 -20
- package/src/keymap/keymap.ts +4 -4
- package/src/layout/sidebarEmptyState.ts +7 -0
- package/src/layout/sidebarRow.ts +1 -1
- 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/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
|
-
import { walk, type FileEntry
|
|
25
|
-
import { Header } from "./Header.tsx"
|
|
26
|
-
import { readFileText } from "./io/readFile.ts"
|
|
16
|
+
import { walk, type FileEntry } from "./discovery/walk.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,24 +71,51 @@ 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
|
|
100
107
|
* independent everywhere else. */
|
|
101
108
|
readonly initialShow: readonly ShowCategory[]
|
|
102
|
-
readonly sort: SortOrder
|
|
103
109
|
readonly mdx: boolean
|
|
104
110
|
readonly maxWidth: number | null
|
|
105
111
|
readonly sidebarMode: SidebarMode
|
|
106
112
|
readonly startupFocus: StartupFocus
|
|
107
113
|
}
|
|
108
114
|
|
|
109
|
-
const DiscoverShell = ({
|
|
115
|
+
export const DiscoverShell = ({
|
|
110
116
|
target,
|
|
117
|
+
initialQuery,
|
|
111
118
|
initialShow,
|
|
112
|
-
sort,
|
|
113
119
|
mdx,
|
|
114
120
|
maxWidth,
|
|
115
121
|
sidebarMode,
|
|
@@ -120,6 +126,8 @@ const DiscoverShell = ({
|
|
|
120
126
|
const [files, setFiles] = useState<readonly FileEntry[]>([])
|
|
121
127
|
const [scanning, setScanning] = useState<boolean>(true)
|
|
122
128
|
const [scanError, setScanError] = useState<string | null>(null)
|
|
129
|
+
const [skippedDirCount, setSkippedDirCount] = useState<number>(0)
|
|
130
|
+
const [lastSkippedDir, setLastSkippedDir] = useState<string | null>(null)
|
|
123
131
|
// Files arrive in a ref-tracked count so the status string can show
|
|
124
132
|
// "indexing… N" even when React hasn't yet flushed the latest setFiles.
|
|
125
133
|
const countRef = useRef(0)
|
|
@@ -132,8 +140,22 @@ const DiscoverShell = ({
|
|
|
132
140
|
setFiles([])
|
|
133
141
|
setScanning(true)
|
|
134
142
|
setScanError(null)
|
|
143
|
+
setSkippedDirCount(0)
|
|
144
|
+
setLastSkippedDir(null)
|
|
135
145
|
countRef.current = 0
|
|
136
|
-
const
|
|
146
|
+
const warnedProgram = walk(target, {
|
|
147
|
+
show,
|
|
148
|
+
mdx,
|
|
149
|
+
onWarning: ({ path }) => {
|
|
150
|
+
const relativePath = relative(resolve(target), path)
|
|
151
|
+
setSkippedDirCount((prev) => prev + 1)
|
|
152
|
+
setLastSkippedDir(
|
|
153
|
+
relativePath.length > 0 && !relativePath.startsWith("..") && !isAbsolute(relativePath)
|
|
154
|
+
? relativePath
|
|
155
|
+
: path,
|
|
156
|
+
)
|
|
157
|
+
},
|
|
158
|
+
}).pipe(
|
|
137
159
|
Stream.groupedWithin(64, Duration.millis(60)),
|
|
138
160
|
Stream.runForEach((chunk) =>
|
|
139
161
|
Effect.sync(() => {
|
|
@@ -147,25 +169,33 @@ const DiscoverShell = ({
|
|
|
147
169
|
onSuccess: () => Effect.sync(() => setScanning(false)),
|
|
148
170
|
onFailure: (cause) =>
|
|
149
171
|
Effect.sync(() => {
|
|
150
|
-
// Interruption is the normal teardown path — don't surface it.
|
|
151
172
|
if (Cause.hasInterrupts(cause)) return
|
|
152
|
-
setScanError(
|
|
173
|
+
setScanError(formatFatalDiscoveryStatus())
|
|
153
174
|
setScanning(false)
|
|
154
175
|
}),
|
|
155
176
|
}),
|
|
156
177
|
)
|
|
157
|
-
const fiber = Effect.runFork(
|
|
178
|
+
const fiber = Effect.runFork(warnedProgram)
|
|
158
179
|
return () => {
|
|
159
180
|
Effect.runFork(Fiber.interrupt(fiber))
|
|
160
181
|
}
|
|
161
|
-
}, [target, show,
|
|
182
|
+
}, [target, show, mdx])
|
|
162
183
|
|
|
163
|
-
const discoveryStatus =
|
|
184
|
+
const discoveryStatus =
|
|
185
|
+
scanError ??
|
|
186
|
+
(scanning
|
|
187
|
+
? `indexing… ${countRef.current}`
|
|
188
|
+
: formatPartialDiscoveryStatus({
|
|
189
|
+
skippedCount: skippedDirCount,
|
|
190
|
+
lastSkippedPath: lastSkippedDir,
|
|
191
|
+
}))
|
|
164
192
|
|
|
165
193
|
return (
|
|
166
194
|
<Browser
|
|
167
195
|
files={files}
|
|
196
|
+
initialQuery={initialQuery}
|
|
168
197
|
maxWidth={maxWidth}
|
|
198
|
+
emptyRootLabel={target}
|
|
169
199
|
discoveryStatus={discoveryStatus}
|
|
170
200
|
sidebarMode={sidebarMode}
|
|
171
201
|
startupFocus={startupFocus}
|
|
@@ -183,79 +213,22 @@ const DiscoverShell = ({
|
|
|
183
213
|
)
|
|
184
214
|
}
|
|
185
215
|
|
|
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
|
-
)
|
|
216
|
+
export const resolveInitialQuery = ({
|
|
217
|
+
pathArg,
|
|
218
|
+
discoveryRoot,
|
|
219
|
+
cwd,
|
|
220
|
+
}: {
|
|
221
|
+
readonly pathArg: string | null
|
|
222
|
+
readonly discoveryRoot: string
|
|
223
|
+
readonly cwd: string
|
|
224
|
+
}): string => {
|
|
225
|
+
if (pathArg === null) return ""
|
|
226
|
+
const resolvedPath = resolve(cwd, pathArg)
|
|
227
|
+
const rel = relative(discoveryRoot, resolvedPath)
|
|
228
|
+
if (rel.length === 0) return ""
|
|
229
|
+
if (!rel.startsWith("..") && !isAbsolute(rel)) return rel
|
|
230
|
+
if (pathArg.startsWith("./")) return pathArg.slice(2)
|
|
231
|
+
return pathArg
|
|
259
232
|
}
|
|
260
233
|
|
|
261
234
|
let updateExitHookRegistered = false
|
|
@@ -331,10 +304,15 @@ if (import.meta.main) {
|
|
|
331
304
|
}
|
|
332
305
|
|
|
333
306
|
const cwd = process.cwd()
|
|
334
|
-
const target = args.path ?? "."
|
|
335
307
|
const discoveryRoot = await resolveDiscoveryRoot({ cliRoot: args.root, defaultRoot, cwd })
|
|
308
|
+
const initialQuery = resolveInitialQuery({ pathArg: args.path, discoveryRoot, cwd })
|
|
336
309
|
|
|
337
310
|
if (args.serve) {
|
|
311
|
+
const target = args.path
|
|
312
|
+
if (target === null) {
|
|
313
|
+
console.error("house: --serve requires a file path")
|
|
314
|
+
process.exit(2)
|
|
315
|
+
}
|
|
338
316
|
let stats: Awaited<ReturnType<typeof stat>>
|
|
339
317
|
try {
|
|
340
318
|
stats = await stat(target)
|
|
@@ -367,14 +345,6 @@ if (import.meta.main) {
|
|
|
367
345
|
process.on("SIGTERM", shutdown)
|
|
368
346
|
// Bun.serve keeps the event loop alive until stop().
|
|
369
347
|
} else {
|
|
370
|
-
let sort: SortOrder = "dirs-first"
|
|
371
|
-
if (args.sort !== null) {
|
|
372
|
-
if (args.sort !== "dirs-first" && args.sort !== "files-first") {
|
|
373
|
-
console.error(`house: --sort must be "dirs-first" or "files-first", got "${args.sort}"`)
|
|
374
|
-
process.exit(2)
|
|
375
|
-
}
|
|
376
|
-
sort = args.sort
|
|
377
|
-
}
|
|
378
348
|
let sidebarMode: SidebarMode = "auto"
|
|
379
349
|
if (args.sidebar !== null) {
|
|
380
350
|
if (args.sidebar !== "auto" && args.sidebar !== "on" && args.sidebar !== "off") {
|
|
@@ -392,13 +362,12 @@ if (import.meta.main) {
|
|
|
392
362
|
}
|
|
393
363
|
}
|
|
394
364
|
await runTui({
|
|
395
|
-
target,
|
|
396
365
|
discoveryRoot,
|
|
366
|
+
initialQuery,
|
|
397
367
|
themeId,
|
|
398
368
|
tone,
|
|
399
369
|
maxWidth,
|
|
400
370
|
show,
|
|
401
|
-
sort,
|
|
402
371
|
mdx,
|
|
403
372
|
sidebarMode,
|
|
404
373
|
startupFocus,
|
|
@@ -408,13 +377,12 @@ if (import.meta.main) {
|
|
|
408
377
|
}
|
|
409
378
|
|
|
410
379
|
interface TuiBootOptions {
|
|
411
|
-
readonly target: string
|
|
412
380
|
readonly discoveryRoot: string
|
|
381
|
+
readonly initialQuery: string
|
|
413
382
|
readonly themeId: string
|
|
414
383
|
readonly tone: "dark" | "light"
|
|
415
384
|
readonly maxWidth: number | null
|
|
416
385
|
readonly show: readonly ShowCategory[]
|
|
417
|
-
readonly sort: SortOrder
|
|
418
386
|
readonly mdx: boolean
|
|
419
387
|
readonly sidebarMode: SidebarMode
|
|
420
388
|
readonly startupFocus: StartupFocus
|
|
@@ -424,26 +392,17 @@ interface TuiBootOptions {
|
|
|
424
392
|
}
|
|
425
393
|
|
|
426
394
|
async function runTui({
|
|
427
|
-
target,
|
|
428
395
|
discoveryRoot,
|
|
396
|
+
initialQuery,
|
|
429
397
|
themeId,
|
|
430
398
|
tone,
|
|
431
399
|
maxWidth,
|
|
432
400
|
show,
|
|
433
|
-
sort,
|
|
434
401
|
mdx,
|
|
435
402
|
sidebarMode,
|
|
436
403
|
startupFocus,
|
|
437
404
|
updateCheck,
|
|
438
405
|
}: 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
406
|
if (updateCheck) {
|
|
448
407
|
// Fire the npm-registry probe in the background. Result lands in a
|
|
449
408
|
// module singleton; the React tree picks it up via `useUpdateNotice`
|
|
@@ -462,40 +421,31 @@ async function runTui({
|
|
|
462
421
|
}
|
|
463
422
|
}
|
|
464
423
|
|
|
465
|
-
const renderer = await createCliRenderer({
|
|
424
|
+
const renderer = await createCliRenderer({
|
|
425
|
+
exitOnCtrlC: false,
|
|
426
|
+
useMouse: true,
|
|
427
|
+
enableMouseMovement: true,
|
|
428
|
+
screenMode: "alternate-screen",
|
|
429
|
+
})
|
|
430
|
+
renderer.useMouse = true
|
|
466
431
|
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
|
-
)
|
|
432
|
+
try {
|
|
433
|
+
await validateDiscoveryRoot(discoveryRoot)
|
|
434
|
+
} catch (err) {
|
|
435
|
+
console.error(`house: ${err instanceof Error ? err.message : String(err)}`)
|
|
436
|
+
process.exit(1)
|
|
500
437
|
}
|
|
438
|
+
createRoot(renderer).render(
|
|
439
|
+
<RegistryProvider initialValues={[[themeAtom, initialTheme]]}>
|
|
440
|
+
<DiscoverShell
|
|
441
|
+
target={discoveryRoot}
|
|
442
|
+
initialQuery={initialQuery}
|
|
443
|
+
initialShow={show}
|
|
444
|
+
mdx={mdx}
|
|
445
|
+
maxWidth={maxWidth}
|
|
446
|
+
sidebarMode={sidebarMode}
|
|
447
|
+
startupFocus={startupFocus}
|
|
448
|
+
/>
|
|
449
|
+
</RegistryProvider>,
|
|
450
|
+
)
|
|
501
451
|
}
|
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
|
+
}
|
package/src/layout/sidebarRow.ts
CHANGED
|
@@ -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/theme/resolve.ts
CHANGED
|
@@ -82,7 +82,7 @@ const HARD_FALLBACK: Readonly<Record<TokenName, HexColor>> = Object.fromEntries(
|
|
|
82
82
|
* the theme omits them. Mirrors opencode's resolve behavior.
|
|
83
83
|
*/
|
|
84
84
|
const TOKEN_FALLBACK: Partial<Record<TokenName, TokenName>> = {
|
|
85
|
-
selectedListItemText: "
|
|
85
|
+
selectedListItemText: "text",
|
|
86
86
|
markdownText: "text",
|
|
87
87
|
markdownCodeBlock: "text",
|
|
88
88
|
borderSubtle: "border",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"textMuted": "darkFgMuted",
|
|
26
26
|
"background": "darkBg",
|
|
27
27
|
"backgroundPanel": "darkBgPanel",
|
|
28
|
-
"backgroundElement": "
|
|
28
|
+
"backgroundElement": "darkBorder",
|
|
29
29
|
"border": "darkBorder",
|
|
30
30
|
"borderActive": "darkFgMuted",
|
|
31
31
|
"borderSubtle": "darkBorder",
|