@carlesandres/house 0.4.13 → 0.5.0
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 +32 -2
- package/README.md +10 -8
- package/dist/bin.js +92 -0
- package/dist/index.js +10005 -0
- package/package.json +15 -3
- package/src/Browser.tsx +0 -1210
- package/src/CommandPalette.tsx +0 -214
- package/src/Footer.tsx +0 -258
- package/src/Header.tsx +0 -71
- package/src/PromptRow.tsx +0 -51
- package/src/Spinner.tsx +0 -39
- package/src/StatusIndicator.tsx +0 -55
- package/src/StatusPopover.tsx +0 -166
- package/src/brand.ts +0 -7
- package/src/cli/argv.ts +0 -179
- package/src/commands/buildCommands.ts +0 -98
- package/src/commands/paletteOnlyCommands.ts +0 -27
- package/src/commands/score.ts +0 -78
- package/src/commands/types.ts +0 -26
- package/src/config/load.ts +0 -381
- package/src/config/save.ts +0 -95
- package/src/discovery/filter.ts +0 -148
- package/src/discovery/show.ts +0 -48
- package/src/discovery/walk.ts +0 -209
- package/src/index.tsx +0 -464
- package/src/io/clipboard.ts +0 -53
- package/src/io/editor.ts +0 -162
- package/src/io/readFile.ts +0 -14
- package/src/keymap/browser.ts +0 -335
- package/src/keymap/displayKey.ts +0 -17
- package/src/keymap/keymap.ts +0 -95
- package/src/layout/resolve.ts +0 -52
- package/src/layout/sidebarEmptyState.ts +0 -7
- package/src/layout/sidebarRow.ts +0 -69
- package/src/markdown/frontmatter.ts +0 -62
- package/src/serve/css.ts +0 -120
- package/src/serve/openBrowser.ts +0 -19
- package/src/serve/render.ts +0 -56
- package/src/serve/server.ts +0 -166
- package/src/theme/atom.ts +0 -23
- package/src/theme/colors.ts +0 -86
- package/src/theme/loader.ts +0 -110
- package/src/theme/registry.ts +0 -12
- package/src/theme/resolve.ts +0 -168
- package/src/theme/themes/aura.json +0 -58
- package/src/theme/themes/ayu.json +0 -69
- package/src/theme/themes/carbonfox.json +0 -201
- package/src/theme/themes/catppuccin-frappe.json +0 -186
- package/src/theme/themes/catppuccin-macchiato.json +0 -186
- package/src/theme/themes/catppuccin.json +0 -212
- package/src/theme/themes/cobalt2.json +0 -181
- package/src/theme/themes/cursor.json +0 -202
- package/src/theme/themes/dracula.json +0 -172
- package/src/theme/themes/everforest.json +0 -194
- package/src/theme/themes/flexoki.json +0 -190
- package/src/theme/themes/github.json +0 -186
- package/src/theme/themes/gruvbox.json +0 -195
- package/src/theme/themes/kanagawa.json +0 -180
- package/src/theme/themes/lucent-orng.json +0 -186
- package/src/theme/themes/material.json +0 -188
- package/src/theme/themes/matrix.json +0 -180
- package/src/theme/themes/mercury.json +0 -198
- package/src/theme/themes/monokai.json +0 -174
- package/src/theme/themes/nightowl.json +0 -174
- package/src/theme/themes/nord.json +0 -176
- package/src/theme/themes/one-dark.json +0 -184
- package/src/theme/themes/opencode.json +0 -198
- package/src/theme/themes/orng.json +0 -202
- package/src/theme/themes/osaka-jade.json +0 -193
- package/src/theme/themes/palenight.json +0 -175
- package/src/theme/themes/rosepine.json +0 -187
- package/src/theme/themes/solarized.json +0 -180
- package/src/theme/themes/synthwave84.json +0 -179
- package/src/theme/themes/tokyonight.json +0 -196
- package/src/theme/themes/vercel.json +0 -198
- package/src/theme/themes/vesper.json +0 -171
- package/src/theme/themes/zenburn.json +0 -176
- package/src/theme/types.ts +0 -115
- package/src/tips.ts +0 -88
- package/src/ui/middleTruncate.ts +0 -27
- package/src/update/cache.ts +0 -77
- package/src/update/check.ts +0 -165
- package/src/update/compare.ts +0 -41
- package/src/update/notice.ts +0 -29
- package/src/update/runtime.ts +0 -48
- package/src/update/useUpdateNotice.ts +0 -24
package/src/discovery/walk.ts
DELETED
|
@@ -1,209 +0,0 @@
|
|
|
1
|
-
import { readdir, readFile } from "node:fs/promises"
|
|
2
|
-
import { extname, join, relative, resolve } from "node:path"
|
|
3
|
-
import { Data, Effect, Stream } from "effect"
|
|
4
|
-
import ignore, { type Ignore } from "ignore"
|
|
5
|
-
import type { ShowCategory } from "./show.ts"
|
|
6
|
-
|
|
7
|
-
export interface FileEntry {
|
|
8
|
-
/** Absolute path on disk. */
|
|
9
|
-
readonly path: string
|
|
10
|
-
/** Path relative to the discovery root, with forward slashes. */
|
|
11
|
-
readonly relativePath: string
|
|
12
|
-
/** File basename. */
|
|
13
|
-
readonly name: string
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export interface DiscoveryWarning {
|
|
17
|
-
readonly path: string
|
|
18
|
-
readonly cause: unknown
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface WalkOptions {
|
|
22
|
-
/** Categories of normally-skipped entries to opt into. Empty (the
|
|
23
|
-
* default) yields the conservative set: no dotfiles, no gitignored
|
|
24
|
-
* entries. Order is irrelevant — semantics are set membership. Hard
|
|
25
|
-
* skips (`node_modules`, `.git`, `.venv`) always apply. */
|
|
26
|
-
readonly show?: Iterable<ShowCategory>
|
|
27
|
-
/** Additional file extensions to include alongside `.md` / `.markdown`.
|
|
28
|
-
* Values may include or omit the leading dot. */
|
|
29
|
-
readonly extensions?: Iterable<string>
|
|
30
|
-
/** Non-fatal subtree read errors. Root-level failures still error the walk. */
|
|
31
|
-
readonly onWarning?: ((warning: DiscoveryWarning) => void) | null
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export class DiscoveryError extends Data.TaggedError("DiscoveryError")<{
|
|
35
|
-
readonly root: string
|
|
36
|
-
readonly cause: unknown
|
|
37
|
-
}> {}
|
|
38
|
-
|
|
39
|
-
const BASE_EXTENSIONS = new Set([".md", ".markdown"])
|
|
40
|
-
const HARD_SKIP_DIRS = new Set(["node_modules", ".git", ".venv"])
|
|
41
|
-
|
|
42
|
-
const normalizeExtension = (ext: string): string => {
|
|
43
|
-
const trimmed = ext.trim().toLowerCase()
|
|
44
|
-
return trimmed.startsWith(".") ? trimmed : `.${trimmed}`
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
interface IgnoreLevel {
|
|
48
|
-
readonly dir: string
|
|
49
|
-
readonly ig: Ignore
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const isIgnored = (
|
|
53
|
-
entryPath: string,
|
|
54
|
-
isDirectory: boolean,
|
|
55
|
-
levels: readonly IgnoreLevel[],
|
|
56
|
-
): boolean => {
|
|
57
|
-
for (const { dir, ig } of levels) {
|
|
58
|
-
const rel = relative(dir, entryPath)
|
|
59
|
-
if (!rel || rel.startsWith("..")) continue
|
|
60
|
-
const candidate = isDirectory ? `${rel}/` : rel
|
|
61
|
-
if (ig.ignores(candidate)) return true
|
|
62
|
-
}
|
|
63
|
-
return false
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const tryLoadGitignore = async (dir: string): Promise<Ignore | null> => {
|
|
67
|
-
try {
|
|
68
|
-
const content = await readFile(join(dir, ".gitignore"), "utf8")
|
|
69
|
-
return ignore().add(content)
|
|
70
|
-
} catch {
|
|
71
|
-
return null
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const sortEntries = <T extends { name: string; isDirectory: () => boolean }>(
|
|
76
|
-
entries: readonly T[],
|
|
77
|
-
): T[] =>
|
|
78
|
-
[...entries].sort((a, b) => {
|
|
79
|
-
const aDir = a.isDirectory()
|
|
80
|
-
const bDir = b.isDirectory()
|
|
81
|
-
if (aDir !== bDir) {
|
|
82
|
-
return aDir ? 1 : -1
|
|
83
|
-
}
|
|
84
|
-
return a.name.localeCompare(b.name)
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* DFS generator. Yields each markdown FileEntry as it is discovered, before
|
|
89
|
-
* descending further. Per-directory sort still happens before yielding so
|
|
90
|
-
* arrival order is files-first and alphabetical within each group.
|
|
91
|
-
*
|
|
92
|
-
* Cancellation: `signal.aborted` is checked between syscalls. Node's
|
|
93
|
-
* `readdir` doesn't accept an AbortSignal, so a single in-flight `readdir`
|
|
94
|
-
* on a slow filesystem still runs to completion before we notice — the
|
|
95
|
-
* generator only exits at the next checkpoint.
|
|
96
|
-
*/
|
|
97
|
-
async function* walkDirGen(
|
|
98
|
-
dirPath: string,
|
|
99
|
-
rootPath: string,
|
|
100
|
-
parentLevels: readonly IgnoreLevel[],
|
|
101
|
-
opts: { showHidden: boolean; showGitignored: boolean; extensions: ReadonlySet<string> },
|
|
102
|
-
onWarning: ((warning: DiscoveryWarning) => void) | null,
|
|
103
|
-
signal: AbortSignal,
|
|
104
|
-
): AsyncGenerator<FileEntry, void, void> {
|
|
105
|
-
if (signal.aborted) return
|
|
106
|
-
const isRoot = dirPath === rootPath
|
|
107
|
-
|
|
108
|
-
let levels = parentLevels
|
|
109
|
-
if (!opts.showGitignored) {
|
|
110
|
-
const ig = await tryLoadGitignore(dirPath)
|
|
111
|
-
if (signal.aborted) return
|
|
112
|
-
if (ig) levels = [...parentLevels, { dir: dirPath, ig }]
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
let raw
|
|
116
|
-
try {
|
|
117
|
-
raw = await readdir(dirPath, { withFileTypes: true })
|
|
118
|
-
} catch (error) {
|
|
119
|
-
if (isRoot) throw error
|
|
120
|
-
onWarning?.({ path: dirPath, cause: error })
|
|
121
|
-
return
|
|
122
|
-
}
|
|
123
|
-
if (signal.aborted) return
|
|
124
|
-
|
|
125
|
-
for (const entry of sortEntries(raw)) {
|
|
126
|
-
if (signal.aborted) return
|
|
127
|
-
|
|
128
|
-
// Never follow symlinks — cycle hazard, and a markdown reader doesn't
|
|
129
|
-
// need them. May be relaxed (files only) in a later iteration.
|
|
130
|
-
if (entry.isSymbolicLink()) continue
|
|
131
|
-
|
|
132
|
-
const entryPath = join(dirPath, entry.name)
|
|
133
|
-
|
|
134
|
-
if (entry.isDirectory()) {
|
|
135
|
-
if (HARD_SKIP_DIRS.has(entry.name)) continue
|
|
136
|
-
if (!opts.showHidden && entry.name.startsWith(".")) continue
|
|
137
|
-
if (!opts.showGitignored && isIgnored(entryPath, true, levels)) continue
|
|
138
|
-
yield* walkDirGen(entryPath, rootPath, levels, opts, onWarning, signal)
|
|
139
|
-
continue
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
if (!entry.isFile()) continue
|
|
143
|
-
if (!opts.showHidden && entry.name.startsWith(".")) continue
|
|
144
|
-
const allowed = opts.extensions
|
|
145
|
-
if (!allowed.has(extname(entry.name).toLowerCase())) continue
|
|
146
|
-
if (!opts.showGitignored && isIgnored(entryPath, false, levels)) continue
|
|
147
|
-
|
|
148
|
-
yield {
|
|
149
|
-
path: entryPath,
|
|
150
|
-
relativePath: relative(rootPath, entryPath),
|
|
151
|
-
name: entry.name,
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Stream markdown files under `root`. Entries arrive in DFS order respecting
|
|
158
|
-
* the per-directory sort. The stream is interruptible at syscall boundaries:
|
|
159
|
-
* the consumer's teardown trips an AbortController, and the generator exits
|
|
160
|
-
* at its next `signal.aborted` check.
|
|
161
|
-
*
|
|
162
|
-
* Rules (see DESIGN.md §6):
|
|
163
|
-
* - Extensions: `.md`, `.markdown`, plus configured extras.
|
|
164
|
-
* - Hard skips (always): `node_modules`, `.git`, `.venv`.
|
|
165
|
-
* - Hidden files/dirs (leading `.`) skipped unless `show` contains `"hidden"`.
|
|
166
|
-
* - `.gitignore` honored, including nested `.gitignore` files.
|
|
167
|
-
* - Symlinks not followed.
|
|
168
|
-
* - Sort: files before directories, alphabetical within each group.
|
|
169
|
-
*/
|
|
170
|
-
export const walk = (
|
|
171
|
-
root: string,
|
|
172
|
-
options: WalkOptions = {},
|
|
173
|
-
): Stream.Stream<FileEntry, DiscoveryError> => {
|
|
174
|
-
const absRoot = resolve(root)
|
|
175
|
-
const show = new Set<ShowCategory>(options.show ?? [])
|
|
176
|
-
const extensions = new Set(BASE_EXTENSIONS)
|
|
177
|
-
for (const ext of options.extensions ?? []) extensions.add(normalizeExtension(ext))
|
|
178
|
-
const opts = {
|
|
179
|
-
showHidden: show.has("hidden"),
|
|
180
|
-
showGitignored: show.has("gitignored"),
|
|
181
|
-
extensions,
|
|
182
|
-
}
|
|
183
|
-
const onWarning = options.onWarning ?? null
|
|
184
|
-
const controller = new AbortController()
|
|
185
|
-
const iterable: AsyncIterable<FileEntry> = {
|
|
186
|
-
[Symbol.asyncIterator]() {
|
|
187
|
-
const gen = walkDirGen(absRoot, absRoot, [], opts, onWarning, controller.signal)
|
|
188
|
-
return {
|
|
189
|
-
next: () => gen.next(),
|
|
190
|
-
return: async (value?: void) => {
|
|
191
|
-
controller.abort()
|
|
192
|
-
return gen.return(value as void)
|
|
193
|
-
},
|
|
194
|
-
}
|
|
195
|
-
},
|
|
196
|
-
}
|
|
197
|
-
return Stream.fromAsyncIterable(iterable, (cause) => new DiscoveryError({ root, cause }))
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Test/convenience helper: collect the full walk into an array. Mirrors the
|
|
202
|
-
* pre-streaming `walk()` signature so call sites that don't need streaming
|
|
203
|
-
* (notably tests) stay terse.
|
|
204
|
-
*/
|
|
205
|
-
export const walkToArray = (
|
|
206
|
-
root: string,
|
|
207
|
-
options: WalkOptions = {},
|
|
208
|
-
): Effect.Effect<readonly FileEntry[], DiscoveryError> =>
|
|
209
|
-
Stream.runCollect(walk(root, options)).pipe(Effect.map((chunk) => Array.from(chunk)))
|
package/src/index.tsx
DELETED
|
@@ -1,464 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bun
|
|
2
|
-
/** house — entry point. Boots the browser TUI or `--serve` preview. */
|
|
3
|
-
|
|
4
|
-
import { stat } from "node:fs/promises"
|
|
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"
|
|
9
|
-
import { Cause, Duration, Effect, Fiber, Stream } from "effect"
|
|
10
|
-
import { useEffect, useRef, useState } from "react"
|
|
11
|
-
import pkg from "../package.json" with { type: "json" }
|
|
12
|
-
import { Browser, type StartupFocus } from "./Browser.tsx"
|
|
13
|
-
import { parseArgv, usage } from "./cli/argv.ts"
|
|
14
|
-
import { defaultConfigPath, formatConfigError, loadConfig } from "./config/load.ts"
|
|
15
|
-
import { parseShowList, SHOW_CATEGORIES, type ShowCategory } from "./discovery/show.ts"
|
|
16
|
-
import { walk, type FileEntry } from "./discovery/walk.ts"
|
|
17
|
-
import { openInBrowser } from "./serve/openBrowser.ts"
|
|
18
|
-
import { startServer } from "./serve/server.ts"
|
|
19
|
-
import { setActiveTheme } from "./theme/colors.ts"
|
|
20
|
-
import { themeAtom, type ThemeState } from "./theme/atom.ts"
|
|
21
|
-
import { getThemeDefinition } from "./theme/registry.ts"
|
|
22
|
-
import { formatQuitNotice } from "./update/notice.ts"
|
|
23
|
-
import { currentUpdateInfo, startUpdateProbe } from "./update/runtime.ts"
|
|
24
|
-
import { useUpdateNotice } from "./update/useUpdateNotice.ts"
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* DiscoverShell — owns the streaming walk for the Browser. Mounts Browser
|
|
28
|
-
* immediately with `files=[]` and pushes entries as the stream emits.
|
|
29
|
-
*
|
|
30
|
-
* Batching: `Stream.groupedWithin(64, 60ms)` coalesces bursts so we don't
|
|
31
|
-
* trigger one React render per file. Tuned by feel — small enough that
|
|
32
|
-
* results still feel live on tiny trees, large enough to keep render
|
|
33
|
-
* frequency sane on big ones. Revisit if profiling says otherwise.
|
|
34
|
-
*
|
|
35
|
-
* Cancellation: the walk runs on a forked fiber; unmount interrupts it.
|
|
36
|
-
* `Quit` in Browser tears down the renderer and exits, which propagates
|
|
37
|
-
* naturally — the cleanup effect still fires before process.exit completes.
|
|
38
|
-
*/
|
|
39
|
-
const pathIsDirectory = async (path: string): Promise<boolean> => {
|
|
40
|
-
try {
|
|
41
|
-
return (await stat(path)).isDirectory()
|
|
42
|
-
} catch {
|
|
43
|
-
return false
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const findGitRoot = async (cwd: string): Promise<string> => {
|
|
48
|
-
const start = resolve(cwd)
|
|
49
|
-
let current = start
|
|
50
|
-
for (;;) {
|
|
51
|
-
if (await pathIsDirectory(resolve(current, ".git"))) return current
|
|
52
|
-
const parent = dirname(current)
|
|
53
|
-
if (parent === current) return start
|
|
54
|
-
current = parent
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export const resolveDiscoveryRoot = async ({
|
|
59
|
-
cliRoot,
|
|
60
|
-
defaultRoot,
|
|
61
|
-
cwd,
|
|
62
|
-
}: {
|
|
63
|
-
readonly cliRoot: string | null
|
|
64
|
-
readonly defaultRoot: "cwd" | "git"
|
|
65
|
-
readonly cwd: string
|
|
66
|
-
}): Promise<string> => {
|
|
67
|
-
if (cliRoot !== null) return cliRoot
|
|
68
|
-
if (defaultRoot === "git") return findGitRoot(cwd)
|
|
69
|
-
return cwd
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export const validateDiscoveryRoot = async (root: string): Promise<void> => {
|
|
73
|
-
let stats: Awaited<ReturnType<typeof stat>>
|
|
74
|
-
try {
|
|
75
|
-
stats = await stat(root)
|
|
76
|
-
} catch (err) {
|
|
77
|
-
throw new Error(`cannot access discovery root ${root}: ${String(err)}`)
|
|
78
|
-
}
|
|
79
|
-
if (!stats.isDirectory()) {
|
|
80
|
-
throw new Error(`discovery root must be a directory, got file ${root}`)
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export const formatPartialDiscoveryStatus = ({
|
|
85
|
-
skippedCount,
|
|
86
|
-
lastSkippedPath,
|
|
87
|
-
}: {
|
|
88
|
-
readonly skippedCount: number
|
|
89
|
-
readonly lastSkippedPath: string | null
|
|
90
|
-
}): string | null => {
|
|
91
|
-
if (skippedCount <= 0) return null
|
|
92
|
-
const noun = skippedCount === 1 ? "directory" : "directories"
|
|
93
|
-
const suffix = lastSkippedPath && skippedCount === 1 ? `: ${lastSkippedPath}` : ""
|
|
94
|
-
return `scan incomplete: skipped ${skippedCount} ${noun}${suffix}`
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export const formatFatalDiscoveryStatus = (): string => "scan failed: unable to read discovery root"
|
|
98
|
-
|
|
99
|
-
interface DiscoverShellProps {
|
|
100
|
-
readonly target: string
|
|
101
|
-
readonly initialQuery: string
|
|
102
|
-
/** Resolved discovery vocabulary from the config layer. The shift+a
|
|
103
|
-
* toggle (#145) is session-only sugar that flips between this set
|
|
104
|
-
* and the full vocabulary; the underlying categories remain
|
|
105
|
-
* independent everywhere else. */
|
|
106
|
-
readonly initialShow: readonly ShowCategory[]
|
|
107
|
-
readonly extensions: readonly string[]
|
|
108
|
-
readonly wrapWidth: number
|
|
109
|
-
readonly initialWrap: boolean
|
|
110
|
-
readonly startupFocus: StartupFocus
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export const DiscoverShell = ({
|
|
114
|
-
target,
|
|
115
|
-
initialQuery,
|
|
116
|
-
initialShow,
|
|
117
|
-
extensions,
|
|
118
|
-
wrapWidth,
|
|
119
|
-
initialWrap,
|
|
120
|
-
startupFocus,
|
|
121
|
-
}: DiscoverShellProps) => {
|
|
122
|
-
const updateNotice = useUpdateNotice()
|
|
123
|
-
const [show, setShow] = useState<readonly ShowCategory[]>(initialShow)
|
|
124
|
-
const [files, setFiles] = useState<readonly FileEntry[]>([])
|
|
125
|
-
const [scanning, setScanning] = useState<boolean>(true)
|
|
126
|
-
const [scanError, setScanError] = useState<string | null>(null)
|
|
127
|
-
const [skippedDirCount, setSkippedDirCount] = useState<number>(0)
|
|
128
|
-
const [lastSkippedDir, setLastSkippedDir] = useState<string | null>(null)
|
|
129
|
-
// Files arrive in a ref-tracked count so the status string can show
|
|
130
|
-
// "indexing… N" even when React hasn't yet flushed the latest setFiles.
|
|
131
|
-
const countRef = useRef(0)
|
|
132
|
-
|
|
133
|
-
useEffect(() => {
|
|
134
|
-
// Restart from a clean slate every time the discovery set changes.
|
|
135
|
-
// Required for the `all` toggle (#145): without this, a flip would
|
|
136
|
-
// concatenate the new walk onto stale entries and leave `scanning`
|
|
137
|
-
// stuck on whatever the previous walk last set it to.
|
|
138
|
-
setFiles([])
|
|
139
|
-
setScanning(true)
|
|
140
|
-
setScanError(null)
|
|
141
|
-
setSkippedDirCount(0)
|
|
142
|
-
setLastSkippedDir(null)
|
|
143
|
-
countRef.current = 0
|
|
144
|
-
const warnedProgram = walk(target, {
|
|
145
|
-
show,
|
|
146
|
-
extensions,
|
|
147
|
-
onWarning: ({ path }) => {
|
|
148
|
-
const relativePath = relative(resolve(target), path)
|
|
149
|
-
setSkippedDirCount((prev) => prev + 1)
|
|
150
|
-
setLastSkippedDir(
|
|
151
|
-
relativePath.length > 0 && !relativePath.startsWith("..") && !isAbsolute(relativePath)
|
|
152
|
-
? relativePath
|
|
153
|
-
: path,
|
|
154
|
-
)
|
|
155
|
-
},
|
|
156
|
-
}).pipe(
|
|
157
|
-
Stream.groupedWithin(64, Duration.millis(60)),
|
|
158
|
-
Stream.runForEach((chunk) =>
|
|
159
|
-
Effect.sync(() => {
|
|
160
|
-
const arr = Array.from(chunk)
|
|
161
|
-
if (arr.length === 0) return
|
|
162
|
-
countRef.current += arr.length
|
|
163
|
-
setFiles((prev) => [...prev, ...arr])
|
|
164
|
-
}),
|
|
165
|
-
),
|
|
166
|
-
Effect.matchCauseEffect({
|
|
167
|
-
onSuccess: () => Effect.sync(() => setScanning(false)),
|
|
168
|
-
onFailure: (cause) =>
|
|
169
|
-
Effect.sync(() => {
|
|
170
|
-
if (Cause.hasInterrupts(cause)) return
|
|
171
|
-
setScanError(formatFatalDiscoveryStatus())
|
|
172
|
-
setScanning(false)
|
|
173
|
-
}),
|
|
174
|
-
}),
|
|
175
|
-
)
|
|
176
|
-
const fiber = Effect.runFork(warnedProgram)
|
|
177
|
-
return () => {
|
|
178
|
-
Effect.runFork(Fiber.interrupt(fiber))
|
|
179
|
-
}
|
|
180
|
-
}, [target, show, extensions])
|
|
181
|
-
|
|
182
|
-
const discoveryStatus =
|
|
183
|
-
scanError ??
|
|
184
|
-
(scanning
|
|
185
|
-
? `indexing… ${countRef.current}`
|
|
186
|
-
: formatPartialDiscoveryStatus({
|
|
187
|
-
skippedCount: skippedDirCount,
|
|
188
|
-
lastSkippedPath: lastSkippedDir,
|
|
189
|
-
}))
|
|
190
|
-
|
|
191
|
-
return (
|
|
192
|
-
<Browser
|
|
193
|
-
files={files}
|
|
194
|
-
initialQuery={initialQuery}
|
|
195
|
-
wrapWidth={wrapWidth}
|
|
196
|
-
initialWrap={initialWrap}
|
|
197
|
-
emptyRootLabel={target}
|
|
198
|
-
discoveryStatus={discoveryStatus}
|
|
199
|
-
startupFocus={startupFocus}
|
|
200
|
-
updateNotice={updateNotice}
|
|
201
|
-
onToggleAll={() => {
|
|
202
|
-
// shift+a is the only place the categories are treated as a
|
|
203
|
-
// single thing. If every category is already on, fall back
|
|
204
|
-
// to "show none"; otherwise opt into the full vocabulary.
|
|
205
|
-
// Each press is a stable round-trip between [] and full.
|
|
206
|
-
const next: readonly ShowCategory[] =
|
|
207
|
-
show.length === SHOW_CATEGORIES.length ? [] : [...SHOW_CATEGORIES]
|
|
208
|
-
setShow(next)
|
|
209
|
-
}}
|
|
210
|
-
/>
|
|
211
|
-
)
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
export const resolveInitialQuery = ({
|
|
215
|
-
pathArg,
|
|
216
|
-
discoveryRoot,
|
|
217
|
-
cwd,
|
|
218
|
-
}: {
|
|
219
|
-
readonly pathArg: string | null
|
|
220
|
-
readonly discoveryRoot: string
|
|
221
|
-
readonly cwd: string
|
|
222
|
-
}): string => {
|
|
223
|
-
if (pathArg === null) return ""
|
|
224
|
-
const resolvedPath = resolve(cwd, pathArg)
|
|
225
|
-
const rel = relative(discoveryRoot, resolvedPath)
|
|
226
|
-
if (rel.length === 0) return ""
|
|
227
|
-
if (!rel.startsWith("..") && !isAbsolute(rel)) return rel
|
|
228
|
-
if (pathArg.startsWith("./")) return pathArg.slice(2)
|
|
229
|
-
return pathArg
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
let updateExitHookRegistered = false
|
|
233
|
-
|
|
234
|
-
if (import.meta.main) {
|
|
235
|
-
const args = parseArgv(Bun.argv.slice(2))
|
|
236
|
-
|
|
237
|
-
if (args.help) {
|
|
238
|
-
console.log(usage)
|
|
239
|
-
process.exit(0)
|
|
240
|
-
}
|
|
241
|
-
if (args.wrapConflict) {
|
|
242
|
-
console.error("house: --wrap and --no-wrap cannot be used together")
|
|
243
|
-
process.exit(2)
|
|
244
|
-
}
|
|
245
|
-
if (Bun.argv.slice(2).includes("--width") && args.width === null) {
|
|
246
|
-
console.error("house: --width requires a positive integer")
|
|
247
|
-
process.exit(2)
|
|
248
|
-
}
|
|
249
|
-
if (args.version) {
|
|
250
|
-
console.log(pkg.version)
|
|
251
|
-
process.exit(0)
|
|
252
|
-
}
|
|
253
|
-
if (args.configPath) {
|
|
254
|
-
console.log(defaultConfigPath())
|
|
255
|
-
process.exit(0)
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// Parse --show eagerly so an invalid token fails fast with the CLI-style
|
|
259
|
-
// "house: ..." message, before any I/O work in loadConfig kicks off.
|
|
260
|
-
let cliShow: readonly ShowCategory[] | null = null
|
|
261
|
-
if (args.show !== null) {
|
|
262
|
-
const parsed = parseShowList(args.show)
|
|
263
|
-
if (!parsed.ok) {
|
|
264
|
-
console.error(
|
|
265
|
-
`house: --show: unknown category "${parsed.invalid.join('", "')}" (valid: ${SHOW_CATEGORIES.join(", ")})`,
|
|
266
|
-
)
|
|
267
|
-
process.exit(2)
|
|
268
|
-
}
|
|
269
|
-
cliShow = parsed.value
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
const config = await Effect.runPromise(
|
|
273
|
-
loadConfig({
|
|
274
|
-
cli: {
|
|
275
|
-
theme: args.theme,
|
|
276
|
-
tone: args.tone,
|
|
277
|
-
extensions:
|
|
278
|
-
args.extensions === null
|
|
279
|
-
? null
|
|
280
|
-
: args.extensions
|
|
281
|
-
.split(",")
|
|
282
|
-
.map((s) => s.trim())
|
|
283
|
-
.filter(Boolean),
|
|
284
|
-
// `--show` replaces env/file when present (set semantics —
|
|
285
|
-
// no per-category merge across sources). `null` falls through.
|
|
286
|
-
show: cliShow,
|
|
287
|
-
focus:
|
|
288
|
-
args.focus === "sidebar" || args.focus === "reader" || args.focus === "filter"
|
|
289
|
-
? args.focus
|
|
290
|
-
: null,
|
|
291
|
-
width:
|
|
292
|
-
args.width === null
|
|
293
|
-
? null
|
|
294
|
-
: (() => {
|
|
295
|
-
const n = Number.parseInt(args.width, 10)
|
|
296
|
-
if (!/^\d+$/.test(args.width) || !Number.isSafeInteger(n) || n <= 0) {
|
|
297
|
-
console.error(`house: --width must be a positive integer, got "${args.width}"`)
|
|
298
|
-
process.exit(2)
|
|
299
|
-
}
|
|
300
|
-
return n
|
|
301
|
-
})(),
|
|
302
|
-
wrap: args.wrap,
|
|
303
|
-
},
|
|
304
|
-
}),
|
|
305
|
-
).catch((err: unknown) => {
|
|
306
|
-
console.error(`house: ${formatConfigError(err)}`)
|
|
307
|
-
process.exit(2)
|
|
308
|
-
})
|
|
309
|
-
const {
|
|
310
|
-
theme: themeId,
|
|
311
|
-
tone,
|
|
312
|
-
extensions,
|
|
313
|
-
show,
|
|
314
|
-
focus: startupFocus,
|
|
315
|
-
defaultRoot,
|
|
316
|
-
width: wrapWidth,
|
|
317
|
-
wrap: initialWrap,
|
|
318
|
-
} = config
|
|
319
|
-
const themeDef = getThemeDefinition(themeId)
|
|
320
|
-
if (themeDef === undefined) {
|
|
321
|
-
// Unreachable: Config.schema validated themeId against themeDefinitions.
|
|
322
|
-
console.error(`house: unknown theme "${themeId}"`)
|
|
323
|
-
process.exit(2)
|
|
324
|
-
}
|
|
325
|
-
setActiveTheme(themeDef, tone)
|
|
326
|
-
|
|
327
|
-
const cwd = process.cwd()
|
|
328
|
-
const discoveryRoot = await resolveDiscoveryRoot({ cliRoot: args.root, defaultRoot, cwd })
|
|
329
|
-
const initialQuery = resolveInitialQuery({ pathArg: args.path, discoveryRoot, cwd })
|
|
330
|
-
|
|
331
|
-
if (args.serve) {
|
|
332
|
-
const target = args.path
|
|
333
|
-
if (target === null) {
|
|
334
|
-
console.error("house: --serve requires a file path")
|
|
335
|
-
process.exit(2)
|
|
336
|
-
}
|
|
337
|
-
let stats: Awaited<ReturnType<typeof stat>>
|
|
338
|
-
try {
|
|
339
|
-
stats = await stat(target)
|
|
340
|
-
} catch (err) {
|
|
341
|
-
console.error(`house: cannot access ${target}: ${String(err)}`)
|
|
342
|
-
process.exit(1)
|
|
343
|
-
}
|
|
344
|
-
if (stats.isDirectory()) {
|
|
345
|
-
console.error(`house: --serve requires a file, got directory ${target}`)
|
|
346
|
-
process.exit(2)
|
|
347
|
-
}
|
|
348
|
-
let port = 0
|
|
349
|
-
if (args.port !== null) {
|
|
350
|
-
const n = Number.parseInt(args.port, 10)
|
|
351
|
-
if (!Number.isFinite(n) || n < 0 || n > 65535) {
|
|
352
|
-
console.error(`house: --port must be 0-65535, got "${args.port}"`)
|
|
353
|
-
process.exit(2)
|
|
354
|
-
}
|
|
355
|
-
port = n
|
|
356
|
-
}
|
|
357
|
-
const handle = startServer({ path: target, port })
|
|
358
|
-
console.log(`house serving ${target} at ${handle.url}`)
|
|
359
|
-
console.log("press ctrl+c to stop")
|
|
360
|
-
openInBrowser(handle.url)
|
|
361
|
-
const shutdown = async () => {
|
|
362
|
-
await handle.stop()
|
|
363
|
-
process.exit(0)
|
|
364
|
-
}
|
|
365
|
-
process.on("SIGINT", shutdown)
|
|
366
|
-
process.on("SIGTERM", shutdown)
|
|
367
|
-
// Bun.serve keeps the event loop alive until stop().
|
|
368
|
-
} else {
|
|
369
|
-
if (args.focus !== null) {
|
|
370
|
-
if (args.focus !== "sidebar" && args.focus !== "reader" && args.focus !== "filter") {
|
|
371
|
-
console.error(
|
|
372
|
-
`house: --focus must be "sidebar", "reader", or "filter", got "${args.focus}"`,
|
|
373
|
-
)
|
|
374
|
-
process.exit(2)
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
await runTui({
|
|
378
|
-
discoveryRoot,
|
|
379
|
-
initialQuery,
|
|
380
|
-
themeId,
|
|
381
|
-
tone,
|
|
382
|
-
wrapWidth,
|
|
383
|
-
initialWrap,
|
|
384
|
-
show,
|
|
385
|
-
extensions,
|
|
386
|
-
startupFocus,
|
|
387
|
-
updateCheck: !args.noUpdateCheck,
|
|
388
|
-
})
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
interface TuiBootOptions {
|
|
393
|
-
readonly discoveryRoot: string
|
|
394
|
-
readonly initialQuery: string
|
|
395
|
-
readonly themeId: string
|
|
396
|
-
readonly tone: "dark" | "light"
|
|
397
|
-
readonly wrapWidth: number
|
|
398
|
-
readonly initialWrap: boolean
|
|
399
|
-
readonly show: readonly ShowCategory[]
|
|
400
|
-
readonly extensions: readonly string[]
|
|
401
|
-
readonly startupFocus: StartupFocus
|
|
402
|
-
/** Run the npm-registry probe and surface the "update available" notice.
|
|
403
|
-
* False suppresses both the toast and the quit-time print. */
|
|
404
|
-
readonly updateCheck: boolean
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
async function runTui({
|
|
408
|
-
discoveryRoot,
|
|
409
|
-
initialQuery,
|
|
410
|
-
themeId,
|
|
411
|
-
tone,
|
|
412
|
-
wrapWidth,
|
|
413
|
-
initialWrap,
|
|
414
|
-
show,
|
|
415
|
-
extensions,
|
|
416
|
-
startupFocus,
|
|
417
|
-
updateCheck,
|
|
418
|
-
}: TuiBootOptions): Promise<void> {
|
|
419
|
-
if (updateCheck) {
|
|
420
|
-
// Fire the npm-registry probe in the background. Result lands in a
|
|
421
|
-
// module singleton; the React tree picks it up via `useUpdateNotice`
|
|
422
|
-
// for the footer toast, and the 'exit' hook below reads it
|
|
423
|
-
// synchronously for the scrollback print. Failures are silent — this
|
|
424
|
-
// whole feature is opportunistic.
|
|
425
|
-
startUpdateProbe(pkg.name, pkg.version)
|
|
426
|
-
// Register once per process. Multiple 'exit' listeners would print
|
|
427
|
-
// the notice multiple times if runTui were ever re-entered.
|
|
428
|
-
if (!updateExitHookRegistered) {
|
|
429
|
-
updateExitHookRegistered = true
|
|
430
|
-
process.on("exit", () => {
|
|
431
|
-
const info = currentUpdateInfo()
|
|
432
|
-
if (info) process.stderr.write(formatQuitNotice(info))
|
|
433
|
-
})
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
const renderer = await createCliRenderer({
|
|
438
|
-
exitOnCtrlC: false,
|
|
439
|
-
useMouse: true,
|
|
440
|
-
enableMouseMovement: true,
|
|
441
|
-
screenMode: "alternate-screen",
|
|
442
|
-
})
|
|
443
|
-
renderer.useMouse = true
|
|
444
|
-
const initialTheme: ThemeState = { id: themeId, tone }
|
|
445
|
-
try {
|
|
446
|
-
await validateDiscoveryRoot(discoveryRoot)
|
|
447
|
-
} catch (err) {
|
|
448
|
-
console.error(`house: ${err instanceof Error ? err.message : String(err)}`)
|
|
449
|
-
process.exit(1)
|
|
450
|
-
}
|
|
451
|
-
createRoot(renderer).render(
|
|
452
|
-
<RegistryProvider initialValues={[[themeAtom, initialTheme]]}>
|
|
453
|
-
<DiscoverShell
|
|
454
|
-
target={discoveryRoot}
|
|
455
|
-
initialQuery={initialQuery}
|
|
456
|
-
initialShow={show}
|
|
457
|
-
extensions={extensions}
|
|
458
|
-
wrapWidth={wrapWidth}
|
|
459
|
-
initialWrap={initialWrap}
|
|
460
|
-
startupFocus={startupFocus}
|
|
461
|
-
/>
|
|
462
|
-
</RegistryProvider>,
|
|
463
|
-
)
|
|
464
|
-
}
|