@carlesandres/house 0.4.13 → 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 +8 -1
- package/package.json +1 -1
- package/src/Browser.tsx +5 -5
- package/src/Header.tsx +113 -24
- package/src/discovery/rootLabel.ts +18 -0
- package/src/index.tsx +7 -2
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,12 @@ The publish workflow (`.github/workflows/publish.yml`) runs on the `release: pub
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.4.14] — 2026-06-15
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Browser header now shows the active discovery root, and empty states reuse the same canonical root label for consistent launch context.
|
|
14
|
+
|
|
9
15
|
## [0.4.13] — 2026-06-15
|
|
10
16
|
|
|
11
17
|
### Added
|
|
@@ -333,7 +339,8 @@ The v1 MVP, published as `@carlesandres/openmdr` on npm.
|
|
|
333
339
|
|
|
334
340
|
Search, stdin, URL fetching, cross-file link following, `$EDITOR` hand-off, syntax highlighting, persistent config, OS-appearance auto-detect, single-binary distribution (issue [#2](https://github.com/carlesandres/openmdr/issues/2)), Homebrew tap. All tracked.
|
|
335
341
|
|
|
336
|
-
[Unreleased]: https://github.com/carlesandres/house/compare/v0.4.
|
|
342
|
+
[Unreleased]: https://github.com/carlesandres/house/compare/v0.4.14...HEAD
|
|
343
|
+
[0.4.14]: https://github.com/carlesandres/house/compare/v0.4.13...v0.4.14
|
|
337
344
|
[0.4.13]: https://github.com/carlesandres/house/compare/v0.4.12...v0.4.13
|
|
338
345
|
[0.4.12]: https://github.com/carlesandres/house/compare/v0.4.11...v0.4.12
|
|
339
346
|
[0.4.11]: https://github.com/carlesandres/house/compare/v0.4.10...v0.4.11
|
package/package.json
CHANGED
package/src/Browser.tsx
CHANGED
|
@@ -55,8 +55,8 @@ export interface BrowserProps {
|
|
|
55
55
|
readonly wrapWidth?: number
|
|
56
56
|
/** Initial reader wrap mode. Runtime toggles are session-only. */
|
|
57
57
|
readonly initialWrap?: boolean
|
|
58
|
-
/**
|
|
59
|
-
readonly
|
|
58
|
+
/** Canonical discovery root label used anywhere the UI names the scan scope. */
|
|
59
|
+
readonly rootLabel?: string
|
|
60
60
|
/** Persistent footer indicator (e.g. "indexing… 42"). Pass null/undefined
|
|
61
61
|
* when discovery has finished; the indicator clears. */
|
|
62
62
|
readonly discoveryStatus?: string | null
|
|
@@ -187,7 +187,7 @@ export const Browser = ({
|
|
|
187
187
|
initialQuery = "",
|
|
188
188
|
wrapWidth = 80,
|
|
189
189
|
initialWrap = false,
|
|
190
|
-
|
|
190
|
+
rootLabel = "current root",
|
|
191
191
|
discoveryStatus = null,
|
|
192
192
|
discoverySpinnerIntervalMs,
|
|
193
193
|
discoverySpinnerInitialFrameIndex,
|
|
@@ -990,7 +990,7 @@ export const Browser = ({
|
|
|
990
990
|
: "No markdown files in"
|
|
991
991
|
: "No files match"
|
|
992
992
|
}
|
|
993
|
-
value={files.length === 0 ? (discoveryActive ? "…" :
|
|
993
|
+
value={files.length === 0 ? (discoveryActive ? "…" : rootLabel) : filterApplied}
|
|
994
994
|
/>
|
|
995
995
|
) : (
|
|
996
996
|
visibleFiles.map((file, idx) => {
|
|
@@ -1044,7 +1044,7 @@ export const Browser = ({
|
|
|
1044
1044
|
<box
|
|
1045
1045
|
style={{ width, height, flexDirection: "column", backgroundColor: colors.backgroundPanel }}
|
|
1046
1046
|
>
|
|
1047
|
-
<Header width={width} currentFile={currentFile} />
|
|
1047
|
+
<Header width={width} currentFile={currentFile} rootLabel={rootLabel} />
|
|
1048
1048
|
<box
|
|
1049
1049
|
style={{
|
|
1050
1050
|
flexDirection: "row",
|
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,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
|
+
}
|
package/src/index.tsx
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
/** house — entry point. Boots the browser TUI or `--serve` preview. */
|
|
3
3
|
|
|
4
4
|
import { stat } from "node:fs/promises"
|
|
5
|
+
import { homedir } from "node:os"
|
|
5
6
|
import { dirname, isAbsolute, relative, resolve } from "node:path"
|
|
6
7
|
import { createCliRenderer } from "@opentui/core"
|
|
7
8
|
import { createRoot } from "@opentui/react"
|
|
@@ -13,6 +14,7 @@ import { Browser, type StartupFocus } from "./Browser.tsx"
|
|
|
13
14
|
import { parseArgv, usage } from "./cli/argv.ts"
|
|
14
15
|
import { defaultConfigPath, formatConfigError, loadConfig } from "./config/load.ts"
|
|
15
16
|
import { parseShowList, SHOW_CATEGORIES, type ShowCategory } from "./discovery/show.ts"
|
|
17
|
+
import { formatDiscoveryRootLabel } from "./discovery/rootLabel.ts"
|
|
16
18
|
import { walk, type FileEntry } from "./discovery/walk.ts"
|
|
17
19
|
import { openInBrowser } from "./serve/openBrowser.ts"
|
|
18
20
|
import { startServer } from "./serve/server.ts"
|
|
@@ -64,7 +66,7 @@ export const resolveDiscoveryRoot = async ({
|
|
|
64
66
|
readonly defaultRoot: "cwd" | "git"
|
|
65
67
|
readonly cwd: string
|
|
66
68
|
}): Promise<string> => {
|
|
67
|
-
if (cliRoot !== null) return cliRoot
|
|
69
|
+
if (cliRoot !== null) return resolve(cwd, cliRoot)
|
|
68
70
|
if (defaultRoot === "git") return findGitRoot(cwd)
|
|
69
71
|
return cwd
|
|
70
72
|
}
|
|
@@ -104,6 +106,7 @@ interface DiscoverShellProps {
|
|
|
104
106
|
* and the full vocabulary; the underlying categories remain
|
|
105
107
|
* independent everywhere else. */
|
|
106
108
|
readonly initialShow: readonly ShowCategory[]
|
|
109
|
+
readonly rootLabel?: string
|
|
107
110
|
readonly extensions: readonly string[]
|
|
108
111
|
readonly wrapWidth: number
|
|
109
112
|
readonly initialWrap: boolean
|
|
@@ -114,6 +117,7 @@ export const DiscoverShell = ({
|
|
|
114
117
|
target,
|
|
115
118
|
initialQuery,
|
|
116
119
|
initialShow,
|
|
120
|
+
rootLabel = target,
|
|
117
121
|
extensions,
|
|
118
122
|
wrapWidth,
|
|
119
123
|
initialWrap,
|
|
@@ -194,7 +198,7 @@ export const DiscoverShell = ({
|
|
|
194
198
|
initialQuery={initialQuery}
|
|
195
199
|
wrapWidth={wrapWidth}
|
|
196
200
|
initialWrap={initialWrap}
|
|
197
|
-
|
|
201
|
+
rootLabel={rootLabel}
|
|
198
202
|
discoveryStatus={discoveryStatus}
|
|
199
203
|
startupFocus={startupFocus}
|
|
200
204
|
updateNotice={updateNotice}
|
|
@@ -454,6 +458,7 @@ async function runTui({
|
|
|
454
458
|
target={discoveryRoot}
|
|
455
459
|
initialQuery={initialQuery}
|
|
456
460
|
initialShow={show}
|
|
461
|
+
rootLabel={formatDiscoveryRootLabel({ discoveryRoot, home: homedir() })}
|
|
457
462
|
extensions={extensions}
|
|
458
463
|
wrapWidth={wrapWidth}
|
|
459
464
|
initialWrap={initialWrap}
|