@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/Header.tsx
CHANGED
|
@@ -5,20 +5,113 @@
|
|
|
5
5
|
* current filename on the left, version on the right. The row is
|
|
6
6
|
* informational, not interactive — see issue #38 for the design discussion.
|
|
7
7
|
*
|
|
8
|
-
* Width degrades gracefully: the
|
|
9
|
-
*
|
|
10
|
-
* identity element. Always rendered — the row is
|
|
11
|
-
* viewport so the user never loses the filename
|
|
12
|
-
* the sidebar drawer overlays the reader on narrow
|
|
8
|
+
* Width degrades gracefully: the discovery root truncates/drops first, then
|
|
9
|
+
* the wordmark, then the version, then the filename truncates, leaving the
|
|
10
|
+
* brand mark as the irreducible identity element. Always rendered — the row is
|
|
11
|
+
* worth one cell on any viewport so the user never loses the filename/root
|
|
12
|
+
* indicator (notably, when the sidebar drawer overlays the reader on narrow
|
|
13
|
+
* viewports).
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
16
|
import pkg from "../package.json" with { type: "json" }
|
|
16
17
|
import { BRAND, BRAND_NAME } from "./brand.ts"
|
|
17
18
|
import { colors } from "./theme/colors.ts"
|
|
19
|
+
import { middleTruncate } from "./ui/middleTruncate.ts"
|
|
18
20
|
|
|
19
21
|
export const HEADER_HEIGHT = 1
|
|
20
22
|
|
|
21
23
|
const FILE_SEPARATOR = " · "
|
|
24
|
+
const HEADER_HORIZONTAL_PADDING = 2
|
|
25
|
+
const HEADER_GROUP_GAP = 1
|
|
26
|
+
const MIN_TRUNCATED_ROOT_WIDTH = 5
|
|
27
|
+
|
|
28
|
+
export interface HeaderSegment {
|
|
29
|
+
readonly id: "brand" | "file" | "root"
|
|
30
|
+
readonly text: string
|
|
31
|
+
readonly tone: "brand" | "primary" | "muted"
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface HeaderLayout {
|
|
35
|
+
readonly left: readonly HeaderSegment[]
|
|
36
|
+
readonly right: string | null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface HeaderLayoutInput {
|
|
40
|
+
readonly width: number
|
|
41
|
+
readonly currentFile?: string | null | undefined
|
|
42
|
+
readonly rootLabel?: string | null | undefined
|
|
43
|
+
readonly version: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const layoutHeaderSegments = ({
|
|
47
|
+
width,
|
|
48
|
+
currentFile,
|
|
49
|
+
rootLabel,
|
|
50
|
+
version,
|
|
51
|
+
}: HeaderLayoutInput): HeaderLayout => {
|
|
52
|
+
const usableWidth = Math.max(0, width - HEADER_HORIZONTAL_PADDING)
|
|
53
|
+
const file = currentFile && currentFile.length > 0 ? currentFile : null
|
|
54
|
+
const root = rootLabel && rootLabel.length > 0 ? rootLabel : null
|
|
55
|
+
const right = `v${version}`
|
|
56
|
+
const brand: HeaderSegment = { id: "brand", text: `${BRAND} ${BRAND_NAME}`, tone: "brand" }
|
|
57
|
+
const iconBrand: HeaderSegment = { id: "brand", text: BRAND, tone: "brand" }
|
|
58
|
+
const fileSegment: HeaderSegment | null =
|
|
59
|
+
file === null ? null : { id: "file", text: file, tone: "primary" }
|
|
60
|
+
const rootSegment: HeaderSegment | null =
|
|
61
|
+
root === null ? null : { id: "root", text: root, tone: "muted" }
|
|
62
|
+
const segments = [brand, ...(fileSegment === null ? [] : [fileSegment])]
|
|
63
|
+
|
|
64
|
+
if (rootSegment !== null && fits([...segments, rootSegment], right, usableWidth)) {
|
|
65
|
+
return { left: [...segments, rootSegment], right }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const rootWidth =
|
|
69
|
+
rootSegment === null
|
|
70
|
+
? 0
|
|
71
|
+
: usableWidth -
|
|
72
|
+
joinedLength(segments) -
|
|
73
|
+
FILE_SEPARATOR.length -
|
|
74
|
+
HEADER_GROUP_GAP -
|
|
75
|
+
right.length
|
|
76
|
+
if (root !== null && root.length > rootWidth && rootWidth >= MIN_TRUNCATED_ROOT_WIDTH) {
|
|
77
|
+
return {
|
|
78
|
+
left: [...segments, { id: "root", text: middleTruncate(root, rootWidth), tone: "muted" }],
|
|
79
|
+
right,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (fits(segments, right, usableWidth)) return { left: segments, right }
|
|
84
|
+
|
|
85
|
+
const iconSegments = [iconBrand, ...(fileSegment === null ? [] : [fileSegment])]
|
|
86
|
+
if (fits(iconSegments, right, usableWidth)) return { left: iconSegments, right }
|
|
87
|
+
if (fits(iconSegments, null, usableWidth)) return { left: iconSegments, right: null }
|
|
88
|
+
if (file !== null) {
|
|
89
|
+
const fileWidth = usableWidth - BRAND.length - FILE_SEPARATOR.length
|
|
90
|
+
if (fileWidth > 0) {
|
|
91
|
+
return {
|
|
92
|
+
left: [iconBrand, { id: "file", text: middleTruncate(file, fileWidth), tone: "primary" }],
|
|
93
|
+
right: null,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { left: [iconBrand], right: null }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const joinedLength = (segments: readonly HeaderSegment[]): number =>
|
|
102
|
+
segments.reduce(
|
|
103
|
+
(total, segment, idx) => total + segment.text.length + (idx === 0 ? 0 : FILE_SEPARATOR.length),
|
|
104
|
+
0,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
const fits = (
|
|
108
|
+
segments: readonly HeaderSegment[],
|
|
109
|
+
right: string | null,
|
|
110
|
+
usableWidth: number,
|
|
111
|
+
): boolean => {
|
|
112
|
+
const rightWidth = right === null ? 0 : HEADER_GROUP_GAP + right.length
|
|
113
|
+
return joinedLength(segments) + rightWidth <= usableWidth
|
|
114
|
+
}
|
|
22
115
|
|
|
23
116
|
export interface HeaderProps {
|
|
24
117
|
readonly width: number
|
|
@@ -26,27 +119,15 @@ export interface HeaderProps {
|
|
|
26
119
|
* it next to the brand mark — replaces the per-pane border title that
|
|
27
120
|
* used to carry this information. */
|
|
28
121
|
readonly currentFile?: string | null
|
|
122
|
+
/** Canonical discovery root / scan-scope label. */
|
|
123
|
+
readonly rootLabel?: string | null
|
|
29
124
|
/** Optional override for the version string (testing). Defaults to
|
|
30
125
|
* the running package's version. */
|
|
31
126
|
readonly version?: string
|
|
32
127
|
}
|
|
33
128
|
|
|
34
|
-
export const Header = ({ width, currentFile, version = pkg.version }: HeaderProps) => {
|
|
35
|
-
const
|
|
36
|
-
const right = `v${version}`
|
|
37
|
-
const file = currentFile && currentFile.length > 0 ? currentFile : null
|
|
38
|
-
const usableWidth = Math.max(0, width - 2) // 1-cell horizontal padding each side
|
|
39
|
-
|
|
40
|
-
// Priority: brand > filename > version. Brand is the irreducible identity
|
|
41
|
-
// anchor. Filename is per-selection useful info — keep it before the
|
|
42
|
-
// largely-static version string. `1` is the minimum gap between left and
|
|
43
|
-
// right groups so they never visually collide.
|
|
44
|
-
const leftWithFile = file !== null ? `${brand}${FILE_SEPARATOR}${file}` : brand
|
|
45
|
-
const showFileWithVersion = leftWithFile.length + 1 + right.length <= usableWidth
|
|
46
|
-
const showFileWithoutVersion = leftWithFile.length <= usableWidth
|
|
47
|
-
const showFile = file !== null && (showFileWithVersion || showFileWithoutVersion)
|
|
48
|
-
const left = showFile ? leftWithFile : brand
|
|
49
|
-
const showRight = left.length + 1 + right.length <= usableWidth
|
|
129
|
+
export const Header = ({ width, currentFile, rootLabel, version = pkg.version }: HeaderProps) => {
|
|
130
|
+
const layout = layoutHeaderSegments({ width, currentFile, rootLabel, version })
|
|
50
131
|
|
|
51
132
|
return (
|
|
52
133
|
<box
|
|
@@ -62,10 +143,18 @@ export const Header = ({ width, currentFile, version = pkg.version }: HeaderProp
|
|
|
62
143
|
}}
|
|
63
144
|
>
|
|
64
145
|
<text wrapMode="none">
|
|
65
|
-
|
|
66
|
-
|
|
146
|
+
{layout.left.map((segment, idx) => (
|
|
147
|
+
<span
|
|
148
|
+
key={`${segment.id}-${idx}`}
|
|
149
|
+
style={{ fg: segment.tone === "muted" ? colors.textMuted : colors.text }}
|
|
150
|
+
>
|
|
151
|
+
{`${idx === 0 ? "" : FILE_SEPARATOR}${segment.text}`}
|
|
152
|
+
</span>
|
|
153
|
+
))}
|
|
67
154
|
</text>
|
|
68
|
-
{
|
|
155
|
+
{layout.right !== null && (
|
|
156
|
+
<text content={layout.right} wrapMode="none" style={{ fg: colors.text }} />
|
|
157
|
+
)}
|
|
69
158
|
</box>
|
|
70
159
|
)
|
|
71
160
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { colors } from "./theme/colors.ts"
|
|
2
|
+
|
|
3
|
+
export type StatusIndicatorVariant = "info" | "warning" | "error" | "success"
|
|
4
|
+
|
|
5
|
+
export interface StatusIndicatorProps {
|
|
6
|
+
readonly icon: string
|
|
7
|
+
readonly variant?: StatusIndicatorVariant
|
|
8
|
+
readonly active?: boolean
|
|
9
|
+
readonly onMouseUp?: () => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const statusIndicatorFg = (variant: StatusIndicatorVariant): string => {
|
|
13
|
+
switch (variant) {
|
|
14
|
+
case "info":
|
|
15
|
+
return colors.info
|
|
16
|
+
case "warning":
|
|
17
|
+
return colors.warning
|
|
18
|
+
case "error":
|
|
19
|
+
return colors.error
|
|
20
|
+
case "success":
|
|
21
|
+
return colors.success
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const StatusIndicator = ({
|
|
26
|
+
icon,
|
|
27
|
+
variant = "info",
|
|
28
|
+
active = true,
|
|
29
|
+
onMouseUp,
|
|
30
|
+
}: StatusIndicatorProps) => {
|
|
31
|
+
const activeColor = statusIndicatorFg(variant)
|
|
32
|
+
const backgroundColor = active ? activeColor : colors.backgroundElement
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<box
|
|
36
|
+
{...(onMouseUp === undefined ? {} : { onMouseUp })}
|
|
37
|
+
style={{
|
|
38
|
+
width: 3,
|
|
39
|
+
height: 1,
|
|
40
|
+
flexDirection: "row",
|
|
41
|
+
backgroundColor,
|
|
42
|
+
}}
|
|
43
|
+
>
|
|
44
|
+
<text
|
|
45
|
+
content={` ${icon} `}
|
|
46
|
+
wrapMode="none"
|
|
47
|
+
style={{
|
|
48
|
+
fg: active ? colors.backgroundPanel : colors.textMuted,
|
|
49
|
+
bg: backgroundColor,
|
|
50
|
+
attributes: active ? 1 : 0,
|
|
51
|
+
}}
|
|
52
|
+
/>
|
|
53
|
+
</box>
|
|
54
|
+
)
|
|
55
|
+
}
|
package/src/StatusPopover.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useTerminalDimensions } from "@opentui/react"
|
|
2
2
|
import { useMemo, useState } from "react"
|
|
3
|
+
import { StatusIndicator, statusIndicatorFg } from "./StatusIndicator.tsx"
|
|
3
4
|
import { colors } from "./theme/colors.ts"
|
|
4
5
|
|
|
5
6
|
export type StatusPopoverVariant = "info" | "warning" | "error" | "success"
|
|
@@ -29,19 +30,6 @@ export interface StatusPopoverPanelProps {
|
|
|
29
30
|
|
|
30
31
|
const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n))
|
|
31
32
|
|
|
32
|
-
const variantFg = (variant: StatusPopoverVariant): string => {
|
|
33
|
-
switch (variant) {
|
|
34
|
-
case "info":
|
|
35
|
-
return colors.info
|
|
36
|
-
case "warning":
|
|
37
|
-
return colors.warning
|
|
38
|
-
case "error":
|
|
39
|
-
return colors.error
|
|
40
|
-
case "success":
|
|
41
|
-
return colors.success
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
33
|
const measureLine = (line: string): number => line.length
|
|
46
34
|
|
|
47
35
|
const wrapLine = (line: string, width: number): string[] => {
|
|
@@ -96,22 +84,11 @@ export const StatusPopover = ({
|
|
|
96
84
|
|
|
97
85
|
const linesToRender = wrapped.slice(0, Math.max(0, popoverHeight - 2))
|
|
98
86
|
while (linesToRender.length < popoverHeight - 2) linesToRender.push("")
|
|
99
|
-
const
|
|
100
|
-
const borderColor = variantFg(variant)
|
|
87
|
+
const borderColor = statusIndicatorFg(variant)
|
|
101
88
|
|
|
102
89
|
return (
|
|
103
90
|
<>
|
|
104
|
-
<
|
|
105
|
-
onMouseUp={() => setOpen(!isOpen)}
|
|
106
|
-
style={{
|
|
107
|
-
width: 3,
|
|
108
|
-
height: 1,
|
|
109
|
-
flexDirection: "row",
|
|
110
|
-
backgroundColor: colors.backgroundElement,
|
|
111
|
-
}}
|
|
112
|
-
>
|
|
113
|
-
<text content={` ${icon} `} wrapMode="none" style={{ fg: triggerFg, attributes: 1 }} />
|
|
114
|
-
</box>
|
|
91
|
+
<StatusIndicator icon={icon} variant={variant} active onMouseUp={() => setOpen(!isOpen)} />
|
|
115
92
|
{showPanel && isOpen && (
|
|
116
93
|
<box
|
|
117
94
|
position="absolute"
|
|
@@ -162,7 +139,7 @@ export const StatusPopoverPanel = ({
|
|
|
162
139
|
const linesToRender = wrapped.slice(0, Math.max(0, popoverHeight - 2))
|
|
163
140
|
while (linesToRender.length < popoverHeight - 2) linesToRender.push("")
|
|
164
141
|
|
|
165
|
-
const borderColor =
|
|
142
|
+
const borderColor = statusIndicatorFg(variant)
|
|
166
143
|
|
|
167
144
|
return (
|
|
168
145
|
<box
|
package/src/cli/argv.ts
CHANGED
|
@@ -12,6 +12,10 @@ export interface ParsedArgs {
|
|
|
12
12
|
readonly tone: string | null
|
|
13
13
|
/** Value of `--width <N>`, or null. Validated by the boot layer (must be a positive integer). */
|
|
14
14
|
readonly width: string | null
|
|
15
|
+
/** Startup reader wrap override. Null means config/env/default decides. */
|
|
16
|
+
readonly wrap: boolean | null
|
|
17
|
+
/** True when both `--wrap` and `--no-wrap` were passed. */
|
|
18
|
+
readonly wrapConflict: boolean
|
|
15
19
|
/** True when `--serve` was passed: serve the positional path as HTML, skip TUI. */
|
|
16
20
|
readonly serve: boolean
|
|
17
21
|
/** Value of `--port <N>`, or null. Validated by the boot layer. */
|
|
@@ -22,8 +26,6 @@ export interface ParsedArgs {
|
|
|
22
26
|
readonly version: boolean
|
|
23
27
|
/** True when `--config-path` was passed: print resolved config path and exit. */
|
|
24
28
|
readonly configPath: boolean
|
|
25
|
-
/** Value of `--sidebar <mode>` (`auto`, `on`, `off`), or null. Validated by the boot layer. */
|
|
26
|
-
readonly sidebar: string | null
|
|
27
29
|
/** True when `--no-update-check` was passed: suppress the npm-registry
|
|
28
30
|
* probe and the "update available" notice. Mirrors the
|
|
29
31
|
* `NO_UPDATE_NOTIFIER` env var so opt-out is reachable without env state. */
|
|
@@ -49,10 +51,11 @@ const createProgram = () =>
|
|
|
49
51
|
.option("--theme [id]")
|
|
50
52
|
.option("--tone [mode]")
|
|
51
53
|
.option("--width [N]")
|
|
54
|
+
.option("--wrap")
|
|
55
|
+
.option("--no-wrap")
|
|
52
56
|
.option("--serve")
|
|
53
57
|
.option("--port [N]")
|
|
54
58
|
.option("--config-path")
|
|
55
|
-
.option("--sidebar [mode]")
|
|
56
59
|
.option("--no-update-check")
|
|
57
60
|
.option("--ext [list]")
|
|
58
61
|
.option("--focus [mode]")
|
|
@@ -67,19 +70,20 @@ const VALUE_FLAGS: ReadonlySet<string> = new Set([
|
|
|
67
70
|
"--tone",
|
|
68
71
|
"--width",
|
|
69
72
|
"--port",
|
|
70
|
-
"--sidebar",
|
|
71
73
|
"--focus",
|
|
72
74
|
"--show",
|
|
73
75
|
"--root",
|
|
74
76
|
"--ext",
|
|
75
77
|
])
|
|
76
78
|
|
|
77
|
-
const REMOVED_VALUE_FLAGS: ReadonlySet<string> = new Set(["--sort"])
|
|
79
|
+
const REMOVED_VALUE_FLAGS: ReadonlySet<string> = new Set(["--sort", "--sidebar"])
|
|
78
80
|
|
|
79
81
|
const BOOLEAN_FLAGS: ReadonlySet<string> = new Set([
|
|
80
82
|
"--serve",
|
|
81
83
|
"--config-path",
|
|
82
84
|
"--no-update-check",
|
|
85
|
+
"--wrap",
|
|
86
|
+
"--no-wrap",
|
|
83
87
|
"--help",
|
|
84
88
|
"-h",
|
|
85
89
|
"--version",
|
|
@@ -114,6 +118,8 @@ export const parseArgv = (argv: readonly string[]): ParsedArgs => {
|
|
|
114
118
|
const opts = program.opts<Record<string, unknown>>()
|
|
115
119
|
const pathArg = findPathArg(argv)
|
|
116
120
|
const stringOrNull = (value: unknown): string | null => (typeof value === "string" ? value : null)
|
|
121
|
+
const hasWrap = argv.includes("--wrap")
|
|
122
|
+
const hasNoWrap = argv.includes("--no-wrap")
|
|
117
123
|
|
|
118
124
|
return {
|
|
119
125
|
path: typeof pathArg === "string" ? pathArg : null,
|
|
@@ -121,12 +127,13 @@ export const parseArgv = (argv: readonly string[]): ParsedArgs => {
|
|
|
121
127
|
theme: stringOrNull(opts["theme"]),
|
|
122
128
|
tone: stringOrNull(opts["tone"]),
|
|
123
129
|
width: stringOrNull(opts["width"]),
|
|
130
|
+
wrap: hasWrap ? true : hasNoWrap ? false : null,
|
|
131
|
+
wrapConflict: hasWrap && hasNoWrap,
|
|
124
132
|
serve: opts["serve"] === true,
|
|
125
133
|
port: stringOrNull(opts["port"]),
|
|
126
134
|
help: opts["help"] === true,
|
|
127
135
|
version: opts["version"] === true,
|
|
128
136
|
configPath: opts["configPath"] === true,
|
|
129
|
-
sidebar: stringOrNull(opts["sidebar"]),
|
|
130
137
|
noUpdateCheck: opts["noUpdateCheck"] === true,
|
|
131
138
|
extensions: stringOrNull(opts["ext"]),
|
|
132
139
|
show: stringOrNull(opts["show"]),
|
|
@@ -145,12 +152,13 @@ export const usage = `usage:
|
|
|
145
152
|
options:
|
|
146
153
|
--theme <id> color theme: ${themeList} (default: opencode)
|
|
147
154
|
--tone <mode> dark or light (default: dark)
|
|
148
|
-
--width <N>
|
|
155
|
+
--width <N> reader wrap width used when wrapping is enabled (default: 80)
|
|
156
|
+
--wrap start with reader wrapping enabled
|
|
157
|
+
--no-wrap start with reader wrapping disabled
|
|
149
158
|
--show <list> reveal normally-skipped entries; comma-separated subset of:
|
|
150
159
|
hidden, gitignored. Use --show "" to clear.
|
|
151
160
|
--root <dir> discovery root to walk (overrides defaultRoot config/env)
|
|
152
|
-
--
|
|
153
|
-
--focus <m> startup focus: sidebar, reader, or filter (default: filter)
|
|
161
|
+
--focus <m> startup focus: sidebar, reader, or filter (default: sidebar)
|
|
154
162
|
--serve serve the positional path as HTML in the browser (skips TUI)
|
|
155
163
|
--port <N> port for --serve (default: OS-assigned)
|
|
156
164
|
-h, --help show this help and exit
|
|
@@ -166,6 +174,6 @@ examples:
|
|
|
166
174
|
|
|
167
175
|
configuration:
|
|
168
176
|
file: $XDG_CONFIG_HOME/house/config.toml (default ~/.config/house/config.toml)
|
|
169
|
-
keys: theme, tone, extensions, show, focus, defaultRoot
|
|
170
|
-
env: HOUSE_THEME, HOUSE_TONE, HOUSE_EXTENSIONS, HOUSE_SHOW, HOUSE_FOCUS, HOUSE_DEFAULT_ROOT
|
|
177
|
+
keys: theme, tone, width, wrap, extensions, show, focus, defaultRoot
|
|
178
|
+
env: HOUSE_THEME, HOUSE_TONE, HOUSE_WIDTH, HOUSE_WRAP, HOUSE_EXTENSIONS, HOUSE_SHOW, HOUSE_FOCUS, HOUSE_DEFAULT_ROOT
|
|
171
179
|
precedence (high → low): flags → env → file → defaults`
|
|
@@ -55,6 +55,7 @@ const annotations: Record<string, Annotation> = {
|
|
|
55
55
|
keywords: ["hidden", "gitignore", "dotfiles", "all"],
|
|
56
56
|
},
|
|
57
57
|
"reader.back": { title: "Back to sidebar", category: "Navigation" },
|
|
58
|
+
"reader.wrap.toggle": { title: "Toggle reader wrap", category: "View" },
|
|
58
59
|
"reader.prevFile": { title: "Previous file", category: "Navigation" },
|
|
59
60
|
"reader.nextFile": { title: "Next file", category: "Navigation" },
|
|
60
61
|
"serve.current": { title: "Open in browser", category: "File" },
|
|
@@ -13,4 +13,15 @@
|
|
|
13
13
|
import type { BrowserCtx } from "../keymap/browser.ts"
|
|
14
14
|
import type { AppCommand } from "./types.ts"
|
|
15
15
|
|
|
16
|
-
export const paletteOnlyCommands = (
|
|
16
|
+
export const paletteOnlyCommands = (ctx: BrowserCtx): readonly AppCommand[] =>
|
|
17
|
+
ctx.hasSelected
|
|
18
|
+
? [
|
|
19
|
+
{
|
|
20
|
+
id: "file.copyContents",
|
|
21
|
+
title: "Copy file contents",
|
|
22
|
+
category: "File",
|
|
23
|
+
keywords: ["copy", "clipboard", "contents", "markdown"],
|
|
24
|
+
run: () => ctx.copyCurrentContents(),
|
|
25
|
+
},
|
|
26
|
+
]
|
|
27
|
+
: []
|
package/src/config/load.ts
CHANGED
|
@@ -20,6 +20,10 @@ export interface HouseConfig {
|
|
|
20
20
|
readonly theme: string
|
|
21
21
|
readonly tone: "dark" | "light"
|
|
22
22
|
readonly extensions: readonly string[]
|
|
23
|
+
/** Reader wrap width used when wrapping is enabled. */
|
|
24
|
+
readonly width: number
|
|
25
|
+
/** Whether the reader starts with fixed-width wrapping enabled. */
|
|
26
|
+
readonly wrap: boolean
|
|
23
27
|
/** Default discovery-root strategy when no explicit `--root` flag is passed. */
|
|
24
28
|
readonly defaultRoot: "cwd" | "git"
|
|
25
29
|
/** Categories of normally-skipped entries to opt into. See
|
|
@@ -40,6 +44,8 @@ export interface CliOverrides {
|
|
|
40
44
|
* other CLI override here). `--show ""` sets the empty set. */
|
|
41
45
|
readonly show: readonly ShowCategory[] | null
|
|
42
46
|
readonly focus: "sidebar" | "reader" | "filter" | null
|
|
47
|
+
readonly width: number | null
|
|
48
|
+
readonly wrap: boolean | null
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
const DEFAULT_THEME = "opencode"
|
|
@@ -47,7 +53,9 @@ const DEFAULT_TONE: "dark" | "light" = "dark"
|
|
|
47
53
|
const DEFAULT_EXTENSIONS: readonly string[] = []
|
|
48
54
|
const DEFAULT_ROOT: "cwd" | "git" = "cwd"
|
|
49
55
|
const DEFAULT_SHOW = ""
|
|
50
|
-
const DEFAULT_FOCUS: "sidebar" | "reader" | "filter" = "
|
|
56
|
+
const DEFAULT_FOCUS: "sidebar" | "reader" | "filter" = "sidebar"
|
|
57
|
+
const DEFAULT_WIDTH = 80
|
|
58
|
+
const DEFAULT_WRAP = false
|
|
51
59
|
|
|
52
60
|
const themeIds = themeDefinitions.map((t) => t.id)
|
|
53
61
|
|
|
@@ -63,6 +71,8 @@ const KNOWN_FILE_KEYS: ReadonlySet<string> = new Set([
|
|
|
63
71
|
"extensions",
|
|
64
72
|
"show",
|
|
65
73
|
"focus",
|
|
74
|
+
"width",
|
|
75
|
+
"wrap",
|
|
66
76
|
"defaultRoot",
|
|
67
77
|
])
|
|
68
78
|
|
|
@@ -78,6 +88,8 @@ const schema = Config.all({
|
|
|
78
88
|
// so the error message can list valid categories at the field's path.
|
|
79
89
|
show: Config.schema(Schema.String, "show"),
|
|
80
90
|
focus: Config.schema(Schema.Literals(["sidebar", "reader", "filter"] as const), "focus"),
|
|
91
|
+
width: Config.schema(Schema.String, "width"),
|
|
92
|
+
wrap: Config.schema(Schema.String, "wrap"),
|
|
81
93
|
})
|
|
82
94
|
|
|
83
95
|
const defaultsProvider = (): ConfigProvider.ConfigProvider =>
|
|
@@ -88,8 +100,26 @@ const defaultsProvider = (): ConfigProvider.ConfigProvider =>
|
|
|
88
100
|
extensions: DEFAULT_EXTENSIONS.join(","),
|
|
89
101
|
show: DEFAULT_SHOW,
|
|
90
102
|
focus: DEFAULT_FOCUS,
|
|
103
|
+
width: String(DEFAULT_WIDTH),
|
|
104
|
+
wrap: String(DEFAULT_WRAP),
|
|
91
105
|
})
|
|
92
106
|
|
|
107
|
+
const sourceError = (message: string, cause?: unknown): ConfigProvider.SourceError =>
|
|
108
|
+
new ConfigProvider.SourceError({ message, cause })
|
|
109
|
+
|
|
110
|
+
const validateFileValue = (path: string, key: string, value: unknown): void => {
|
|
111
|
+
if (key === "width") {
|
|
112
|
+
if (!Number.isSafeInteger(value) || (value as number) <= 0) {
|
|
113
|
+
throw sourceError(`invalid value for width in ${path}: expected a positive integer`)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (key === "wrap") {
|
|
117
|
+
if (typeof value !== "boolean") {
|
|
118
|
+
throw sourceError(`invalid value for wrap in ${path}: expected true or false`)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
93
123
|
/**
|
|
94
124
|
* Levenshtein edit distance, capped at `cap` for early exit.
|
|
95
125
|
* Used only to suggest "did you mean X?" when a config key looks like a
|
|
@@ -154,15 +184,22 @@ const fileProvider = (
|
|
|
154
184
|
const parsed = yield* Effect.try({
|
|
155
185
|
try: () => Bun.TOML.parse(text) as Record<string, unknown>,
|
|
156
186
|
catch: (cause) =>
|
|
157
|
-
|
|
158
|
-
|
|
187
|
+
sourceError(
|
|
188
|
+
`invalid TOML in ${path}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
159
189
|
cause,
|
|
160
|
-
|
|
190
|
+
),
|
|
161
191
|
})
|
|
162
192
|
const known = [...KNOWN_FILE_KEYS]
|
|
163
193
|
const filtered: Record<string, unknown> = {}
|
|
164
194
|
for (const [k, v] of Object.entries(parsed)) {
|
|
165
195
|
if (KNOWN_FILE_KEYS.has(k)) {
|
|
196
|
+
yield* Effect.try({
|
|
197
|
+
try: () => validateFileValue(path, k, v),
|
|
198
|
+
catch: (cause) =>
|
|
199
|
+
cause instanceof ConfigProvider.SourceError
|
|
200
|
+
? cause
|
|
201
|
+
: sourceError(`invalid value for ${k} in ${path}`, cause),
|
|
202
|
+
})
|
|
166
203
|
filtered[k] = v
|
|
167
204
|
} else {
|
|
168
205
|
onWarning(formatUnknownKeyWarning(path, k, known))
|
|
@@ -206,12 +243,16 @@ const envProvider = (env: Record<string, string | undefined>): ConfigProvider.Co
|
|
|
206
243
|
const extensions = env["HOUSE_EXTENSIONS"]
|
|
207
244
|
const show = env["HOUSE_SHOW"]
|
|
208
245
|
const focus = env["HOUSE_FOCUS"]
|
|
246
|
+
const width = env["HOUSE_WIDTH"]
|
|
247
|
+
const wrap = env["HOUSE_WRAP"]
|
|
209
248
|
if (theme !== undefined) entries.push(["theme", theme])
|
|
210
249
|
if (tone !== undefined) entries.push(["tone", tone])
|
|
211
250
|
if (defaultRoot !== undefined) entries.push(["defaultRoot", defaultRoot])
|
|
212
251
|
if (extensions !== undefined) entries.push(["extensions", extensions])
|
|
213
252
|
if (show !== undefined) entries.push(["show", show])
|
|
214
253
|
if (focus !== undefined) entries.push(["focus", focus])
|
|
254
|
+
if (width !== undefined) entries.push(["width", width])
|
|
255
|
+
if (wrap !== undefined) entries.push(["wrap", wrap])
|
|
215
256
|
return ConfigProvider.fromUnknown(Object.fromEntries(entries))
|
|
216
257
|
}
|
|
217
258
|
|
|
@@ -222,9 +263,28 @@ const cliProvider = (overrides: CliOverrides): ConfigProvider.ConfigProvider =>
|
|
|
222
263
|
if (overrides.extensions !== null) entries.push(["extensions", overrides.extensions.join(",")])
|
|
223
264
|
if (overrides.show !== null) entries.push(["show", overrides.show.join(",")])
|
|
224
265
|
if (overrides.focus !== null) entries.push(["focus", overrides.focus])
|
|
266
|
+
if (overrides.width !== null) entries.push(["width", String(overrides.width)])
|
|
267
|
+
if (overrides.wrap !== null) entries.push(["wrap", String(overrides.wrap)])
|
|
225
268
|
return ConfigProvider.fromUnknown(Object.fromEntries(entries))
|
|
226
269
|
}
|
|
227
270
|
|
|
271
|
+
const parsePositiveInteger = (key: string, raw: string): Effect.Effect<number, Error> => {
|
|
272
|
+
if (!/^\d+$/.test(raw)) {
|
|
273
|
+
return Effect.fail(new Error(`${key}: expected a positive integer, got ${JSON.stringify(raw)}`))
|
|
274
|
+
}
|
|
275
|
+
const value = Number.parseInt(raw, 10)
|
|
276
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
277
|
+
return Effect.fail(new Error(`${key}: expected a positive integer, got ${JSON.stringify(raw)}`))
|
|
278
|
+
}
|
|
279
|
+
return Effect.succeed(value)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const parseBoolean = (key: string, raw: string): Effect.Effect<boolean, Error> => {
|
|
283
|
+
if (raw === "true") return Effect.succeed(true)
|
|
284
|
+
if (raw === "false") return Effect.succeed(false)
|
|
285
|
+
return Effect.fail(new Error(`${key}: expected true or false, got ${JSON.stringify(raw)}`))
|
|
286
|
+
}
|
|
287
|
+
|
|
228
288
|
export interface LoadOptions {
|
|
229
289
|
readonly cli?: CliOverrides
|
|
230
290
|
/** Override the TOML path (tests). Defaults to `$XDG_CONFIG_HOME/house/config.toml`. */
|
|
@@ -265,6 +325,8 @@ export const loadConfig = (
|
|
|
265
325
|
extensions: null,
|
|
266
326
|
show: null,
|
|
267
327
|
focus: null,
|
|
328
|
+
width: null,
|
|
329
|
+
wrap: null,
|
|
268
330
|
}
|
|
269
331
|
const onWarning = options.onWarning ?? ((msg) => process.stderr.write(`${msg}\n`))
|
|
270
332
|
const provider = cliProvider(cli).pipe(
|
|
@@ -273,41 +335,47 @@ export const loadConfig = (
|
|
|
273
335
|
ConfigProvider.orElse(defaultsProvider()),
|
|
274
336
|
)
|
|
275
337
|
return schema.parse(provider).pipe(
|
|
276
|
-
Effect.flatMap((raw) =>
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
raw.
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
338
|
+
Effect.flatMap((raw) =>
|
|
339
|
+
Effect.gen(function* () {
|
|
340
|
+
const defaultRoot =
|
|
341
|
+
raw.defaultRoot === "cwd" || raw.defaultRoot === "git" ? raw.defaultRoot : DEFAULT_ROOT
|
|
342
|
+
if (raw.defaultRoot !== defaultRoot) {
|
|
343
|
+
onWarning(
|
|
344
|
+
`house: ignoring invalid value ${JSON.stringify(raw.defaultRoot)} for defaultRoot in config/env; using "${DEFAULT_ROOT}"`,
|
|
345
|
+
)
|
|
346
|
+
}
|
|
347
|
+
const parsed = parseShowList(raw.show)
|
|
348
|
+
if (!parsed.ok) {
|
|
349
|
+
// Effect's `Config.ConfigError` requires a `SchemaError` or
|
|
350
|
+
// `SourceError` cause that we don't have a clean constructor
|
|
351
|
+
// for here — surface as a plain Error and let the boot
|
|
352
|
+
// layer's existing `formatConfigError` (which already handles
|
|
353
|
+
// `instanceof Error`) render it.
|
|
354
|
+
return yield* Effect.fail(
|
|
355
|
+
new Error(
|
|
356
|
+
`show: unknown category "${parsed.invalid.join('", "')}" (valid: ${SHOW_CATEGORIES.join(", ")})`,
|
|
357
|
+
),
|
|
358
|
+
)
|
|
359
|
+
}
|
|
360
|
+
const width = yield* parsePositiveInteger("width", raw.width)
|
|
361
|
+
const wrap = yield* parseBoolean("wrap", raw.wrap)
|
|
362
|
+
return {
|
|
363
|
+
theme: raw.theme,
|
|
364
|
+
tone: raw.tone,
|
|
365
|
+
defaultRoot,
|
|
366
|
+
width,
|
|
367
|
+
wrap,
|
|
368
|
+
extensions:
|
|
369
|
+
raw.extensions === ""
|
|
370
|
+
? []
|
|
371
|
+
: raw.extensions
|
|
372
|
+
.split(",")
|
|
373
|
+
.map((s) => s.trim())
|
|
374
|
+
.filter(Boolean),
|
|
375
|
+
show: parsed.value,
|
|
376
|
+
focus: raw.focus,
|
|
377
|
+
}
|
|
378
|
+
}),
|
|
379
|
+
),
|
|
312
380
|
)
|
|
313
381
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { isAbsolute, relative } from "node:path"
|
|
2
|
+
|
|
3
|
+
export interface DiscoveryRootLabelInput {
|
|
4
|
+
readonly discoveryRoot: string
|
|
5
|
+
readonly home: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const formatDiscoveryRootLabel = ({
|
|
9
|
+
discoveryRoot,
|
|
10
|
+
home,
|
|
11
|
+
}: DiscoveryRootLabelInput): string => {
|
|
12
|
+
if (discoveryRoot === home) return "~"
|
|
13
|
+
const homeRelative = relative(home, discoveryRoot)
|
|
14
|
+
if (homeRelative.length > 0 && !homeRelative.startsWith("..") && !isAbsolute(homeRelative)) {
|
|
15
|
+
return `~/${homeRelative}`
|
|
16
|
+
}
|
|
17
|
+
return discoveryRoot
|
|
18
|
+
}
|