@carlesandres/house 0.4.11 → 0.4.13
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 +3 -3
- package/src/Browser.tsx +60 -38
- package/src/CommandPalette.tsx +5 -2
- package/src/Footer.tsx +55 -28
- 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/index.tsx +44 -35
- package/src/io/clipboard.ts +53 -0
- package/src/keymap/browser.ts +15 -0
- package/src/layout/resolve.ts +0 -8
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,28 @@ The publish workflow (`.github/workflows/publish.yml`) runs on the `release: pub
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.4.13] — 2026-06-15
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Command palette now includes a `Copy file contents` action for the selected markdown file, copying the exact on-disk text to the system clipboard on macOS and Linux.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Reader wrapping is now a session mode toggled with `w`: `width` / `--width` defines the fixed wrap width, while `wrap` / `--wrap` / `--no-wrap` controls startup mode. The footer now keeps a persistent `W` indicator for wrap state.
|
|
18
|
+
- The built-in startup focus now defaults to `sidebar` instead of opening the filter prompt immediately; use `--focus filter`, `HOUSE_FOCUS=filter`, or `focus = "filter"` to keep the previous behavior.
|
|
19
|
+
- Removed `--sidebar` as a startup mode; the sidebar now starts visible and remains controlled interactively with `s`, `tab`, and `/` during the session.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- Footer status chrome, palette sizing, and reader wrap width calculations now stay within the visible pane.
|
|
24
|
+
|
|
25
|
+
## [0.4.12] — 2026-06-14
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- Pinned the Effect runtime stack to the latest beta that does not pull `ini@7`, avoiding npm install engine warnings for users on older Node 22 installers.
|
|
30
|
+
|
|
9
31
|
## [0.4.11] — 2026-06-14
|
|
10
32
|
|
|
11
33
|
### Fixed
|
|
@@ -311,7 +333,9 @@ The v1 MVP, published as `@carlesandres/openmdr` on npm.
|
|
|
311
333
|
|
|
312
334
|
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.
|
|
313
335
|
|
|
314
|
-
[Unreleased]: https://github.com/carlesandres/house/compare/v0.4.
|
|
336
|
+
[Unreleased]: https://github.com/carlesandres/house/compare/v0.4.13...HEAD
|
|
337
|
+
[0.4.13]: https://github.com/carlesandres/house/compare/v0.4.12...v0.4.13
|
|
338
|
+
[0.4.12]: https://github.com/carlesandres/house/compare/v0.4.11...v0.4.12
|
|
315
339
|
[0.4.11]: https://github.com/carlesandres/house/compare/v0.4.10...v0.4.11
|
|
316
340
|
[0.4.10]: https://github.com/carlesandres/house/compare/v0.4.9...v0.4.10
|
|
317
341
|
[0.4.9]: https://github.com/carlesandres/house/compare/v0.4.8...v0.4.9
|
package/README.md
CHANGED
|
@@ -64,11 +64,12 @@ house --serve README.md
|
|
|
64
64
|
| -------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------ |
|
|
65
65
|
| `--theme <name>` | `opencode` | Starting theme (see list below) |
|
|
66
66
|
| `--tone dark\|light` | `dark` | Starting tone |
|
|
67
|
-
| `--width <N>` |
|
|
67
|
+
| `--width <N>` | `80` | Reader wrap width used when wrapping is enabled |
|
|
68
|
+
| `--wrap` | off | Start with reader wrapping enabled |
|
|
69
|
+
| `--no-wrap` | off | Start with reader wrapping disabled |
|
|
68
70
|
| `--show <list>` | `""` | Reveal normally-skipped entries; comma-separated subset of `hidden`, `gitignored`. Use `--show ""` to clear. |
|
|
69
71
|
| `--root <dir>` | current directory | Discovery root to walk; overrides `defaultRoot` config/env |
|
|
70
|
-
| `--
|
|
71
|
-
| `--focus <mode>` | `filter` | Startup focus: `sidebar`, `reader`, or `filter`. `filter` opens the sidebar filter prompt immediately. |
|
|
72
|
+
| `--focus <mode>` | `sidebar` | Startup focus: `sidebar`, `reader`, or `filter`. `filter` opens the sidebar filter prompt immediately. |
|
|
72
73
|
| `--serve` | off | Serve the positional path as HTML in the browser (skips TUI) |
|
|
73
74
|
| `--port <N>` | OS-assigned | Port for `--serve` |
|
|
74
75
|
| `--ext <list>` | none | Include extra file extensions (comma-separated) |
|
|
@@ -92,24 +93,28 @@ Run `house --config-path` to print the exact location.
|
|
|
92
93
|
theme = "tokyonight"
|
|
93
94
|
tone = "dark"
|
|
94
95
|
extensions = []
|
|
96
|
+
width = 80
|
|
97
|
+
wrap = false
|
|
95
98
|
show = ["hidden", "gitignored"]
|
|
96
|
-
focus = "
|
|
99
|
+
focus = "sidebar"
|
|
97
100
|
defaultRoot = "cwd" # or "git"
|
|
98
101
|
```
|
|
99
102
|
|
|
100
|
-
Supported keys: `theme`, `tone`, `extensions`, `show`, `focus`, `defaultRoot`.
|
|
103
|
+
Supported keys: `theme`, `tone`, `extensions`, `width`, `wrap`, `show`, `focus`, `defaultRoot`.
|
|
101
104
|
|
|
102
105
|
`show` is a list of normally-skipped categories to opt into. Known categories: `hidden` (dot-prefixed entries), `gitignored` (entries matched by a `.gitignore`). Default is the empty list. Hard skips (`node_modules`, `.git`, `.venv`) always apply.
|
|
103
106
|
|
|
104
107
|
Precedence, highest to lowest:
|
|
105
108
|
|
|
106
|
-
1. CLI flags (`--theme`, `--tone`, `--ext`, `--show`, `--focus`, `--root`)
|
|
107
|
-
2. Env vars (`HOUSE_THEME`, `HOUSE_TONE`, `HOUSE_EXTENSIONS`, `HOUSE_SHOW`, `HOUSE_FOCUS`, `HOUSE_DEFAULT_ROOT`)
|
|
109
|
+
1. CLI flags (`--theme`, `--tone`, `--ext`, `--width`, `--wrap`, `--no-wrap`, `--show`, `--focus`, `--root`)
|
|
110
|
+
2. Env vars (`HOUSE_THEME`, `HOUSE_TONE`, `HOUSE_EXTENSIONS`, `HOUSE_WIDTH`, `HOUSE_WRAP`, `HOUSE_SHOW`, `HOUSE_FOCUS`, `HOUSE_DEFAULT_ROOT`)
|
|
108
111
|
3. Config file
|
|
109
|
-
4. Built-in defaults (`opencode` / `dark` / `extensions = []` / `show = []` / `focus = "
|
|
112
|
+
4. Built-in defaults (`opencode` / `dark` / `extensions = []` / `width = 80` / `wrap = false` / `show = []` / `focus = "sidebar"` / `defaultRoot = "cwd"`)
|
|
110
113
|
|
|
111
114
|
`HOUSE_SHOW` takes a comma-separated list (`HOUSE_SHOW=hidden,gitignored`). For `show` specifically, each source completely replaces the next — categories don't merge across layers. Press `shift+a` in the TUI to round-trip between the configured set and the full vocabulary without editing config.
|
|
112
115
|
|
|
116
|
+
`width` is the fixed reader width used when wrapping is enabled. `wrap` controls startup mode; press `w` in the TUI to toggle reader wrapping for the current session without editing config.
|
|
117
|
+
|
|
113
118
|
The file is optional — a missing file is fine. Invalid keys, unknown themes, or malformed TOML fail loudly with a one-line error. Per-project config (`.house/config.toml`) and additional keys are deferred.
|
|
114
119
|
|
|
115
120
|
## Keys
|
|
@@ -122,6 +127,7 @@ The file is optional — a missing file is fine. Invalid keys, unknown themes, o
|
|
|
122
127
|
| `tab` | Toggle focus (sidebar ↔ reader) |
|
|
123
128
|
| `s` | Toggle sidebar visibility |
|
|
124
129
|
| `ctrl+p` | Command palette |
|
|
130
|
+
| `w` | Toggle reader wrap |
|
|
125
131
|
| `O` | Open current file in browser as HTML |
|
|
126
132
|
| `E` | Open current file in `$EDITOR` (`$VISUAL` takes precedence) |
|
|
127
133
|
| `t` | Next theme |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@carlesandres/house",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.13",
|
|
4
4
|
"description": "TUI-first markdown reader on opentui",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -51,11 +51,11 @@
|
|
|
51
51
|
"prepare": "git config core.hooksPath .githooks 2>/dev/null || true"
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
|
-
"@effect/atom-react": "4.0.0-beta.
|
|
54
|
+
"@effect/atom-react": "4.0.0-beta.60",
|
|
55
55
|
"@opentui/core": "0.2.15",
|
|
56
56
|
"@opentui/react": "0.2.15",
|
|
57
57
|
"commander": "^14.0.3",
|
|
58
|
-
"effect": "4.0.0-beta.
|
|
58
|
+
"effect": "4.0.0-beta.60",
|
|
59
59
|
"ignore": "^7.0.5",
|
|
60
60
|
"marked": "^18.0.4",
|
|
61
61
|
"react": "19.2.6",
|
package/src/Browser.tsx
CHANGED
|
@@ -25,16 +25,12 @@ import { parseFrontmatter } from "./markdown/frontmatter.ts"
|
|
|
25
25
|
import { BRAND, BRAND_NAME } from "./brand.ts"
|
|
26
26
|
import { Footer, FOOTER_HEIGHT, type FooterProps } from "./Footer.tsx"
|
|
27
27
|
import { Header, HEADER_HEIGHT } from "./Header.tsx"
|
|
28
|
+
import { copyTextToClipboard } from "./io/clipboard.ts"
|
|
28
29
|
import { openInEditor, resolveEditor } from "./io/editor.ts"
|
|
29
30
|
import { readFileText } from "./io/readFile.ts"
|
|
30
31
|
import { browserBindings, type BrowserCtx } from "./keymap/browser.ts"
|
|
31
32
|
import { dispatch } from "./keymap/keymap.ts"
|
|
32
|
-
import {
|
|
33
|
-
canFitInline,
|
|
34
|
-
defaultPreferredWidth,
|
|
35
|
-
initialShownForAuto,
|
|
36
|
-
resolveSidebarWidth,
|
|
37
|
-
} from "./layout/resolve.ts"
|
|
33
|
+
import { canFitInline, defaultPreferredWidth, resolveSidebarWidth } from "./layout/resolve.ts"
|
|
38
34
|
import { fitSidebarEmptyValue } from "./layout/sidebarEmptyState.ts"
|
|
39
35
|
import { formatSidebarRow } from "./layout/sidebarRow.ts"
|
|
40
36
|
import { PromptRow } from "./PromptRow.tsx"
|
|
@@ -48,7 +44,6 @@ import { themeDefinitions, getThemeDefinition } from "./theme/registry.ts"
|
|
|
48
44
|
import { saveThemePreference } from "./config/save.ts"
|
|
49
45
|
import { middleTruncate } from "./ui/middleTruncate.ts"
|
|
50
46
|
|
|
51
|
-
export type SidebarMode = "auto" | "on" | "off"
|
|
52
47
|
export type StartupFocus = "sidebar" | "reader" | "filter"
|
|
53
48
|
|
|
54
49
|
export interface BrowserProps {
|
|
@@ -56,8 +51,10 @@ export interface BrowserProps {
|
|
|
56
51
|
readonly initialIndex?: number
|
|
57
52
|
/** Initial applied filter query seeded from the CLI positional. */
|
|
58
53
|
readonly initialQuery?: string
|
|
59
|
-
/**
|
|
60
|
-
readonly
|
|
54
|
+
/** Reader wrap width used when wrapping is enabled. */
|
|
55
|
+
readonly wrapWidth?: number
|
|
56
|
+
/** Initial reader wrap mode. Runtime toggles are session-only. */
|
|
57
|
+
readonly initialWrap?: boolean
|
|
61
58
|
/** Discovery root label used in the post-discovery empty-vault sidebar row. */
|
|
62
59
|
readonly emptyRootLabel?: string
|
|
63
60
|
/** Persistent footer indicator (e.g. "indexing… 42"). Pass null/undefined
|
|
@@ -74,12 +71,11 @@ export interface BrowserProps {
|
|
|
74
71
|
readonly renderedPathDebounceMs?: number
|
|
75
72
|
/** Test seam: disable reader-empty-state tip rotation effect. */
|
|
76
73
|
readonly disableReaderEmptyStateRotation?: boolean
|
|
77
|
-
/** Initial sidebar visibility (`--sidebar` flag). `auto` consults the
|
|
78
|
-
* launch viewport bucket once; subsequent visibility goes through `s`. */
|
|
79
|
-
readonly sidebarMode?: SidebarMode
|
|
80
74
|
readonly onQuit?: () => void
|
|
81
75
|
/** Test seam: replaces the file reader. */
|
|
82
76
|
readonly readFile?: (path: string) => Promise<string>
|
|
77
|
+
/** Test seam: replaces the system clipboard writer. */
|
|
78
|
+
readonly copyToClipboard?: (text: string) => Promise<void>
|
|
83
79
|
/** Optional one-shot footer toast surfaced on first appearance (e.g. the
|
|
84
80
|
* "update available" nudge). Shown with an extended TTL so the user has
|
|
85
81
|
* time to read it; subsequent transient toasts (theme cycle, etc.)
|
|
@@ -189,7 +185,8 @@ export const Browser = ({
|
|
|
189
185
|
files,
|
|
190
186
|
initialIndex = 0,
|
|
191
187
|
initialQuery = "",
|
|
192
|
-
|
|
188
|
+
wrapWidth = 80,
|
|
189
|
+
initialWrap = false,
|
|
193
190
|
emptyRootLabel = "current root",
|
|
194
191
|
discoveryStatus = null,
|
|
195
192
|
discoverySpinnerIntervalMs,
|
|
@@ -198,9 +195,9 @@ export const Browser = ({
|
|
|
198
195
|
filterDebounceMs = FILTER_DEBOUNCE_MS,
|
|
199
196
|
renderedPathDebounceMs = RENDERED_PATH_DEBOUNCE_MS,
|
|
200
197
|
disableReaderEmptyStateRotation = false,
|
|
201
|
-
sidebarMode = "auto",
|
|
202
198
|
onQuit,
|
|
203
199
|
readFile = defaultReadFile,
|
|
200
|
+
copyToClipboard = copyTextToClipboard,
|
|
204
201
|
updateNotice = null,
|
|
205
202
|
updateNoticeTtlMs = 10000,
|
|
206
203
|
disableFooterNoticeAutoClear = false,
|
|
@@ -216,24 +213,13 @@ export const Browser = ({
|
|
|
216
213
|
const [selectedIndex, setSelectedIndex] = useState(() =>
|
|
217
214
|
clamp(initialIndex, 0, Math.max(0, files.length - 1)),
|
|
218
215
|
)
|
|
216
|
+
const [wrapEnabled, setWrapEnabled] = useState(initialWrap)
|
|
219
217
|
const [loaded, setLoaded] = useState<{ path: string; content: string } | null>(null)
|
|
220
218
|
const [error, setError] = useState<string | null>(null)
|
|
221
|
-
// `shown` is the user's sticky preference.
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
// useState initializer pins this to the first render — buckets are
|
|
226
|
-
// launch-only by design, so resize must NOT re-evaluate.
|
|
227
|
-
const [shown, setShown] = useState<boolean>(() => {
|
|
228
|
-
switch (sidebarMode) {
|
|
229
|
-
case "on":
|
|
230
|
-
return true
|
|
231
|
-
case "off":
|
|
232
|
-
return false
|
|
233
|
-
case "auto":
|
|
234
|
-
return initialShownForAuto(width)
|
|
235
|
-
}
|
|
236
|
-
})
|
|
219
|
+
// `shown` is the user's sticky interactive preference. The sidebar starts
|
|
220
|
+
// visible; after launch, `s`/`tab`/`/` may hide or reveal it. Visibility is
|
|
221
|
+
// derived: `visible = shown || focus === "sidebar"`. See DESIGN.md §7.1.
|
|
222
|
+
const [shown, setShown] = useState<boolean>(true)
|
|
237
223
|
const startInFilter = startupFocus === "filter"
|
|
238
224
|
const initialFocus: "sidebar" | "reader" =
|
|
239
225
|
startupFocus === null
|
|
@@ -245,13 +231,12 @@ export const Browser = ({
|
|
|
245
231
|
: "sidebar"
|
|
246
232
|
// `filter` mirrors `openFilter`'s focus rule: the filter input lives in
|
|
247
233
|
// the sidebar, so opening it on mount also forces sidebar focus regardless
|
|
248
|
-
// of
|
|
249
|
-
// focus even when `shown` is false). Plain
|
|
250
|
-
// same pane focus without opening the prompt.
|
|
251
|
-
// legacy Browser behavior: initial focus follows
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
)
|
|
234
|
+
// of the current interactive visibility state (§7.1's visibility derivation
|
|
235
|
+
// surfaces the sidebar via focus even when `shown` is false). Plain
|
|
236
|
+
// `sidebar` startup shares the same pane focus without opening the prompt.
|
|
237
|
+
// When omitted, preserve the legacy Browser behavior: initial focus follows
|
|
238
|
+
// visibility.
|
|
239
|
+
const [focus, setFocus] = useState<"sidebar" | "reader">(() => initialFocus)
|
|
255
240
|
const [sidebarScroll, setSidebarScroll] = useState<number>(0)
|
|
256
241
|
const [filterOpen, setFilterOpen] = useState<boolean>(startInFilter)
|
|
257
242
|
const [filterInput, setFilterInput] = useState<string>(initialQuery)
|
|
@@ -492,6 +477,7 @@ export const Browser = ({
|
|
|
492
477
|
restoreFilterOnSidebarFocus: restoreFilterOnSidebarFocusRef.current,
|
|
493
478
|
filterQuery: filterInput,
|
|
494
479
|
paletteOpen,
|
|
480
|
+
wrapEnabled,
|
|
495
481
|
setFocus,
|
|
496
482
|
// Wrapped so any keymap-driven selection move (j/k/g/G/[/], reader
|
|
497
483
|
// prev/next) clears the pending-restore ref from #145. Internal
|
|
@@ -565,6 +551,7 @@ export const Browser = ({
|
|
|
565
551
|
setPaletteIndex(0)
|
|
566
552
|
dispatchFloatingOverlay({ type: "open-command-palette" })
|
|
567
553
|
},
|
|
554
|
+
toggleWrap: () => setWrapEnabled((prev) => !prev),
|
|
568
555
|
cycleTheme,
|
|
569
556
|
toggleTone,
|
|
570
557
|
toggleAll: () => {
|
|
@@ -674,6 +661,26 @@ export const Browser = ({
|
|
|
674
661
|
}
|
|
675
662
|
})()
|
|
676
663
|
},
|
|
664
|
+
copyCurrentContents: () => {
|
|
665
|
+
const file = displayedFiles[selectedIndex]
|
|
666
|
+
if (!file) return
|
|
667
|
+
void (async () => {
|
|
668
|
+
let text: string
|
|
669
|
+
try {
|
|
670
|
+
text = await readFile(file.path)
|
|
671
|
+
} catch {
|
|
672
|
+
pushFooterNotice(`copy failed: cannot read ${file.relativePath}`)
|
|
673
|
+
return
|
|
674
|
+
}
|
|
675
|
+
try {
|
|
676
|
+
await copyToClipboard(text)
|
|
677
|
+
pushFooterNotice(`copied ${file.relativePath}`)
|
|
678
|
+
} catch (err) {
|
|
679
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
680
|
+
pushFooterNotice(`copy failed: ${message}`)
|
|
681
|
+
}
|
|
682
|
+
})()
|
|
683
|
+
},
|
|
677
684
|
}
|
|
678
685
|
|
|
679
686
|
useKeyboard((key) => {
|
|
@@ -860,6 +867,12 @@ export const Browser = ({
|
|
|
860
867
|
const isNarrow = !canFitInline(width)
|
|
861
868
|
const sidebarInline = isNarrow ? sidebarActive : shown || sidebarActive
|
|
862
869
|
const readerVisible = isNarrow ? readerActive : true
|
|
870
|
+
// When fixed-width wrapping is enabled, never size markdown wider than the
|
|
871
|
+
// visible reader body. The scrollbox deliberately disables horizontal
|
|
872
|
+
// scrolling, so an over-wide markdown node clips instead of wrapping.
|
|
873
|
+
const readerPaneWidth = isNarrow || !sidebarInline ? width : Math.max(1, width - sidebarWidth)
|
|
874
|
+
const readerBodyWidth = Math.max(1, readerPaneWidth - 2) // reader padding: 1 left + 1 right
|
|
875
|
+
const markdownWidth = wrapEnabled ? Math.min(wrapWidth, readerBodyWidth) : "100%"
|
|
863
876
|
// Currently-selected file shown in the Header (which replaced the
|
|
864
877
|
// per-pane border title that used to carry this information).
|
|
865
878
|
const currentFile = selected?.relativePath ?? null
|
|
@@ -924,6 +937,15 @@ export const Browser = ({
|
|
|
924
937
|
width,
|
|
925
938
|
notice: footerNotice?.text ?? null,
|
|
926
939
|
discoveryStatus,
|
|
940
|
+
indicators: [
|
|
941
|
+
{
|
|
942
|
+
id: "wrap",
|
|
943
|
+
icon: "W",
|
|
944
|
+
variant: "info",
|
|
945
|
+
active: wrapEnabled,
|
|
946
|
+
onMouseUp: () => setWrapEnabled((prev) => !prev),
|
|
947
|
+
},
|
|
948
|
+
],
|
|
927
949
|
...(discoverySpinnerIntervalMs === undefined ? {} : { discoverySpinnerIntervalMs }),
|
|
928
950
|
...(discoverySpinnerInitialFrameIndex === undefined
|
|
929
951
|
? {}
|
|
@@ -1162,7 +1184,7 @@ export const Browser = ({
|
|
|
1162
1184
|
fg={colors.text}
|
|
1163
1185
|
bg={readerActive ? colors.background : colors.backgroundPanel}
|
|
1164
1186
|
conceal
|
|
1165
|
-
style={{ width:
|
|
1187
|
+
style={{ width: markdownWidth }}
|
|
1166
1188
|
/>
|
|
1167
1189
|
</scrollbox>
|
|
1168
1190
|
)}
|
package/src/CommandPalette.tsx
CHANGED
|
@@ -88,9 +88,12 @@ export const CommandPalette = ({
|
|
|
88
88
|
const overlayWidth = Math.min(viewportWidth - 4, 64)
|
|
89
89
|
const rows = buildRows(commands)
|
|
90
90
|
// Reserve: 2 for border (top+bottom), 1 query row, 1 spacer below query,
|
|
91
|
-
// 1 spacer above footer, 1 footer row. Body gets the rest.
|
|
91
|
+
// 1 spacer above footer, 1 footer row. Body gets the rest. Keep a one-row
|
|
92
|
+
// viewport margin above and below when possible; the modal itself already
|
|
93
|
+
// owns a border and clips its body, so a larger fixed margin needlessly hides
|
|
94
|
+
// the final command on medium-height terminals.
|
|
92
95
|
const chrome = 2 + 1 + 1 + 1 + 1
|
|
93
|
-
const maxBody = Math.max(1, viewportHeight -
|
|
96
|
+
const maxBody = Math.max(1, viewportHeight - 2 - chrome)
|
|
94
97
|
const desiredBody = Math.max(1, rows.length || 1)
|
|
95
98
|
const bodyHeight = Math.min(desiredBody, maxBody)
|
|
96
99
|
const overlayHeight = chrome + bodyHeight
|
package/src/Footer.tsx
CHANGED
|
@@ -24,6 +24,7 @@ import type React from "react"
|
|
|
24
24
|
import type { KeyBinding } from "./keymap/keymap.ts"
|
|
25
25
|
import { displayKey } from "./keymap/displayKey.ts"
|
|
26
26
|
import { Spinner } from "./Spinner.tsx"
|
|
27
|
+
import { StatusIndicator, type StatusIndicatorProps } from "./StatusIndicator.tsx"
|
|
27
28
|
import { colors } from "./theme/colors.ts"
|
|
28
29
|
|
|
29
30
|
/** Rows the Footer occupies. Importers use it for layout math so a future
|
|
@@ -39,6 +40,8 @@ export interface FooterProps<C> {
|
|
|
39
40
|
* no TTL, cleared by the caller when the underlying activity finishes.
|
|
40
41
|
* Loses to `notice` when both are set so transient toasts still surface. */
|
|
41
42
|
readonly discoveryStatus?: string | null
|
|
43
|
+
/** Persistent compact state/status indicators, rendered after built-in warning indicators. */
|
|
44
|
+
readonly indicators?: readonly (StatusIndicatorProps & { readonly id: string })[]
|
|
42
45
|
/** Test seam: override spinner tick speed so tests don't sleep on the full
|
|
43
46
|
* production interval. Ignored when discoveryStatus is null. */
|
|
44
47
|
readonly discoverySpinnerIntervalMs?: number
|
|
@@ -86,6 +89,7 @@ const fitHints = (hints: readonly Hint[], width: number): Hint[] => {
|
|
|
86
89
|
}
|
|
87
90
|
|
|
88
91
|
const STATUS_SEPARATOR = " · "
|
|
92
|
+
const NON_WARNING_STATUS_PREFIX_WIDTH = 2 // 1-cell spinner + 1-cell spacer
|
|
89
93
|
|
|
90
94
|
const isPartialDiscoveryWarning = (status: string | null): boolean =>
|
|
91
95
|
status?.startsWith("scan incomplete:") ?? false
|
|
@@ -96,6 +100,7 @@ export const Footer = <C,>({
|
|
|
96
100
|
width,
|
|
97
101
|
notice,
|
|
98
102
|
discoveryStatus,
|
|
103
|
+
indicators = [],
|
|
99
104
|
discoverySpinnerIntervalMs,
|
|
100
105
|
discoverySpinnerInitialFrameIndex,
|
|
101
106
|
discoverySpinnerRegisterTick,
|
|
@@ -130,19 +135,48 @@ export const Footer = <C,>({
|
|
|
130
135
|
const status =
|
|
131
136
|
discoveryStatus && discoveryStatus.length > 0 ? normalizeStatusLine(discoveryStatus) : null
|
|
132
137
|
const isPartialWarning = isPartialDiscoveryWarning(status)
|
|
138
|
+
const renderedIndicators = [
|
|
139
|
+
...(isPartialWarning
|
|
140
|
+
? [
|
|
141
|
+
{
|
|
142
|
+
id: "discovery-warning",
|
|
143
|
+
icon: "!",
|
|
144
|
+
variant: "warning" as const,
|
|
145
|
+
active: true,
|
|
146
|
+
...(onDiscoveryWarningToggle === undefined
|
|
147
|
+
? {}
|
|
148
|
+
: { onMouseUp: onDiscoveryWarningToggle }),
|
|
149
|
+
},
|
|
150
|
+
]
|
|
151
|
+
: []),
|
|
152
|
+
...indicators,
|
|
153
|
+
]
|
|
154
|
+
const indicatorBudget = Math.min(usableWidth, renderedIndicators.length * 3)
|
|
155
|
+
const contentBudget = Math.max(0, usableWidth - indicatorBudget)
|
|
156
|
+
const statusChromeWidth = status
|
|
157
|
+
? (isPartialWarning ? 0 : NON_WARNING_STATUS_PREFIX_WIDTH) + STATUS_SEPARATOR.length
|
|
158
|
+
: 0
|
|
133
159
|
const statusBudget = status
|
|
134
|
-
? Math.min((isPartialWarning ?
|
|
160
|
+
? Math.min((isPartialWarning ? 0 : status.length) + statusChromeWidth, contentBudget)
|
|
135
161
|
: 0
|
|
136
|
-
const hintsWidth = Math.max(0,
|
|
162
|
+
const hintsWidth = Math.max(0, contentBudget - statusBudget)
|
|
137
163
|
const visibleHints = fitHints(hints, hintsWidth)
|
|
138
|
-
const
|
|
139
|
-
?
|
|
164
|
+
const nonWarningStatusPrefixBudget =
|
|
165
|
+
status && !isPartialWarning ? Math.min(NON_WARNING_STATUS_PREFIX_WIDTH, statusBudget) : 0
|
|
166
|
+
const statusContent = status ? status.slice(0, Math.max(0, statusBudget - statusChromeWidth)) : ""
|
|
167
|
+
const statusSeparatorContent = status
|
|
168
|
+
? STATUS_SEPARATOR.slice(
|
|
169
|
+
0,
|
|
170
|
+
Math.max(0, statusBudget - nonWarningStatusPrefixBudget - statusContent.length),
|
|
171
|
+
)
|
|
140
172
|
: ""
|
|
141
173
|
const noticeContent = notice
|
|
142
|
-
? notice.length >
|
|
143
|
-
? notice.slice(0,
|
|
174
|
+
? notice.length > contentBudget
|
|
175
|
+
? notice.slice(0, contentBudget)
|
|
144
176
|
: notice
|
|
145
177
|
: null
|
|
178
|
+
const renderIndicators = () =>
|
|
179
|
+
renderedIndicators.map(({ id, ...props }) => <StatusIndicator key={id} {...props} />)
|
|
146
180
|
|
|
147
181
|
// Two-tone hint row: keys render in `text` (foreground-strength), the
|
|
148
182
|
// `:label` portion in `textMuted`. Matches ghui's footer treatment so
|
|
@@ -177,25 +211,12 @@ export const Footer = <C,>({
|
|
|
177
211
|
if (noticeContent !== null) {
|
|
178
212
|
return (
|
|
179
213
|
<box style={rowStyle}>
|
|
214
|
+
{renderIndicators()}
|
|
180
215
|
<text content={noticeContent} wrapMode="none" style={{ fg: colors.primary }} />
|
|
181
216
|
</box>
|
|
182
217
|
)
|
|
183
218
|
}
|
|
184
219
|
|
|
185
|
-
const warningTrigger = isPartialWarning ? (
|
|
186
|
-
<box
|
|
187
|
-
{...(onDiscoveryWarningToggle === undefined ? {} : { onMouseUp: onDiscoveryWarningToggle })}
|
|
188
|
-
style={{
|
|
189
|
-
width: 1,
|
|
190
|
-
height: 1,
|
|
191
|
-
flexDirection: "row",
|
|
192
|
-
backgroundColor: colors.backgroundElement,
|
|
193
|
-
}}
|
|
194
|
-
>
|
|
195
|
-
<text content="!" wrapMode="none" style={{ fg: colors.warning, attributes: 1 }} />
|
|
196
|
-
</box>
|
|
197
|
-
) : null
|
|
198
|
-
|
|
199
220
|
if (status !== null) {
|
|
200
221
|
const spinnerProps = {
|
|
201
222
|
fg: colors.secondary,
|
|
@@ -212,20 +233,26 @@ export const Footer = <C,>({
|
|
|
212
233
|
|
|
213
234
|
return (
|
|
214
235
|
<box style={rowStyle}>
|
|
215
|
-
{
|
|
216
|
-
{
|
|
236
|
+
{renderIndicators()}
|
|
237
|
+
{nonWarningStatusPrefixBudget >= 1 ? <Spinner {...spinnerProps} /> : null}
|
|
238
|
+
{nonWarningStatusPrefixBudget >= 2 ? (
|
|
217
239
|
<text content=" " wrapMode="none" style={{ fg: colors.textMuted }} />
|
|
218
|
-
)}
|
|
219
|
-
{isPartialWarning ? (
|
|
220
|
-
warningTrigger
|
|
221
|
-
) : (
|
|
240
|
+
) : null}
|
|
241
|
+
{isPartialWarning ? null : (
|
|
222
242
|
<text content={statusContent} wrapMode="none" style={{ fg: colors.secondary }} />
|
|
223
243
|
)}
|
|
224
|
-
|
|
244
|
+
{statusSeparatorContent.length > 0 && (
|
|
245
|
+
<text content={statusSeparatorContent} wrapMode="none" style={{ fg: colors.textMuted }} />
|
|
246
|
+
)}
|
|
225
247
|
{renderHints()}
|
|
226
248
|
</box>
|
|
227
249
|
)
|
|
228
250
|
}
|
|
229
251
|
|
|
230
|
-
return
|
|
252
|
+
return (
|
|
253
|
+
<box style={rowStyle}>
|
|
254
|
+
{renderIndicators()}
|
|
255
|
+
{renderHints()}
|
|
256
|
+
</box>
|
|
257
|
+
)
|
|
231
258
|
}
|
|
@@ -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
|
}
|
package/src/index.tsx
CHANGED
|
@@ -36,8 +36,6 @@ import { useUpdateNotice } from "./update/useUpdateNotice.ts"
|
|
|
36
36
|
* `Quit` in Browser tears down the renderer and exits, which propagates
|
|
37
37
|
* naturally — the cleanup effect still fires before process.exit completes.
|
|
38
38
|
*/
|
|
39
|
-
export type SidebarMode = "auto" | "on" | "off"
|
|
40
|
-
|
|
41
39
|
const pathIsDirectory = async (path: string): Promise<boolean> => {
|
|
42
40
|
try {
|
|
43
41
|
return (await stat(path)).isDirectory()
|
|
@@ -107,8 +105,8 @@ interface DiscoverShellProps {
|
|
|
107
105
|
* independent everywhere else. */
|
|
108
106
|
readonly initialShow: readonly ShowCategory[]
|
|
109
107
|
readonly extensions: readonly string[]
|
|
110
|
-
readonly
|
|
111
|
-
readonly
|
|
108
|
+
readonly wrapWidth: number
|
|
109
|
+
readonly initialWrap: boolean
|
|
112
110
|
readonly startupFocus: StartupFocus
|
|
113
111
|
}
|
|
114
112
|
|
|
@@ -117,8 +115,8 @@ export const DiscoverShell = ({
|
|
|
117
115
|
initialQuery,
|
|
118
116
|
initialShow,
|
|
119
117
|
extensions,
|
|
120
|
-
|
|
121
|
-
|
|
118
|
+
wrapWidth,
|
|
119
|
+
initialWrap,
|
|
122
120
|
startupFocus,
|
|
123
121
|
}: DiscoverShellProps) => {
|
|
124
122
|
const updateNotice = useUpdateNotice()
|
|
@@ -194,10 +192,10 @@ export const DiscoverShell = ({
|
|
|
194
192
|
<Browser
|
|
195
193
|
files={files}
|
|
196
194
|
initialQuery={initialQuery}
|
|
197
|
-
|
|
195
|
+
wrapWidth={wrapWidth}
|
|
196
|
+
initialWrap={initialWrap}
|
|
198
197
|
emptyRootLabel={target}
|
|
199
198
|
discoveryStatus={discoveryStatus}
|
|
200
|
-
sidebarMode={sidebarMode}
|
|
201
199
|
startupFocus={startupFocus}
|
|
202
200
|
updateNotice={updateNotice}
|
|
203
201
|
onToggleAll={() => {
|
|
@@ -240,6 +238,14 @@ if (import.meta.main) {
|
|
|
240
238
|
console.log(usage)
|
|
241
239
|
process.exit(0)
|
|
242
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
|
+
}
|
|
243
249
|
if (args.version) {
|
|
244
250
|
console.log(pkg.version)
|
|
245
251
|
process.exit(0)
|
|
@@ -282,13 +288,34 @@ if (import.meta.main) {
|
|
|
282
288
|
args.focus === "sidebar" || args.focus === "reader" || args.focus === "filter"
|
|
283
289
|
? args.focus
|
|
284
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,
|
|
285
303
|
},
|
|
286
304
|
}),
|
|
287
305
|
).catch((err: unknown) => {
|
|
288
306
|
console.error(`house: ${formatConfigError(err)}`)
|
|
289
307
|
process.exit(2)
|
|
290
308
|
})
|
|
291
|
-
const {
|
|
309
|
+
const {
|
|
310
|
+
theme: themeId,
|
|
311
|
+
tone,
|
|
312
|
+
extensions,
|
|
313
|
+
show,
|
|
314
|
+
focus: startupFocus,
|
|
315
|
+
defaultRoot,
|
|
316
|
+
width: wrapWidth,
|
|
317
|
+
wrap: initialWrap,
|
|
318
|
+
} = config
|
|
292
319
|
const themeDef = getThemeDefinition(themeId)
|
|
293
320
|
if (themeDef === undefined) {
|
|
294
321
|
// Unreachable: Config.schema validated themeId against themeDefinitions.
|
|
@@ -297,16 +324,6 @@ if (import.meta.main) {
|
|
|
297
324
|
}
|
|
298
325
|
setActiveTheme(themeDef, tone)
|
|
299
326
|
|
|
300
|
-
let maxWidth: number | null = null
|
|
301
|
-
if (args.width !== null) {
|
|
302
|
-
const n = Number.parseInt(args.width, 10)
|
|
303
|
-
if (!Number.isFinite(n) || n <= 0) {
|
|
304
|
-
console.error(`house: --width must be a positive integer, got "${args.width}"`)
|
|
305
|
-
process.exit(2)
|
|
306
|
-
}
|
|
307
|
-
maxWidth = n
|
|
308
|
-
}
|
|
309
|
-
|
|
310
327
|
const cwd = process.cwd()
|
|
311
328
|
const discoveryRoot = await resolveDiscoveryRoot({ cliRoot: args.root, defaultRoot, cwd })
|
|
312
329
|
const initialQuery = resolveInitialQuery({ pathArg: args.path, discoveryRoot, cwd })
|
|
@@ -349,14 +366,6 @@ if (import.meta.main) {
|
|
|
349
366
|
process.on("SIGTERM", shutdown)
|
|
350
367
|
// Bun.serve keeps the event loop alive until stop().
|
|
351
368
|
} else {
|
|
352
|
-
let sidebarMode: SidebarMode = "auto"
|
|
353
|
-
if (args.sidebar !== null) {
|
|
354
|
-
if (args.sidebar !== "auto" && args.sidebar !== "on" && args.sidebar !== "off") {
|
|
355
|
-
console.error(`house: --sidebar must be "auto", "on", or "off", got "${args.sidebar}"`)
|
|
356
|
-
process.exit(2)
|
|
357
|
-
}
|
|
358
|
-
sidebarMode = args.sidebar
|
|
359
|
-
}
|
|
360
369
|
if (args.focus !== null) {
|
|
361
370
|
if (args.focus !== "sidebar" && args.focus !== "reader" && args.focus !== "filter") {
|
|
362
371
|
console.error(
|
|
@@ -370,10 +379,10 @@ if (import.meta.main) {
|
|
|
370
379
|
initialQuery,
|
|
371
380
|
themeId,
|
|
372
381
|
tone,
|
|
373
|
-
|
|
382
|
+
wrapWidth,
|
|
383
|
+
initialWrap,
|
|
374
384
|
show,
|
|
375
385
|
extensions,
|
|
376
|
-
sidebarMode,
|
|
377
386
|
startupFocus,
|
|
378
387
|
updateCheck: !args.noUpdateCheck,
|
|
379
388
|
})
|
|
@@ -385,10 +394,10 @@ interface TuiBootOptions {
|
|
|
385
394
|
readonly initialQuery: string
|
|
386
395
|
readonly themeId: string
|
|
387
396
|
readonly tone: "dark" | "light"
|
|
388
|
-
readonly
|
|
397
|
+
readonly wrapWidth: number
|
|
398
|
+
readonly initialWrap: boolean
|
|
389
399
|
readonly show: readonly ShowCategory[]
|
|
390
400
|
readonly extensions: readonly string[]
|
|
391
|
-
readonly sidebarMode: SidebarMode
|
|
392
401
|
readonly startupFocus: StartupFocus
|
|
393
402
|
/** Run the npm-registry probe and surface the "update available" notice.
|
|
394
403
|
* False suppresses both the toast and the quit-time print. */
|
|
@@ -400,10 +409,10 @@ async function runTui({
|
|
|
400
409
|
initialQuery,
|
|
401
410
|
themeId,
|
|
402
411
|
tone,
|
|
403
|
-
|
|
412
|
+
wrapWidth,
|
|
413
|
+
initialWrap,
|
|
404
414
|
show,
|
|
405
415
|
extensions,
|
|
406
|
-
sidebarMode,
|
|
407
416
|
startupFocus,
|
|
408
417
|
updateCheck,
|
|
409
418
|
}: TuiBootOptions): Promise<void> {
|
|
@@ -446,8 +455,8 @@ async function runTui({
|
|
|
446
455
|
initialQuery={initialQuery}
|
|
447
456
|
initialShow={show}
|
|
448
457
|
extensions={extensions}
|
|
449
|
-
|
|
450
|
-
|
|
458
|
+
wrapWidth={wrapWidth}
|
|
459
|
+
initialWrap={initialWrap}
|
|
451
460
|
startupFocus={startupFocus}
|
|
452
461
|
/>
|
|
453
462
|
</RegistryProvider>,
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort clipboard write for macOS/Linux.
|
|
3
|
+
*
|
|
4
|
+
* The selected tool is fed through stdin so large or multiline markdown can be
|
|
5
|
+
* copied without shell quoting issues.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface ClipboardCommand {
|
|
9
|
+
readonly cmd: string
|
|
10
|
+
readonly args: readonly string[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type WhichFn = (command: string) => string | null | undefined
|
|
14
|
+
|
|
15
|
+
interface CopyToClipboardDeps {
|
|
16
|
+
readonly platform?: NodeJS.Platform
|
|
17
|
+
readonly which?: WhichFn
|
|
18
|
+
readonly spawn?: (command: ClipboardCommand, text: string) => Promise<number>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const resolveClipboardCommand = (
|
|
22
|
+
platform: NodeJS.Platform,
|
|
23
|
+
which: WhichFn,
|
|
24
|
+
): ClipboardCommand | null => {
|
|
25
|
+
if (platform === "darwin") return { cmd: "pbcopy", args: [] }
|
|
26
|
+
if (platform !== "linux") return null
|
|
27
|
+
if (which("wl-copy")) return { cmd: "wl-copy", args: [] }
|
|
28
|
+
if (which("xclip")) return { cmd: "xclip", args: ["-selection", "clipboard"] }
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const spawnClipboardCommand = async (command: ClipboardCommand, text: string): Promise<number> => {
|
|
33
|
+
const proc = Bun.spawn([command.cmd, ...command.args], {
|
|
34
|
+
stdin: new Response(text),
|
|
35
|
+
stdout: "ignore",
|
|
36
|
+
stderr: "ignore",
|
|
37
|
+
})
|
|
38
|
+
return await proc.exited
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const copyTextToClipboard = async (
|
|
42
|
+
text: string,
|
|
43
|
+
{
|
|
44
|
+
platform = process.platform,
|
|
45
|
+
which = Bun.which.bind(Bun),
|
|
46
|
+
spawn = spawnClipboardCommand,
|
|
47
|
+
}: CopyToClipboardDeps = {},
|
|
48
|
+
): Promise<void> => {
|
|
49
|
+
const command = resolveClipboardCommand(platform, which)
|
|
50
|
+
if (!command) throw new Error("no clipboard tool found")
|
|
51
|
+
const exitCode = await spawn(command, text)
|
|
52
|
+
if (exitCode !== 0) throw new Error("clipboard command failed")
|
|
53
|
+
}
|
package/src/keymap/browser.ts
CHANGED
|
@@ -23,6 +23,7 @@ export interface BrowserCtx {
|
|
|
23
23
|
* hint gate so the hint only appears when there is something to clear. */
|
|
24
24
|
readonly filterQuery: string
|
|
25
25
|
readonly paletteOpen: boolean
|
|
26
|
+
readonly wrapEnabled: boolean
|
|
26
27
|
readonly setFocus: (next: BrowserFocus | ((prev: BrowserFocus) => BrowserFocus)) => void
|
|
27
28
|
readonly setSelectedIndex: (updater: (prev: number) => number) => void
|
|
28
29
|
/** Toggle `shown` and adjust focus per DESIGN.md §7.1 (see s-behavior table). */
|
|
@@ -33,6 +34,7 @@ export interface BrowserCtx {
|
|
|
33
34
|
* without first reopening with `/` and backspacing. */
|
|
34
35
|
readonly clearAndOpenFilter: () => void
|
|
35
36
|
readonly openPalette: () => void
|
|
37
|
+
readonly toggleWrap: () => void
|
|
36
38
|
readonly cycleTheme: (delta: 1 | -1) => void
|
|
37
39
|
readonly toggleTone: () => void
|
|
38
40
|
readonly quit: () => void
|
|
@@ -41,6 +43,10 @@ export interface BrowserCtx {
|
|
|
41
43
|
/** Suspend the TUI, hand the TTY to `$EDITOR`, resume and re-read on
|
|
42
44
|
* exit. No-op when nothing is selected; gating is the binding's job. */
|
|
43
45
|
readonly editCurrent: () => void
|
|
46
|
+
/** Copy the currently-selected file's raw contents to the system
|
|
47
|
+
* clipboard. No-op when nothing is selected; palette gating is the
|
|
48
|
+
* primary availability control. */
|
|
49
|
+
readonly copyCurrentContents: () => void
|
|
44
50
|
/** Toggle hidden + gitignored discovery axes together (#145 — UI sugar
|
|
45
51
|
* for `shift+a`; the underlying flags stay independent everywhere
|
|
46
52
|
* else). Snapshots the current selected path so it can be restored
|
|
@@ -162,6 +168,15 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
|
|
|
162
168
|
// does not write back to the TOML config.
|
|
163
169
|
run: (c) => c.toggleAll(),
|
|
164
170
|
},
|
|
171
|
+
{
|
|
172
|
+
id: "reader.wrap.toggle",
|
|
173
|
+
group: "Global",
|
|
174
|
+
description: "Toggle reader wrap",
|
|
175
|
+
hint: "wrap",
|
|
176
|
+
keys: ["w"],
|
|
177
|
+
hintWhen: inputClosed,
|
|
178
|
+
run: (c) => c.toggleWrap(),
|
|
179
|
+
},
|
|
165
180
|
{
|
|
166
181
|
id: "theme.next",
|
|
167
182
|
group: "Global",
|
package/src/layout/resolve.ts
CHANGED
|
@@ -50,11 +50,3 @@ export const defaultPreferredWidth = (viewport: number): number =>
|
|
|
50
50
|
*/
|
|
51
51
|
export const canFitInline = (viewport: number): boolean =>
|
|
52
52
|
viewport >= SIDEBAR_MIN_WIDTH + DIVIDER_WIDTH + READER_MIN_WIDTH
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Initial sidebar visibility for `--sidebar=auto`. Always true — every
|
|
56
|
-
* viewport now boots on the sidebar (narrow: as the single visible screen;
|
|
57
|
-
* wide: as the focused inline pane). `--sidebar=off` is the only way to
|
|
58
|
-
* boot directly into the reader.
|
|
59
|
-
*/
|
|
60
|
-
export const initialShownForAuto = (_viewport: number): boolean => true
|