@carlesandres/house 0.3.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 +179 -0
- package/LICENSE +21 -0
- package/README.md +99 -0
- package/package.json +67 -0
- package/src/Browser.tsx +472 -0
- package/src/Footer.tsx +151 -0
- package/src/HelpOverlay.tsx +130 -0
- package/src/cli/argv.ts +106 -0
- package/src/discovery/filter.ts +56 -0
- package/src/discovery/walk.ts +143 -0
- package/src/index.tsx +265 -0
- package/src/io/readFile.ts +14 -0
- package/src/keymap/browser.ts +229 -0
- package/src/keymap/keymap.ts +86 -0
- package/src/serve/css.ts +120 -0
- package/src/serve/openBrowser.ts +19 -0
- package/src/serve/render.ts +56 -0
- package/src/serve/server.ts +163 -0
- package/src/theme/atom.ts +23 -0
- package/src/theme/colors.ts +79 -0
- package/src/theme/loader.ts +110 -0
- package/src/theme/registry.ts +12 -0
- package/src/theme/resolve.ts +168 -0
- package/src/theme/themes/aura.json +58 -0
- package/src/theme/themes/ayu.json +69 -0
- package/src/theme/themes/carbonfox.json +201 -0
- package/src/theme/themes/catppuccin-frappe.json +186 -0
- package/src/theme/themes/catppuccin-macchiato.json +186 -0
- package/src/theme/themes/catppuccin.json +212 -0
- package/src/theme/themes/cobalt2.json +181 -0
- package/src/theme/themes/cursor.json +202 -0
- package/src/theme/themes/dracula.json +172 -0
- package/src/theme/themes/everforest.json +194 -0
- package/src/theme/themes/flexoki.json +190 -0
- package/src/theme/themes/github.json +186 -0
- package/src/theme/themes/gruvbox.json +195 -0
- package/src/theme/themes/kanagawa.json +180 -0
- package/src/theme/themes/lucent-orng.json +186 -0
- package/src/theme/themes/material.json +188 -0
- package/src/theme/themes/matrix.json +180 -0
- package/src/theme/themes/mercury.json +198 -0
- package/src/theme/themes/monokai.json +174 -0
- package/src/theme/themes/nightowl.json +174 -0
- package/src/theme/themes/nord.json +176 -0
- package/src/theme/themes/one-dark.json +184 -0
- package/src/theme/themes/opencode.json +198 -0
- package/src/theme/themes/orng.json +202 -0
- package/src/theme/themes/osaka-jade.json +193 -0
- package/src/theme/themes/palenight.json +175 -0
- package/src/theme/themes/rosepine.json +187 -0
- package/src/theme/themes/solarized.json +176 -0
- package/src/theme/themes/synthwave84.json +179 -0
- package/src/theme/themes/tokyonight.json +196 -0
- package/src/theme/themes/vercel.json +198 -0
- package/src/theme/themes/vesper.json +171 -0
- package/src/theme/themes/zenburn.json +176 -0
- package/src/theme/types.ts +109 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HelpOverlay — modal panel listing the keymap.
|
|
3
|
+
*
|
|
4
|
+
* Renders absolute-positioned over the rest of the UI. Iterates the
|
|
5
|
+
* KeyBinding[] array directly; there is no separate hand-written list of
|
|
6
|
+
* keys — the dispatcher and the help text are the same source of truth.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { KeyBinding } from "./keymap/keymap.ts"
|
|
10
|
+
import { colors } from "./theme/colors.ts"
|
|
11
|
+
|
|
12
|
+
export interface HelpOverlayProps<C> {
|
|
13
|
+
readonly bindings: readonly KeyBinding<C>[]
|
|
14
|
+
readonly viewportWidth: number
|
|
15
|
+
readonly viewportHeight: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const formatKeys = (keys: readonly string[]): string => keys.join(", ")
|
|
19
|
+
|
|
20
|
+
interface Row {
|
|
21
|
+
readonly key: string
|
|
22
|
+
readonly text: string
|
|
23
|
+
readonly kind: "header" | "binding" | "spacer" | "footer"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const buildRows = <C,>(bindings: readonly KeyBinding<C>[]): Row[] => {
|
|
27
|
+
const order: string[] = []
|
|
28
|
+
const grouped = new Map<string, KeyBinding<C>[]>()
|
|
29
|
+
for (const b of bindings) {
|
|
30
|
+
if (!b.group) continue
|
|
31
|
+
if (!grouped.has(b.group)) {
|
|
32
|
+
grouped.set(b.group, [])
|
|
33
|
+
order.push(b.group)
|
|
34
|
+
}
|
|
35
|
+
grouped.get(b.group)!.push(b)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Width of the keys column: longest formatted-keys string across all
|
|
39
|
+
// shown bindings, plus a small gap before descriptions.
|
|
40
|
+
let keyColumn = 0
|
|
41
|
+
for (const list of grouped.values()) {
|
|
42
|
+
for (const b of list) keyColumn = Math.max(keyColumn, formatKeys(b.keys).length)
|
|
43
|
+
}
|
|
44
|
+
const gap = 2
|
|
45
|
+
const padTo = keyColumn + gap
|
|
46
|
+
|
|
47
|
+
const rows: Row[] = []
|
|
48
|
+
for (let i = 0; i < order.length; i++) {
|
|
49
|
+
const group = order[i]!
|
|
50
|
+
// A spacer before *every* group, including the first. Without one
|
|
51
|
+
// before the first, opentui collapses the header onto the first
|
|
52
|
+
// binding row — appears to be an interaction between the title
|
|
53
|
+
// border and the first child of a padded column.
|
|
54
|
+
rows.push({ key: `spacer-before-${group}`, text: " ", kind: "spacer" })
|
|
55
|
+
rows.push({ key: `header-${group}`, text: group, kind: "header" })
|
|
56
|
+
for (const b of grouped.get(group)!) {
|
|
57
|
+
rows.push({
|
|
58
|
+
key: `binding-${b.id}`,
|
|
59
|
+
text: ` ${formatKeys(b.keys).padEnd(padTo)}${b.description}`,
|
|
60
|
+
kind: "binding",
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
rows.push({ key: "spacer-footer", text: " ", kind: "spacer" })
|
|
65
|
+
rows.push({ key: "footer", text: "press ? or esc to dismiss", kind: "footer" })
|
|
66
|
+
return rows
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const HelpOverlay = <C,>({
|
|
70
|
+
bindings,
|
|
71
|
+
viewportWidth,
|
|
72
|
+
viewportHeight,
|
|
73
|
+
}: HelpOverlayProps<C>) => {
|
|
74
|
+
const rows = buildRows(bindings)
|
|
75
|
+
|
|
76
|
+
const overlayWidth = Math.min(viewportWidth - 4, 64)
|
|
77
|
+
const desiredHeight = rows.length + 2 // border top + bottom
|
|
78
|
+
const overlayHeight = Math.min(viewportHeight - 4, desiredHeight + 2) // +2 for vertical padding
|
|
79
|
+
const left = Math.max(0, Math.floor((viewportWidth - overlayWidth) / 2))
|
|
80
|
+
const top = Math.max(0, Math.floor((viewportHeight - overlayHeight) / 2))
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<box
|
|
84
|
+
position="absolute"
|
|
85
|
+
left={left}
|
|
86
|
+
top={top}
|
|
87
|
+
width={overlayWidth}
|
|
88
|
+
height={overlayHeight}
|
|
89
|
+
zIndex={10}
|
|
90
|
+
title=" Help "
|
|
91
|
+
titleAlignment="left"
|
|
92
|
+
style={{
|
|
93
|
+
border: true,
|
|
94
|
+
borderColor: colors.borderActive,
|
|
95
|
+
padding: 1,
|
|
96
|
+
flexDirection: "column",
|
|
97
|
+
backgroundColor: colors.surface,
|
|
98
|
+
}}
|
|
99
|
+
>
|
|
100
|
+
{rows.map((row) => {
|
|
101
|
+
switch (row.kind) {
|
|
102
|
+
case "header":
|
|
103
|
+
return (
|
|
104
|
+
<text
|
|
105
|
+
key={row.key}
|
|
106
|
+
wrapMode="none"
|
|
107
|
+
content={row.text}
|
|
108
|
+
style={{ fg: colors.borderActive, attributes: 1 /* bold */ }}
|
|
109
|
+
/>
|
|
110
|
+
)
|
|
111
|
+
case "footer":
|
|
112
|
+
return (
|
|
113
|
+
<text
|
|
114
|
+
key={row.key}
|
|
115
|
+
wrapMode="none"
|
|
116
|
+
content={row.text}
|
|
117
|
+
style={{ fg: colors.textMuted }}
|
|
118
|
+
/>
|
|
119
|
+
)
|
|
120
|
+
case "spacer":
|
|
121
|
+
return <text key={row.key} content=" " />
|
|
122
|
+
case "binding":
|
|
123
|
+
return (
|
|
124
|
+
<text key={row.key} wrapMode="none" content={row.text} style={{ fg: colors.text }} />
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
})}
|
|
128
|
+
</box>
|
|
129
|
+
)
|
|
130
|
+
}
|
package/src/cli/argv.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { themeDefinitions } from "../theme/registry.ts"
|
|
2
|
+
|
|
3
|
+
export interface ParsedArgs {
|
|
4
|
+
/** First positional argument, or null if none was given. */
|
|
5
|
+
readonly path: string | null
|
|
6
|
+
/** Value of `--theme <id>`, or null. Validated by the boot layer against the registry. */
|
|
7
|
+
readonly theme: string | null
|
|
8
|
+
/** Value of `--tone dark|light`, or null. Validated by the boot layer. */
|
|
9
|
+
readonly tone: string | null
|
|
10
|
+
/** Value of `--width <N>`, or null. Validated by the boot layer (must be a positive integer). */
|
|
11
|
+
readonly width: string | null
|
|
12
|
+
/** True when `--all` was passed: include hidden + gitignored files in discovery. */
|
|
13
|
+
readonly all: boolean
|
|
14
|
+
/** Value of `--sort <mode>` (`dirs-first` or `files-first`), or null. Validated by the boot layer. */
|
|
15
|
+
readonly sort: string | null
|
|
16
|
+
/** True when `--serve` was passed: serve the given file as HTML, skip TUI. */
|
|
17
|
+
readonly serve: boolean
|
|
18
|
+
/** Value of `--port <N>`, or null. Validated by the boot layer. */
|
|
19
|
+
readonly port: string | null
|
|
20
|
+
/** True when `--help` was passed. */
|
|
21
|
+
readonly help: boolean
|
|
22
|
+
/** True when `--version` was passed. */
|
|
23
|
+
readonly version: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Minimal argv parser.
|
|
28
|
+
*
|
|
29
|
+
* Does not validate flag values — boot layers do, so error messages can
|
|
30
|
+
* reference domain knowledge (registered themes, valid integer ranges)
|
|
31
|
+
* without coupling the parser to it.
|
|
32
|
+
*/
|
|
33
|
+
export const parseArgv = (argv: readonly string[]): ParsedArgs => {
|
|
34
|
+
let path: string | null = null
|
|
35
|
+
let theme: string | null = null
|
|
36
|
+
let tone: string | null = null
|
|
37
|
+
let width: string | null = null
|
|
38
|
+
let all = false
|
|
39
|
+
let sort: string | null = null
|
|
40
|
+
let serve = false
|
|
41
|
+
let port: string | null = null
|
|
42
|
+
let help = false
|
|
43
|
+
let version = false
|
|
44
|
+
|
|
45
|
+
for (let i = 0; i < argv.length; i++) {
|
|
46
|
+
const arg = argv[i]!
|
|
47
|
+
switch (arg) {
|
|
48
|
+
case "--theme":
|
|
49
|
+
theme = argv[i + 1] ?? null
|
|
50
|
+
i++
|
|
51
|
+
continue
|
|
52
|
+
case "--tone":
|
|
53
|
+
tone = argv[i + 1] ?? null
|
|
54
|
+
i++
|
|
55
|
+
continue
|
|
56
|
+
case "--width":
|
|
57
|
+
width = argv[i + 1] ?? null
|
|
58
|
+
i++
|
|
59
|
+
continue
|
|
60
|
+
case "--all":
|
|
61
|
+
all = true
|
|
62
|
+
continue
|
|
63
|
+
case "--sort":
|
|
64
|
+
sort = argv[i + 1] ?? null
|
|
65
|
+
i++
|
|
66
|
+
continue
|
|
67
|
+
case "--serve":
|
|
68
|
+
serve = true
|
|
69
|
+
continue
|
|
70
|
+
case "--port":
|
|
71
|
+
port = argv[i + 1] ?? null
|
|
72
|
+
i++
|
|
73
|
+
continue
|
|
74
|
+
case "--help":
|
|
75
|
+
case "-h":
|
|
76
|
+
help = true
|
|
77
|
+
continue
|
|
78
|
+
case "--version":
|
|
79
|
+
case "-v":
|
|
80
|
+
version = true
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
if (path === null && !arg.startsWith("-")) {
|
|
84
|
+
path = arg
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { path, theme, tone, width, all, sort, serve, port, help, version }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const themeList = themeDefinitions.map((t) => t.id).join(", ")
|
|
92
|
+
|
|
93
|
+
export const usage = `usage: house [path] [options]
|
|
94
|
+
|
|
95
|
+
path file or directory; defaults to the current directory
|
|
96
|
+
|
|
97
|
+
options:
|
|
98
|
+
--theme <id> color theme: ${themeList} (default: opencode)
|
|
99
|
+
--tone <mode> dark or light (default: dark)
|
|
100
|
+
--width <N> cap rendered markdown width at N columns
|
|
101
|
+
--all include hidden and gitignored files in discovery
|
|
102
|
+
--sort <mode> sidebar order: dirs-first (default) or files-first
|
|
103
|
+
--serve serve the given file as HTML in the browser (skips TUI)
|
|
104
|
+
--port <N> port for --serve (default: OS-assigned)
|
|
105
|
+
-h, --help show this help and exit
|
|
106
|
+
-v, --version print version and exit`
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fuzzy filter for the sidebar.
|
|
3
|
+
*
|
|
4
|
+
* Matching: case-insensitive subsequence on `relativePath`. A query "drm"
|
|
5
|
+
* matches "docs/readme.md". Scoring (higher is better):
|
|
6
|
+
* - +10 for a match at the start of the string or right after `/`
|
|
7
|
+
* (word boundary — what the user typed lines up with a path segment)
|
|
8
|
+
* - +5 when the current match is adjacent to the previous one
|
|
9
|
+
* (consecutive runs read as "drm" matching the literal substring)
|
|
10
|
+
* - +1 otherwise
|
|
11
|
+
*
|
|
12
|
+
* The scorer is intentionally tiny — the only goal is to surface the
|
|
13
|
+
* "obvious" match for short queries against a few hundred paths. A full
|
|
14
|
+
* fzf-style scorer (with bonuses for camelCase, separators, etc.) is
|
|
15
|
+
* deferred until the simple version proves insufficient.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { FileEntry } from "./walk.ts"
|
|
19
|
+
|
|
20
|
+
export const fuzzyScore = (query: string, target: string): number | null => {
|
|
21
|
+
if (query.length === 0) return 0
|
|
22
|
+
const q = query.toLowerCase()
|
|
23
|
+
const t = target.toLowerCase()
|
|
24
|
+
let qi = 0
|
|
25
|
+
let score = 0
|
|
26
|
+
let lastMatch = -2
|
|
27
|
+
for (let i = 0; i < t.length && qi < q.length; i++) {
|
|
28
|
+
if (t[i] !== q[qi]) continue
|
|
29
|
+
const isWordStart = i === 0 || t[i - 1] === "/"
|
|
30
|
+
score += isWordStart ? 10 : 1
|
|
31
|
+
if (lastMatch === i - 1) score += 5
|
|
32
|
+
lastMatch = i
|
|
33
|
+
qi++
|
|
34
|
+
}
|
|
35
|
+
if (qi < q.length) return null
|
|
36
|
+
return score
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Filter and re-rank a file list by a query. Empty query returns the input
|
|
41
|
+
* unchanged (preserves the discovery sort order). Non-empty query keeps
|
|
42
|
+
* matches only, sorted by score desc; ties fall back to the input order so
|
|
43
|
+
* the discovery sort still leaks through.
|
|
44
|
+
*/
|
|
45
|
+
export const filterFiles = (files: readonly FileEntry[], query: string): readonly FileEntry[] => {
|
|
46
|
+
if (query.length === 0) return files
|
|
47
|
+
const scored: { file: FileEntry; score: number; index: number }[] = []
|
|
48
|
+
for (let i = 0; i < files.length; i++) {
|
|
49
|
+
const file = files[i]!
|
|
50
|
+
const score = fuzzyScore(query, file.relativePath)
|
|
51
|
+
if (score === null) continue
|
|
52
|
+
scored.push({ file, score, index: i })
|
|
53
|
+
}
|
|
54
|
+
scored.sort((a, b) => b.score - a.score || a.index - b.index)
|
|
55
|
+
return scored.map((s) => s.file)
|
|
56
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises"
|
|
2
|
+
import { extname, join, relative, resolve } from "node:path"
|
|
3
|
+
import { Data, Effect } from "effect"
|
|
4
|
+
import ignore, { type Ignore } from "ignore"
|
|
5
|
+
|
|
6
|
+
export interface FileEntry {
|
|
7
|
+
/** Absolute path on disk. */
|
|
8
|
+
readonly path: string
|
|
9
|
+
/** Path relative to the discovery root, with forward slashes. */
|
|
10
|
+
readonly relativePath: string
|
|
11
|
+
/** File basename. */
|
|
12
|
+
readonly name: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type SortOrder = "dirs-first" | "files-first"
|
|
16
|
+
|
|
17
|
+
export interface WalkOptions {
|
|
18
|
+
/** Include hidden files and gitignored entries. Hard skips still apply. */
|
|
19
|
+
readonly all?: boolean
|
|
20
|
+
/** Group order within each directory. Default `dirs-first`. */
|
|
21
|
+
readonly sort?: SortOrder
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class DiscoveryError extends Data.TaggedError("DiscoveryError")<{
|
|
25
|
+
readonly root: string
|
|
26
|
+
readonly cause: unknown
|
|
27
|
+
}> {}
|
|
28
|
+
|
|
29
|
+
const MARKDOWN_EXTENSIONS = new Set([".md", ".markdown", ".mdx"])
|
|
30
|
+
const HARD_SKIP_DIRS = new Set(["node_modules", ".git", ".venv"])
|
|
31
|
+
|
|
32
|
+
interface IgnoreLevel {
|
|
33
|
+
readonly dir: string
|
|
34
|
+
readonly ig: Ignore
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const isIgnored = (
|
|
38
|
+
entryPath: string,
|
|
39
|
+
isDirectory: boolean,
|
|
40
|
+
levels: readonly IgnoreLevel[],
|
|
41
|
+
): boolean => {
|
|
42
|
+
for (const { dir, ig } of levels) {
|
|
43
|
+
const rel = relative(dir, entryPath)
|
|
44
|
+
if (!rel || rel.startsWith("..")) continue
|
|
45
|
+
const candidate = isDirectory ? `${rel}/` : rel
|
|
46
|
+
if (ig.ignores(candidate)) return true
|
|
47
|
+
}
|
|
48
|
+
return false
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const tryLoadGitignore = async (dir: string): Promise<Ignore | null> => {
|
|
52
|
+
try {
|
|
53
|
+
const content = await readFile(join(dir, ".gitignore"), "utf8")
|
|
54
|
+
return ignore().add(content)
|
|
55
|
+
} catch {
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const sortEntries = <T extends { name: string; isDirectory: () => boolean }>(
|
|
61
|
+
entries: readonly T[],
|
|
62
|
+
order: SortOrder,
|
|
63
|
+
): T[] =>
|
|
64
|
+
[...entries].sort((a, b) => {
|
|
65
|
+
const aDir = a.isDirectory()
|
|
66
|
+
const bDir = b.isDirectory()
|
|
67
|
+
if (aDir !== bDir) {
|
|
68
|
+
if (order === "files-first") return aDir ? 1 : -1
|
|
69
|
+
return aDir ? -1 : 1
|
|
70
|
+
}
|
|
71
|
+
return a.name.localeCompare(b.name)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const walkDir = async (
|
|
75
|
+
dirPath: string,
|
|
76
|
+
rootPath: string,
|
|
77
|
+
parentLevels: readonly IgnoreLevel[],
|
|
78
|
+
results: FileEntry[],
|
|
79
|
+
opts: { all: boolean; sort: SortOrder },
|
|
80
|
+
): Promise<void> => {
|
|
81
|
+
let levels = parentLevels
|
|
82
|
+
if (!opts.all) {
|
|
83
|
+
const ig = await tryLoadGitignore(dirPath)
|
|
84
|
+
if (ig) levels = [...parentLevels, { dir: dirPath, ig }]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const raw = await readdir(dirPath, { withFileTypes: true })
|
|
88
|
+
for (const entry of sortEntries(raw, opts.sort)) {
|
|
89
|
+
// Never follow symlinks — cycle hazard, and a markdown reader doesn't
|
|
90
|
+
// need them. May be relaxed (files only) in a later iteration.
|
|
91
|
+
if (entry.isSymbolicLink()) continue
|
|
92
|
+
|
|
93
|
+
const entryPath = join(dirPath, entry.name)
|
|
94
|
+
|
|
95
|
+
if (entry.isDirectory()) {
|
|
96
|
+
if (HARD_SKIP_DIRS.has(entry.name)) continue
|
|
97
|
+
if (!opts.all && entry.name.startsWith(".")) continue
|
|
98
|
+
if (!opts.all && isIgnored(entryPath, true, levels)) continue
|
|
99
|
+
await walkDir(entryPath, rootPath, levels, results, opts)
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!entry.isFile()) continue
|
|
104
|
+
if (!opts.all && entry.name.startsWith(".")) continue
|
|
105
|
+
if (!MARKDOWN_EXTENSIONS.has(extname(entry.name).toLowerCase())) continue
|
|
106
|
+
if (!opts.all && isIgnored(entryPath, false, levels)) continue
|
|
107
|
+
|
|
108
|
+
results.push({
|
|
109
|
+
path: entryPath,
|
|
110
|
+
relativePath: relative(rootPath, entryPath),
|
|
111
|
+
name: entry.name,
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Walk a directory tree and return markdown files in a stable order.
|
|
118
|
+
*
|
|
119
|
+
* Rules (see DESIGN.md §6):
|
|
120
|
+
* - Extensions: `.md`, `.markdown`, `.mdx`.
|
|
121
|
+
* - Hard skips (always): `node_modules`, `.git`, `.venv`.
|
|
122
|
+
* - Hidden files/dirs (leading `.`) skipped unless `all: true`.
|
|
123
|
+
* - `.gitignore` honored, including nested `.gitignore` files.
|
|
124
|
+
* - Symlinks not followed.
|
|
125
|
+
* - Sort: alphabetical within each group; directories before files
|
|
126
|
+
* (`dirs-first`, default) or files before directories (`files-first`).
|
|
127
|
+
*/
|
|
128
|
+
export const walk = (
|
|
129
|
+
root: string,
|
|
130
|
+
options: WalkOptions = {},
|
|
131
|
+
): Effect.Effect<readonly FileEntry[], DiscoveryError> =>
|
|
132
|
+
Effect.tryPromise({
|
|
133
|
+
try: async () => {
|
|
134
|
+
const absRoot = resolve(root)
|
|
135
|
+
const results: FileEntry[] = []
|
|
136
|
+
await walkDir(absRoot, absRoot, [], results, {
|
|
137
|
+
all: options.all ?? false,
|
|
138
|
+
sort: options.sort ?? "dirs-first",
|
|
139
|
+
})
|
|
140
|
+
return results
|
|
141
|
+
},
|
|
142
|
+
catch: (cause) => new DiscoveryError({ root, cause }),
|
|
143
|
+
})
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
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
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { stat } from "node:fs/promises"
|
|
12
|
+
import { createCliRenderer, SyntaxStyle } from "@opentui/core"
|
|
13
|
+
import { createRoot, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
|
|
14
|
+
import { RegistryProvider, useAtomSet, useAtomValue } from "@effect/atom-react"
|
|
15
|
+
import { Effect } from "effect"
|
|
16
|
+
import { useMemo } from "react"
|
|
17
|
+
import pkg from "../package.json" with { type: "json" }
|
|
18
|
+
import { Browser } from "./Browser.tsx"
|
|
19
|
+
import { parseArgv, usage } from "./cli/argv.ts"
|
|
20
|
+
import { walk, type SortOrder } from "./discovery/walk.ts"
|
|
21
|
+
import { readFileText } from "./io/readFile.ts"
|
|
22
|
+
import { openInBrowser } from "./serve/openBrowser.ts"
|
|
23
|
+
import { startServer } from "./serve/server.ts"
|
|
24
|
+
import { colors, setActiveTheme } from "./theme/colors.ts"
|
|
25
|
+
import { themeAtom, type ThemeState } from "./theme/atom.ts"
|
|
26
|
+
import { getThemeDefinition, isThemeId, themeDefinitions } from "./theme/registry.ts"
|
|
27
|
+
|
|
28
|
+
export interface AppProps {
|
|
29
|
+
/** Markdown source to render. */
|
|
30
|
+
readonly content: string
|
|
31
|
+
/** Optional title shown in the frame border. Defaults to a generic label. */
|
|
32
|
+
readonly title?: string
|
|
33
|
+
/** Cap the rendered markdown's width at N columns (left-aligned). Null = fill the pane. */
|
|
34
|
+
readonly maxWidth?: number | null
|
|
35
|
+
/** Override quit behavior. Tests pass a spy; the binary uses the default. */
|
|
36
|
+
readonly onQuit?: () => void
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const App = ({ content, title = "house", maxWidth = null, onQuit }: AppProps) => {
|
|
40
|
+
const renderer = useRenderer()
|
|
41
|
+
const { width, height } = useTerminalDimensions()
|
|
42
|
+
const theme = useAtomValue(themeAtom)
|
|
43
|
+
const setTheme = useAtomSet(themeAtom)
|
|
44
|
+
const syntaxStyle = useMemo(() => SyntaxStyle.fromStyles(colors.syntax), [theme])
|
|
45
|
+
|
|
46
|
+
const cycleTheme = (delta: 1 | -1) => {
|
|
47
|
+
const idx = themeDefinitions.findIndex((d) => d.id === theme.id)
|
|
48
|
+
const next = themeDefinitions[(idx + delta + themeDefinitions.length) % themeDefinitions.length]
|
|
49
|
+
if (!next) return
|
|
50
|
+
setActiveTheme(next, theme.tone)
|
|
51
|
+
setTheme({ id: next.id, tone: theme.tone })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const toggleTone = () => {
|
|
55
|
+
const nextTone = theme.tone === "dark" ? "light" : "dark"
|
|
56
|
+
const def = getThemeDefinition(theme.id)
|
|
57
|
+
if (def) setActiveTheme(def, nextTone)
|
|
58
|
+
setTheme({ id: theme.id, tone: nextTone })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
useKeyboard((key) => {
|
|
62
|
+
if (key.name === "q" || (key.ctrl && key.name === "c")) {
|
|
63
|
+
if (onQuit) {
|
|
64
|
+
onQuit()
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
renderer?.destroy()
|
|
68
|
+
process.exit(0)
|
|
69
|
+
}
|
|
70
|
+
if (key.name === "t" && !key.shift) cycleTheme(1)
|
|
71
|
+
if (key.name === "t" && key.shift) cycleTheme(-1)
|
|
72
|
+
if (key.name === "l" && key.shift) toggleTone()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<box style={{ width, height, flexDirection: "column", backgroundColor: colors.background }}>
|
|
77
|
+
<box
|
|
78
|
+
title={` ${title} `}
|
|
79
|
+
titleAlignment="left"
|
|
80
|
+
style={{
|
|
81
|
+
border: true,
|
|
82
|
+
borderColor: colors.border,
|
|
83
|
+
padding: 1,
|
|
84
|
+
flexGrow: 1,
|
|
85
|
+
flexShrink: 1,
|
|
86
|
+
backgroundColor: colors.background,
|
|
87
|
+
}}
|
|
88
|
+
>
|
|
89
|
+
<scrollbox
|
|
90
|
+
style={{
|
|
91
|
+
scrollY: true,
|
|
92
|
+
scrollX: false,
|
|
93
|
+
flexGrow: 1,
|
|
94
|
+
flexShrink: 1,
|
|
95
|
+
backgroundColor: colors.background,
|
|
96
|
+
}}
|
|
97
|
+
focused
|
|
98
|
+
>
|
|
99
|
+
<markdown
|
|
100
|
+
content={content}
|
|
101
|
+
syntaxStyle={syntaxStyle}
|
|
102
|
+
fg={colors.text}
|
|
103
|
+
bg={colors.background}
|
|
104
|
+
conceal
|
|
105
|
+
style={{ width: maxWidth ?? "100%" }}
|
|
106
|
+
/>
|
|
107
|
+
</scrollbox>
|
|
108
|
+
</box>
|
|
109
|
+
</box>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (import.meta.main) {
|
|
114
|
+
const args = parseArgv(Bun.argv.slice(2))
|
|
115
|
+
|
|
116
|
+
if (args.help) {
|
|
117
|
+
console.log(usage)
|
|
118
|
+
process.exit(0)
|
|
119
|
+
}
|
|
120
|
+
if (args.version) {
|
|
121
|
+
console.log(pkg.version)
|
|
122
|
+
process.exit(0)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const themeId = args.theme ?? "opencode"
|
|
126
|
+
if (!isThemeId(themeId)) {
|
|
127
|
+
const known = themeDefinitions.map((t) => t.id).join(", ")
|
|
128
|
+
console.error(`house: unknown theme "${themeId}". Known: ${known}`)
|
|
129
|
+
process.exit(2)
|
|
130
|
+
}
|
|
131
|
+
const tone = args.tone ?? "dark"
|
|
132
|
+
if (tone !== "dark" && tone !== "light") {
|
|
133
|
+
console.error(`house: --tone must be "dark" or "light", got "${tone}"`)
|
|
134
|
+
process.exit(2)
|
|
135
|
+
}
|
|
136
|
+
const themeDef = getThemeDefinition(themeId)
|
|
137
|
+
if (themeDef === undefined) {
|
|
138
|
+
console.error(`house: unknown theme "${themeId}"`)
|
|
139
|
+
process.exit(2)
|
|
140
|
+
}
|
|
141
|
+
setActiveTheme(themeDef, tone)
|
|
142
|
+
|
|
143
|
+
let maxWidth: number | null = null
|
|
144
|
+
if (args.width !== null) {
|
|
145
|
+
const n = Number.parseInt(args.width, 10)
|
|
146
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
147
|
+
console.error(`house: --width must be a positive integer, got "${args.width}"`)
|
|
148
|
+
process.exit(2)
|
|
149
|
+
}
|
|
150
|
+
maxWidth = n
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const target = args.path ?? "."
|
|
154
|
+
|
|
155
|
+
if (args.serve) {
|
|
156
|
+
let stats: Awaited<ReturnType<typeof stat>>
|
|
157
|
+
try {
|
|
158
|
+
stats = await stat(target)
|
|
159
|
+
} catch (err) {
|
|
160
|
+
console.error(`house: cannot access ${target}: ${String(err)}`)
|
|
161
|
+
process.exit(1)
|
|
162
|
+
}
|
|
163
|
+
if (stats.isDirectory()) {
|
|
164
|
+
console.error(`house: --serve requires a file, got directory ${target}`)
|
|
165
|
+
process.exit(2)
|
|
166
|
+
}
|
|
167
|
+
let port = 0
|
|
168
|
+
if (args.port !== null) {
|
|
169
|
+
const n = Number.parseInt(args.port, 10)
|
|
170
|
+
if (!Number.isFinite(n) || n < 0 || n > 65535) {
|
|
171
|
+
console.error(`house: --port must be 0-65535, got "${args.port}"`)
|
|
172
|
+
process.exit(2)
|
|
173
|
+
}
|
|
174
|
+
port = n
|
|
175
|
+
}
|
|
176
|
+
const handle = startServer({ path: target, port })
|
|
177
|
+
console.log(`house serving ${target} at ${handle.url}`)
|
|
178
|
+
console.log("press ctrl+c to stop")
|
|
179
|
+
openInBrowser(handle.url)
|
|
180
|
+
const shutdown = async () => {
|
|
181
|
+
await handle.stop()
|
|
182
|
+
process.exit(0)
|
|
183
|
+
}
|
|
184
|
+
process.on("SIGINT", shutdown)
|
|
185
|
+
process.on("SIGTERM", shutdown)
|
|
186
|
+
// Bun.serve keeps the event loop alive until stop().
|
|
187
|
+
} else {
|
|
188
|
+
let sort: SortOrder = "dirs-first"
|
|
189
|
+
if (args.sort !== null) {
|
|
190
|
+
if (args.sort !== "dirs-first" && args.sort !== "files-first") {
|
|
191
|
+
console.error(`house: --sort must be "dirs-first" or "files-first", got "${args.sort}"`)
|
|
192
|
+
process.exit(2)
|
|
193
|
+
}
|
|
194
|
+
sort = args.sort
|
|
195
|
+
}
|
|
196
|
+
await runTui({ target, themeId, tone, maxWidth, all: args.all, sort })
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
interface TuiBootOptions {
|
|
201
|
+
readonly target: string
|
|
202
|
+
readonly themeId: string
|
|
203
|
+
readonly tone: "dark" | "light"
|
|
204
|
+
readonly maxWidth: number | null
|
|
205
|
+
readonly all: boolean
|
|
206
|
+
readonly sort: SortOrder
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function runTui({
|
|
210
|
+
target,
|
|
211
|
+
themeId,
|
|
212
|
+
tone,
|
|
213
|
+
maxWidth,
|
|
214
|
+
all,
|
|
215
|
+
sort,
|
|
216
|
+
}: TuiBootOptions): Promise<void> {
|
|
217
|
+
let stats: Awaited<ReturnType<typeof stat>>
|
|
218
|
+
try {
|
|
219
|
+
stats = await stat(target)
|
|
220
|
+
} catch (err) {
|
|
221
|
+
console.error(`house: cannot access ${target}: ${String(err)}`)
|
|
222
|
+
process.exit(1)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const renderer = await createCliRenderer({ exitOnCtrlC: false })
|
|
226
|
+
const initialTheme: ThemeState = { id: themeId, tone }
|
|
227
|
+
|
|
228
|
+
if (stats.isDirectory()) {
|
|
229
|
+
const files = await Effect.runPromise(
|
|
230
|
+
walk(target, { all, sort }).pipe(
|
|
231
|
+
Effect.tapError((err) =>
|
|
232
|
+
Effect.sync(() => {
|
|
233
|
+
console.error(`house: cannot walk ${target}: ${String(err.cause)}`)
|
|
234
|
+
}),
|
|
235
|
+
),
|
|
236
|
+
),
|
|
237
|
+
).catch(() => {
|
|
238
|
+
process.exit(1)
|
|
239
|
+
})
|
|
240
|
+
if (!Array.isArray(files)) process.exit(1)
|
|
241
|
+
createRoot(renderer).render(
|
|
242
|
+
<RegistryProvider initialValues={[[themeAtom, initialTheme]]}>
|
|
243
|
+
<Browser files={files} title={target} maxWidth={maxWidth} />
|
|
244
|
+
</RegistryProvider>,
|
|
245
|
+
)
|
|
246
|
+
} else {
|
|
247
|
+
const content = await Effect.runPromise(
|
|
248
|
+
readFileText(target).pipe(
|
|
249
|
+
Effect.tapError((err) =>
|
|
250
|
+
Effect.sync(() => {
|
|
251
|
+
console.error(`house: cannot read ${err.path}: ${String(err.cause)}`)
|
|
252
|
+
}),
|
|
253
|
+
),
|
|
254
|
+
),
|
|
255
|
+
).catch(() => {
|
|
256
|
+
process.exit(1)
|
|
257
|
+
})
|
|
258
|
+
if (typeof content !== "string") process.exit(1)
|
|
259
|
+
createRoot(renderer).render(
|
|
260
|
+
<RegistryProvider initialValues={[[themeAtom, initialTheme]]}>
|
|
261
|
+
<App content={content} title={target} maxWidth={maxWidth} />
|
|
262
|
+
</RegistryProvider>,
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
}
|