@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/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 maxWidth: number | null
111
- readonly sidebarMode: SidebarMode
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
- maxWidth,
121
- sidebarMode,
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
- maxWidth={maxWidth}
198
- emptyRootLabel={target}
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 { theme: themeId, tone, extensions, show, focus: startupFocus, defaultRoot } = config
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
- maxWidth,
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 maxWidth: number | null
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
- maxWidth,
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
- maxWidth={maxWidth}
450
- sidebarMode={sidebarMode}
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
+ }
@@ -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",
@@ -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