@carlesandres/house 0.4.12 → 0.4.14
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 +25 -1
- package/README.md +14 -8
- package/package.json +1 -1
- package/src/Browser.tsx +65 -43
- package/src/CommandPalette.tsx +5 -2
- package/src/Footer.tsx +55 -28
- package/src/Header.tsx +113 -24
- package/src/StatusIndicator.tsx +55 -0
- package/src/StatusPopover.tsx +4 -27
- package/src/cli/argv.ts +19 -11
- package/src/commands/buildCommands.ts +1 -0
- package/src/commands/paletteOnlyCommands.ts +12 -1
- package/src/config/load.ts +108 -40
- package/src/discovery/rootLabel.ts +18 -0
- package/src/index.tsx +51 -37
- package/src/io/clipboard.ts +53 -0
- package/src/keymap/browser.ts +15 -0
- package/src/layout/resolve.ts +0 -8
package/src/index.tsx
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
/** house — entry point. Boots the browser TUI or `--serve` preview. */
|
|
3
3
|
|
|
4
4
|
import { stat } from "node:fs/promises"
|
|
5
|
+
import { homedir } from "node:os"
|
|
5
6
|
import { dirname, isAbsolute, relative, resolve } from "node:path"
|
|
6
7
|
import { createCliRenderer } from "@opentui/core"
|
|
7
8
|
import { createRoot } from "@opentui/react"
|
|
@@ -13,6 +14,7 @@ import { Browser, type StartupFocus } from "./Browser.tsx"
|
|
|
13
14
|
import { parseArgv, usage } from "./cli/argv.ts"
|
|
14
15
|
import { defaultConfigPath, formatConfigError, loadConfig } from "./config/load.ts"
|
|
15
16
|
import { parseShowList, SHOW_CATEGORIES, type ShowCategory } from "./discovery/show.ts"
|
|
17
|
+
import { formatDiscoveryRootLabel } from "./discovery/rootLabel.ts"
|
|
16
18
|
import { walk, type FileEntry } from "./discovery/walk.ts"
|
|
17
19
|
import { openInBrowser } from "./serve/openBrowser.ts"
|
|
18
20
|
import { startServer } from "./serve/server.ts"
|
|
@@ -36,8 +38,6 @@ import { useUpdateNotice } from "./update/useUpdateNotice.ts"
|
|
|
36
38
|
* `Quit` in Browser tears down the renderer and exits, which propagates
|
|
37
39
|
* naturally — the cleanup effect still fires before process.exit completes.
|
|
38
40
|
*/
|
|
39
|
-
export type SidebarMode = "auto" | "on" | "off"
|
|
40
|
-
|
|
41
41
|
const pathIsDirectory = async (path: string): Promise<boolean> => {
|
|
42
42
|
try {
|
|
43
43
|
return (await stat(path)).isDirectory()
|
|
@@ -66,7 +66,7 @@ export const resolveDiscoveryRoot = async ({
|
|
|
66
66
|
readonly defaultRoot: "cwd" | "git"
|
|
67
67
|
readonly cwd: string
|
|
68
68
|
}): Promise<string> => {
|
|
69
|
-
if (cliRoot !== null) return cliRoot
|
|
69
|
+
if (cliRoot !== null) return resolve(cwd, cliRoot)
|
|
70
70
|
if (defaultRoot === "git") return findGitRoot(cwd)
|
|
71
71
|
return cwd
|
|
72
72
|
}
|
|
@@ -106,9 +106,10 @@ interface DiscoverShellProps {
|
|
|
106
106
|
* and the full vocabulary; the underlying categories remain
|
|
107
107
|
* independent everywhere else. */
|
|
108
108
|
readonly initialShow: readonly ShowCategory[]
|
|
109
|
+
readonly rootLabel?: string
|
|
109
110
|
readonly extensions: readonly string[]
|
|
110
|
-
readonly
|
|
111
|
-
readonly
|
|
111
|
+
readonly wrapWidth: number
|
|
112
|
+
readonly initialWrap: boolean
|
|
112
113
|
readonly startupFocus: StartupFocus
|
|
113
114
|
}
|
|
114
115
|
|
|
@@ -116,9 +117,10 @@ export const DiscoverShell = ({
|
|
|
116
117
|
target,
|
|
117
118
|
initialQuery,
|
|
118
119
|
initialShow,
|
|
120
|
+
rootLabel = target,
|
|
119
121
|
extensions,
|
|
120
|
-
|
|
121
|
-
|
|
122
|
+
wrapWidth,
|
|
123
|
+
initialWrap,
|
|
122
124
|
startupFocus,
|
|
123
125
|
}: DiscoverShellProps) => {
|
|
124
126
|
const updateNotice = useUpdateNotice()
|
|
@@ -194,10 +196,10 @@ export const DiscoverShell = ({
|
|
|
194
196
|
<Browser
|
|
195
197
|
files={files}
|
|
196
198
|
initialQuery={initialQuery}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
+
wrapWidth={wrapWidth}
|
|
200
|
+
initialWrap={initialWrap}
|
|
201
|
+
rootLabel={rootLabel}
|
|
199
202
|
discoveryStatus={discoveryStatus}
|
|
200
|
-
sidebarMode={sidebarMode}
|
|
201
203
|
startupFocus={startupFocus}
|
|
202
204
|
updateNotice={updateNotice}
|
|
203
205
|
onToggleAll={() => {
|
|
@@ -240,6 +242,14 @@ if (import.meta.main) {
|
|
|
240
242
|
console.log(usage)
|
|
241
243
|
process.exit(0)
|
|
242
244
|
}
|
|
245
|
+
if (args.wrapConflict) {
|
|
246
|
+
console.error("house: --wrap and --no-wrap cannot be used together")
|
|
247
|
+
process.exit(2)
|
|
248
|
+
}
|
|
249
|
+
if (Bun.argv.slice(2).includes("--width") && args.width === null) {
|
|
250
|
+
console.error("house: --width requires a positive integer")
|
|
251
|
+
process.exit(2)
|
|
252
|
+
}
|
|
243
253
|
if (args.version) {
|
|
244
254
|
console.log(pkg.version)
|
|
245
255
|
process.exit(0)
|
|
@@ -282,13 +292,34 @@ if (import.meta.main) {
|
|
|
282
292
|
args.focus === "sidebar" || args.focus === "reader" || args.focus === "filter"
|
|
283
293
|
? args.focus
|
|
284
294
|
: null,
|
|
295
|
+
width:
|
|
296
|
+
args.width === null
|
|
297
|
+
? null
|
|
298
|
+
: (() => {
|
|
299
|
+
const n = Number.parseInt(args.width, 10)
|
|
300
|
+
if (!/^\d+$/.test(args.width) || !Number.isSafeInteger(n) || n <= 0) {
|
|
301
|
+
console.error(`house: --width must be a positive integer, got "${args.width}"`)
|
|
302
|
+
process.exit(2)
|
|
303
|
+
}
|
|
304
|
+
return n
|
|
305
|
+
})(),
|
|
306
|
+
wrap: args.wrap,
|
|
285
307
|
},
|
|
286
308
|
}),
|
|
287
309
|
).catch((err: unknown) => {
|
|
288
310
|
console.error(`house: ${formatConfigError(err)}`)
|
|
289
311
|
process.exit(2)
|
|
290
312
|
})
|
|
291
|
-
const {
|
|
313
|
+
const {
|
|
314
|
+
theme: themeId,
|
|
315
|
+
tone,
|
|
316
|
+
extensions,
|
|
317
|
+
show,
|
|
318
|
+
focus: startupFocus,
|
|
319
|
+
defaultRoot,
|
|
320
|
+
width: wrapWidth,
|
|
321
|
+
wrap: initialWrap,
|
|
322
|
+
} = config
|
|
292
323
|
const themeDef = getThemeDefinition(themeId)
|
|
293
324
|
if (themeDef === undefined) {
|
|
294
325
|
// Unreachable: Config.schema validated themeId against themeDefinitions.
|
|
@@ -297,16 +328,6 @@ if (import.meta.main) {
|
|
|
297
328
|
}
|
|
298
329
|
setActiveTheme(themeDef, tone)
|
|
299
330
|
|
|
300
|
-
let maxWidth: number | null = null
|
|
301
|
-
if (args.width !== null) {
|
|
302
|
-
const n = Number.parseInt(args.width, 10)
|
|
303
|
-
if (!Number.isFinite(n) || n <= 0) {
|
|
304
|
-
console.error(`house: --width must be a positive integer, got "${args.width}"`)
|
|
305
|
-
process.exit(2)
|
|
306
|
-
}
|
|
307
|
-
maxWidth = n
|
|
308
|
-
}
|
|
309
|
-
|
|
310
331
|
const cwd = process.cwd()
|
|
311
332
|
const discoveryRoot = await resolveDiscoveryRoot({ cliRoot: args.root, defaultRoot, cwd })
|
|
312
333
|
const initialQuery = resolveInitialQuery({ pathArg: args.path, discoveryRoot, cwd })
|
|
@@ -349,14 +370,6 @@ if (import.meta.main) {
|
|
|
349
370
|
process.on("SIGTERM", shutdown)
|
|
350
371
|
// Bun.serve keeps the event loop alive until stop().
|
|
351
372
|
} else {
|
|
352
|
-
let sidebarMode: SidebarMode = "auto"
|
|
353
|
-
if (args.sidebar !== null) {
|
|
354
|
-
if (args.sidebar !== "auto" && args.sidebar !== "on" && args.sidebar !== "off") {
|
|
355
|
-
console.error(`house: --sidebar must be "auto", "on", or "off", got "${args.sidebar}"`)
|
|
356
|
-
process.exit(2)
|
|
357
|
-
}
|
|
358
|
-
sidebarMode = args.sidebar
|
|
359
|
-
}
|
|
360
373
|
if (args.focus !== null) {
|
|
361
374
|
if (args.focus !== "sidebar" && args.focus !== "reader" && args.focus !== "filter") {
|
|
362
375
|
console.error(
|
|
@@ -370,10 +383,10 @@ if (import.meta.main) {
|
|
|
370
383
|
initialQuery,
|
|
371
384
|
themeId,
|
|
372
385
|
tone,
|
|
373
|
-
|
|
386
|
+
wrapWidth,
|
|
387
|
+
initialWrap,
|
|
374
388
|
show,
|
|
375
389
|
extensions,
|
|
376
|
-
sidebarMode,
|
|
377
390
|
startupFocus,
|
|
378
391
|
updateCheck: !args.noUpdateCheck,
|
|
379
392
|
})
|
|
@@ -385,10 +398,10 @@ interface TuiBootOptions {
|
|
|
385
398
|
readonly initialQuery: string
|
|
386
399
|
readonly themeId: string
|
|
387
400
|
readonly tone: "dark" | "light"
|
|
388
|
-
readonly
|
|
401
|
+
readonly wrapWidth: number
|
|
402
|
+
readonly initialWrap: boolean
|
|
389
403
|
readonly show: readonly ShowCategory[]
|
|
390
404
|
readonly extensions: readonly string[]
|
|
391
|
-
readonly sidebarMode: SidebarMode
|
|
392
405
|
readonly startupFocus: StartupFocus
|
|
393
406
|
/** Run the npm-registry probe and surface the "update available" notice.
|
|
394
407
|
* False suppresses both the toast and the quit-time print. */
|
|
@@ -400,10 +413,10 @@ async function runTui({
|
|
|
400
413
|
initialQuery,
|
|
401
414
|
themeId,
|
|
402
415
|
tone,
|
|
403
|
-
|
|
416
|
+
wrapWidth,
|
|
417
|
+
initialWrap,
|
|
404
418
|
show,
|
|
405
419
|
extensions,
|
|
406
|
-
sidebarMode,
|
|
407
420
|
startupFocus,
|
|
408
421
|
updateCheck,
|
|
409
422
|
}: TuiBootOptions): Promise<void> {
|
|
@@ -445,9 +458,10 @@ async function runTui({
|
|
|
445
458
|
target={discoveryRoot}
|
|
446
459
|
initialQuery={initialQuery}
|
|
447
460
|
initialShow={show}
|
|
461
|
+
rootLabel={formatDiscoveryRootLabel({ discoveryRoot, home: homedir() })}
|
|
448
462
|
extensions={extensions}
|
|
449
|
-
|
|
450
|
-
|
|
463
|
+
wrapWidth={wrapWidth}
|
|
464
|
+
initialWrap={initialWrap}
|
|
451
465
|
startupFocus={startupFocus}
|
|
452
466
|
/>
|
|
453
467
|
</RegistryProvider>,
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort clipboard write for macOS/Linux.
|
|
3
|
+
*
|
|
4
|
+
* The selected tool is fed through stdin so large or multiline markdown can be
|
|
5
|
+
* copied without shell quoting issues.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface ClipboardCommand {
|
|
9
|
+
readonly cmd: string
|
|
10
|
+
readonly args: readonly string[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type WhichFn = (command: string) => string | null | undefined
|
|
14
|
+
|
|
15
|
+
interface CopyToClipboardDeps {
|
|
16
|
+
readonly platform?: NodeJS.Platform
|
|
17
|
+
readonly which?: WhichFn
|
|
18
|
+
readonly spawn?: (command: ClipboardCommand, text: string) => Promise<number>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const resolveClipboardCommand = (
|
|
22
|
+
platform: NodeJS.Platform,
|
|
23
|
+
which: WhichFn,
|
|
24
|
+
): ClipboardCommand | null => {
|
|
25
|
+
if (platform === "darwin") return { cmd: "pbcopy", args: [] }
|
|
26
|
+
if (platform !== "linux") return null
|
|
27
|
+
if (which("wl-copy")) return { cmd: "wl-copy", args: [] }
|
|
28
|
+
if (which("xclip")) return { cmd: "xclip", args: ["-selection", "clipboard"] }
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const spawnClipboardCommand = async (command: ClipboardCommand, text: string): Promise<number> => {
|
|
33
|
+
const proc = Bun.spawn([command.cmd, ...command.args], {
|
|
34
|
+
stdin: new Response(text),
|
|
35
|
+
stdout: "ignore",
|
|
36
|
+
stderr: "ignore",
|
|
37
|
+
})
|
|
38
|
+
return await proc.exited
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const copyTextToClipboard = async (
|
|
42
|
+
text: string,
|
|
43
|
+
{
|
|
44
|
+
platform = process.platform,
|
|
45
|
+
which = Bun.which.bind(Bun),
|
|
46
|
+
spawn = spawnClipboardCommand,
|
|
47
|
+
}: CopyToClipboardDeps = {},
|
|
48
|
+
): Promise<void> => {
|
|
49
|
+
const command = resolveClipboardCommand(platform, which)
|
|
50
|
+
if (!command) throw new Error("no clipboard tool found")
|
|
51
|
+
const exitCode = await spawn(command, text)
|
|
52
|
+
if (exitCode !== 0) throw new Error("clipboard command failed")
|
|
53
|
+
}
|
package/src/keymap/browser.ts
CHANGED
|
@@ -23,6 +23,7 @@ export interface BrowserCtx {
|
|
|
23
23
|
* hint gate so the hint only appears when there is something to clear. */
|
|
24
24
|
readonly filterQuery: string
|
|
25
25
|
readonly paletteOpen: boolean
|
|
26
|
+
readonly wrapEnabled: boolean
|
|
26
27
|
readonly setFocus: (next: BrowserFocus | ((prev: BrowserFocus) => BrowserFocus)) => void
|
|
27
28
|
readonly setSelectedIndex: (updater: (prev: number) => number) => void
|
|
28
29
|
/** Toggle `shown` and adjust focus per DESIGN.md §7.1 (see s-behavior table). */
|
|
@@ -33,6 +34,7 @@ export interface BrowserCtx {
|
|
|
33
34
|
* without first reopening with `/` and backspacing. */
|
|
34
35
|
readonly clearAndOpenFilter: () => void
|
|
35
36
|
readonly openPalette: () => void
|
|
37
|
+
readonly toggleWrap: () => void
|
|
36
38
|
readonly cycleTheme: (delta: 1 | -1) => void
|
|
37
39
|
readonly toggleTone: () => void
|
|
38
40
|
readonly quit: () => void
|
|
@@ -41,6 +43,10 @@ export interface BrowserCtx {
|
|
|
41
43
|
/** Suspend the TUI, hand the TTY to `$EDITOR`, resume and re-read on
|
|
42
44
|
* exit. No-op when nothing is selected; gating is the binding's job. */
|
|
43
45
|
readonly editCurrent: () => void
|
|
46
|
+
/** Copy the currently-selected file's raw contents to the system
|
|
47
|
+
* clipboard. No-op when nothing is selected; palette gating is the
|
|
48
|
+
* primary availability control. */
|
|
49
|
+
readonly copyCurrentContents: () => void
|
|
44
50
|
/** Toggle hidden + gitignored discovery axes together (#145 — UI sugar
|
|
45
51
|
* for `shift+a`; the underlying flags stay independent everywhere
|
|
46
52
|
* else). Snapshots the current selected path so it can be restored
|
|
@@ -162,6 +168,15 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
|
|
|
162
168
|
// does not write back to the TOML config.
|
|
163
169
|
run: (c) => c.toggleAll(),
|
|
164
170
|
},
|
|
171
|
+
{
|
|
172
|
+
id: "reader.wrap.toggle",
|
|
173
|
+
group: "Global",
|
|
174
|
+
description: "Toggle reader wrap",
|
|
175
|
+
hint: "wrap",
|
|
176
|
+
keys: ["w"],
|
|
177
|
+
hintWhen: inputClosed,
|
|
178
|
+
run: (c) => c.toggleWrap(),
|
|
179
|
+
},
|
|
165
180
|
{
|
|
166
181
|
id: "theme.next",
|
|
167
182
|
group: "Global",
|
package/src/layout/resolve.ts
CHANGED
|
@@ -50,11 +50,3 @@ export const defaultPreferredWidth = (viewport: number): number =>
|
|
|
50
50
|
*/
|
|
51
51
|
export const canFitInline = (viewport: number): boolean =>
|
|
52
52
|
viewport >= SIDEBAR_MIN_WIDTH + DIVIDER_WIDTH + READER_MIN_WIDTH
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Initial sidebar visibility for `--sidebar=auto`. Always true — every
|
|
56
|
-
* viewport now boots on the sidebar (narrow: as the single visible screen;
|
|
57
|
-
* wide: as the focused inline pane). `--sidebar=off` is the only way to
|
|
58
|
-
* boot directly into the reader.
|
|
59
|
-
*/
|
|
60
|
-
export const initialShownForAuto = (_viewport: number): boolean => true
|