@carlesandres/house 0.4.1 → 0.4.3
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 +44 -1
- package/README.md +22 -5
- package/package.json +15 -13
- package/src/Browser.tsx +127 -100
- package/src/CommandPalette.tsx +2 -5
- package/src/Footer.tsx +5 -1
- package/src/PromptRow.tsx +49 -0
- package/src/commands/buildCommands.ts +1 -0
- package/src/io/editor.ts +162 -0
- package/src/keymap/browser.ts +79 -22
- package/src/keymap/keymap.ts +6 -0
- package/src/layout/sidebarRow.ts +85 -0
- package/src/serve/server.ts +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,40 @@ The publish workflow (`.github/workflows/publish.yml`) runs on the `release: pub
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.4.3] — 2026-05-23
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Dependency alignment: bumped `effect` to `4.0.0-beta.70` to match `@effect/atom-react@4.0.0-beta.70`, removing npm `ERESOLVE overriding peer dependency` warnings during global installs.
|
|
14
|
+
|
|
15
|
+
## [0.4.2] — 2026-05-23
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- Editor hand-off: press `e` to open the selected file in `$EDITOR`/`$VISUAL` (`#19`).
|
|
20
|
+
- Sidebar rows now render basename-first with segment-aware parent-path elision, improving scanability in deep trees.
|
|
21
|
+
- `ctrl+\\` clears an active sidebar filter query without leaving filter mode.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- Sidebar filter input and command palette input now share a single `PromptRow` UI path for consistent rendering and behavior.
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- Dev server SSE stream (`/__reload`) now stays open past Bun's 10s idle timeout.
|
|
30
|
+
- Filter Escape behavior no longer reverts to the pre-filter selection; Escape now closes/clears filter predictably.
|
|
31
|
+
|
|
32
|
+
### Docs
|
|
33
|
+
|
|
34
|
+
- README synced with current features, options, keys, and config behavior.
|
|
35
|
+
- Windows support is now documented as POSIX-only for this release line, with tracking issues linked.
|
|
36
|
+
- ROADMAP docs updated for release tooling and standalone-binary epic breakdown.
|
|
37
|
+
|
|
38
|
+
### Tests
|
|
39
|
+
|
|
40
|
+
- Added PTY regression coverage for selection background behavior.
|
|
41
|
+
- Added sidebar coverage for basename+parent rendering and `fitTail` behavior at very narrow widths.
|
|
42
|
+
|
|
9
43
|
## [0.4.1] — 2026-05-22
|
|
10
44
|
|
|
11
45
|
### Added
|
|
@@ -139,4 +173,13 @@ The v1 MVP, published as `@carlesandres/openmdr` on npm.
|
|
|
139
173
|
|
|
140
174
|
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.
|
|
141
175
|
|
|
142
|
-
[Unreleased]: https://github.com/carlesandres/house/compare/v0.4.
|
|
176
|
+
[Unreleased]: https://github.com/carlesandres/house/compare/v0.4.3...HEAD
|
|
177
|
+
[0.4.3]: https://github.com/carlesandres/house/compare/v0.4.2...v0.4.3
|
|
178
|
+
[0.4.2]: https://github.com/carlesandres/house/compare/v0.4.1...v0.4.2
|
|
179
|
+
[0.4.1]: https://github.com/carlesandres/house/compare/v0.4.0...v0.4.1
|
|
180
|
+
[0.4.0]: https://github.com/carlesandres/house/compare/v0.3.1...v0.4.0
|
|
181
|
+
[0.3.1]: https://github.com/carlesandres/house/compare/v0.3.0...v0.3.1
|
|
182
|
+
[0.3.0]: https://github.com/carlesandres/house/compare/v0.2.1...v0.3.0
|
|
183
|
+
[0.2.1]: https://github.com/carlesandres/house/compare/v0.2.0...v0.2.1
|
|
184
|
+
[0.2.0]: https://github.com/carlesandres/house/compare/v0.1.0...v0.2.0
|
|
185
|
+
[0.1.0]: https://github.com/carlesandres/house/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -12,8 +12,9 @@ A terminal markdown reader and navigator — themable and configurable, with a k
|
|
|
12
12
|
- **Command palette**
|
|
13
13
|
- **Keyboard-driven**
|
|
14
14
|
- **Open in browser**
|
|
15
|
+
- **Open in `$EDITOR`**
|
|
15
16
|
|
|
16
|
-
Requires [Bun](https://bun.sh) on `PATH`.
|
|
17
|
+
Requires [Bun](https://bun.sh) on `PATH`. Supported on macOS and Linux; Windows is unsupported and unvalidated (see [#129](https://github.com/carlesandres/house/issues/129)).
|
|
17
18
|
|
|
18
19
|
## Install
|
|
19
20
|
|
|
@@ -37,7 +38,7 @@ bun add -g @carlesandres/house
|
|
|
37
38
|
house [options] <path>
|
|
38
39
|
```
|
|
39
40
|
|
|
40
|
-
`<path>` can be a directory (walks for `.md` files) or a single
|
|
41
|
+
`<path>` can be a directory (walks for `.md`, `.markdown`, and `.mdx` files) or a single markdown file. Defaults to the current directory if omitted.
|
|
41
42
|
|
|
42
43
|
### Options
|
|
43
44
|
|
|
@@ -45,7 +46,17 @@ house [options] <path>
|
|
|
45
46
|
|------|---------|-------------|
|
|
46
47
|
| `--theme <name>` | `opencode` | Starting theme (see list below) |
|
|
47
48
|
| `--tone dark\|light` | `dark` | Starting tone |
|
|
49
|
+
| `--width <N>` | — | Cap rendered markdown width at N columns |
|
|
50
|
+
| `--all` | off | Include hidden and gitignored files in discovery |
|
|
51
|
+
| `--sort <mode>` | `dirs-first` | Sidebar order: `dirs-first` or `files-first` |
|
|
52
|
+
| `--sidebar <mode>` | `auto` | Initial sidebar visibility: `auto`, `on`, or `off` |
|
|
53
|
+
| `--serve` | off | Serve the given file as HTML in the browser (skips TUI) |
|
|
54
|
+
| `--port <N>` | OS-assigned | Port for `--serve` |
|
|
55
|
+
| `--no-mdx` | off | Exclude `.mdx` files from discovery |
|
|
56
|
+
| `--no-update-check` | off | Suppress the "newer version available" check (also via `NO_UPDATE_NOTIFIER=1`) |
|
|
48
57
|
| `--config-path` | — | Print the resolved config-file path and exit |
|
|
58
|
+
| `-h`, `--help` | — | Show help and exit |
|
|
59
|
+
| `-v`, `--version` | — | Print version and exit |
|
|
49
60
|
|
|
50
61
|
## Configuration
|
|
51
62
|
|
|
@@ -61,14 +72,17 @@ Run `house --config-path` to print the exact location.
|
|
|
61
72
|
# ~/.config/house/config.toml
|
|
62
73
|
theme = "tokyonight"
|
|
63
74
|
tone = "dark"
|
|
75
|
+
mdx = true
|
|
64
76
|
```
|
|
65
77
|
|
|
78
|
+
Supported keys: `theme`, `tone`, `mdx`.
|
|
79
|
+
|
|
66
80
|
Precedence, highest to lowest:
|
|
67
81
|
|
|
68
|
-
1. CLI flags (`--theme`, `--tone`)
|
|
69
|
-
2. Env vars (`HOUSE_THEME`, `HOUSE_TONE`)
|
|
82
|
+
1. CLI flags (`--theme`, `--tone`, `--no-mdx`)
|
|
83
|
+
2. Env vars (`HOUSE_THEME`, `HOUSE_TONE`, `HOUSE_MDX`)
|
|
70
84
|
3. Config file
|
|
71
|
-
4. Built-in defaults (`opencode` / `dark`)
|
|
85
|
+
4. Built-in defaults (`opencode` / `dark` / `mdx = true`)
|
|
72
86
|
|
|
73
87
|
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.
|
|
74
88
|
|
|
@@ -82,7 +96,9 @@ The file is optional — a missing file is fine. Invalid keys, unknown themes, o
|
|
|
82
96
|
| `tab` | Toggle focus (sidebar ↔ reader) |
|
|
83
97
|
| `s` | Toggle sidebar visibility |
|
|
84
98
|
| `?` | Show / dismiss help overlay |
|
|
99
|
+
| `ctrl+p` | Command palette |
|
|
85
100
|
| `o` | Open current file in browser as HTML |
|
|
101
|
+
| `e` | Open current file in `$EDITOR` (`$VISUAL` takes precedence) |
|
|
86
102
|
| `t` | Next theme |
|
|
87
103
|
| `T` | Previous theme |
|
|
88
104
|
| `L` | Toggle dark / light tone |
|
|
@@ -123,6 +139,7 @@ Each theme supports dark and light tones. Cycle with `t` / `T`; toggle tone with
|
|
|
123
139
|
- [glow](https://github.com/charmbracelet/glow) — render markdown on the CLI, with pizzazz
|
|
124
140
|
- [ghui](https://github.com/kitlangton/ghui) — keyboard-driven terminal UI for GitHub pull requests
|
|
125
141
|
- [hunk](https://github.com/modem-dev/hunk) — review-first terminal diff viewer for agent-authored changesets
|
|
142
|
+
- [opencode](https://github.com/anomalyco/opencode) — terminal UI whose palette inspired house's themes
|
|
126
143
|
|
|
127
144
|
## License
|
|
128
145
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@carlesandres/house",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"description": "TUI-first markdown reader on opentui",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -44,26 +44,28 @@
|
|
|
44
44
|
"format": "oxfmt src/",
|
|
45
45
|
"format:check": "oxfmt --check src/",
|
|
46
46
|
"test": "bun test",
|
|
47
|
+
"test:pty": "HOUSE_PTY=1 bun test test/pty/",
|
|
47
48
|
"build:themes": "bun run dev/build-themes.ts",
|
|
48
49
|
"repro:pty-codeblocks": "bun run dev/repro-pty-codeblocks.ts",
|
|
49
50
|
"prepare": "git config core.hooksPath .githooks 2>/dev/null || true"
|
|
50
51
|
},
|
|
51
52
|
"dependencies": {
|
|
52
|
-
"@effect/atom-react": "4.0.0-beta.
|
|
53
|
-
"@opentui/core": "0.2.
|
|
54
|
-
"@opentui/react": "0.2.
|
|
55
|
-
"effect": "4.0.0-beta.
|
|
53
|
+
"@effect/atom-react": "4.0.0-beta.70",
|
|
54
|
+
"@opentui/core": "0.2.15",
|
|
55
|
+
"@opentui/react": "0.2.15",
|
|
56
|
+
"effect": "4.0.0-beta.70",
|
|
56
57
|
"ignore": "^7.0.5",
|
|
57
|
-
"marked": "^18.0.
|
|
58
|
-
"react": "19.2.
|
|
58
|
+
"marked": "^18.0.4",
|
|
59
|
+
"react": "19.2.6",
|
|
59
60
|
"scheduler": "0.27.0"
|
|
60
61
|
},
|
|
61
62
|
"devDependencies": {
|
|
62
|
-
"@effect/language-service": "0.
|
|
63
|
-
"@types/bun": "1.3.
|
|
64
|
-
"@types/react": "19.2.
|
|
65
|
-
"oxfmt": "0.
|
|
66
|
-
"oxlint": "1.
|
|
67
|
-
"
|
|
63
|
+
"@effect/language-service": "0.86.2",
|
|
64
|
+
"@types/bun": "1.3.14",
|
|
65
|
+
"@types/react": "19.2.15",
|
|
66
|
+
"oxfmt": "0.51.0",
|
|
67
|
+
"oxlint": "1.66.0",
|
|
68
|
+
"tuistory": "^0.8.0",
|
|
69
|
+
"typescript": "6.0.3"
|
|
68
70
|
}
|
|
69
71
|
}
|
package/src/Browser.tsx
CHANGED
|
@@ -25,6 +25,7 @@ import { BRAND, BRAND_NAME } from "./brand.ts"
|
|
|
25
25
|
import { Footer, FOOTER_HEIGHT } from "./Footer.tsx"
|
|
26
26
|
import { Header, HEADER_HEIGHT } from "./Header.tsx"
|
|
27
27
|
import { HelpOverlay } from "./HelpOverlay.tsx"
|
|
28
|
+
import { openInEditor, resolveEditor } from "./io/editor.ts"
|
|
28
29
|
import { readFileText } from "./io/readFile.ts"
|
|
29
30
|
import { browserBindings, type BrowserCtx } from "./keymap/browser.ts"
|
|
30
31
|
import { dispatch } from "./keymap/keymap.ts"
|
|
@@ -34,6 +35,8 @@ import {
|
|
|
34
35
|
initialShownForAuto,
|
|
35
36
|
resolveSidebarWidth,
|
|
36
37
|
} from "./layout/resolve.ts"
|
|
38
|
+
import { formatSidebarRow } from "./layout/sidebarRow.ts"
|
|
39
|
+
import { PromptRow } from "./PromptRow.tsx"
|
|
37
40
|
import { openInBrowser } from "./serve/openBrowser.ts"
|
|
38
41
|
import { startServer, type ServerHandle } from "./serve/server.ts"
|
|
39
42
|
import { colors, setActiveTheme } from "./theme/colors.ts"
|
|
@@ -141,9 +144,6 @@ export const Browser = ({
|
|
|
141
144
|
// otherwise still observe filterOpen=false through closure).
|
|
142
145
|
const filterOpenRef = useRef(false)
|
|
143
146
|
const filterQueryRef = useRef("")
|
|
144
|
-
// Snapshot the query at filter-open so Esc reverts edits but commit (Return)
|
|
145
|
-
// keeps them.
|
|
146
|
-
const priorFilterQueryRef = useRef("")
|
|
147
147
|
const [footerNotice, setFooterNoticeState] = useState<{
|
|
148
148
|
readonly text: string
|
|
149
149
|
readonly ttlMs: number
|
|
@@ -261,10 +261,12 @@ export const Browser = ({
|
|
|
261
261
|
// operate on what the user actually sees.
|
|
262
262
|
const ctx: BrowserCtx = {
|
|
263
263
|
files: displayedFiles,
|
|
264
|
+
hasSelected: selected != null,
|
|
264
265
|
focus,
|
|
265
266
|
sidebarShown: shown,
|
|
266
267
|
helpVisible,
|
|
267
268
|
filterOpen,
|
|
269
|
+
filterQuery,
|
|
268
270
|
paletteOpen,
|
|
269
271
|
setFocus,
|
|
270
272
|
setSelectedIndex,
|
|
@@ -299,7 +301,17 @@ export const Browser = ({
|
|
|
299
301
|
// the inline sidebar back on screen if it was hidden. In narrow,
|
|
300
302
|
// focusing the sidebar swaps to the sidebar screen. Either way
|
|
301
303
|
// no need to mutate `shown`.
|
|
302
|
-
|
|
304
|
+
if (focus !== "sidebar") setFocus("sidebar")
|
|
305
|
+
filterOpenRef.current = true
|
|
306
|
+
setFilterOpen(true)
|
|
307
|
+
},
|
|
308
|
+
clearAndOpenFilter: () => {
|
|
309
|
+
// Reset both the ref and the state so the freshly-opened modal
|
|
310
|
+
// shows an empty input and selection lands on the first file in
|
|
311
|
+
// the (now unfiltered) list.
|
|
312
|
+
filterQueryRef.current = ""
|
|
313
|
+
setFilterQuery("")
|
|
314
|
+
setSelectedIndex(() => 0)
|
|
303
315
|
if (focus !== "sidebar") setFocus("sidebar")
|
|
304
316
|
filterOpenRef.current = true
|
|
305
317
|
setFilterOpen(true)
|
|
@@ -351,65 +363,96 @@ export const Browser = ({
|
|
|
351
363
|
renderer?.destroy()
|
|
352
364
|
process.exit(0)
|
|
353
365
|
},
|
|
366
|
+
editCurrent: () => {
|
|
367
|
+
const file = displayedFiles[selectedIndex]
|
|
368
|
+
if (!file) return
|
|
369
|
+
const editor = resolveEditor(process.env)
|
|
370
|
+
if (!editor) {
|
|
371
|
+
pushFooterNotice("set $EDITOR or $VISUAL to use e")
|
|
372
|
+
return
|
|
373
|
+
}
|
|
374
|
+
if (!renderer) {
|
|
375
|
+
// Test environments without a real renderer (e.g. testRender's
|
|
376
|
+
// host) don't expose suspend/resume. Nothing safe to do here.
|
|
377
|
+
pushFooterNotice("editor unavailable in this environment")
|
|
378
|
+
return
|
|
379
|
+
}
|
|
380
|
+
// Fire-and-forget: useKeyboard's run() is synchronous, but the
|
|
381
|
+
// editor session is naturally async. We always re-enter the
|
|
382
|
+
// renderer in the finally block so a thrown error never leaves
|
|
383
|
+
// the user staring at a dead terminal.
|
|
384
|
+
void (async () => {
|
|
385
|
+
renderer.suspend()
|
|
386
|
+
renderer.currentRenderBuffer.clear()
|
|
387
|
+
let result
|
|
388
|
+
try {
|
|
389
|
+
result = await openInEditor({ editor, filePath: file.path })
|
|
390
|
+
} finally {
|
|
391
|
+
renderer.currentRenderBuffer.clear()
|
|
392
|
+
renderer.resume()
|
|
393
|
+
renderer.requestRender()
|
|
394
|
+
}
|
|
395
|
+
// Only reload the in-memory cache when the edited file is the
|
|
396
|
+
// one currently displayed. Editing a sidebar-selected file that
|
|
397
|
+
// the reader hasn't caught up to (debounce in flight) is fine —
|
|
398
|
+
// the regular load path picks up the new mtime when renderedPath
|
|
399
|
+
// advances.
|
|
400
|
+
if (file.path === renderedPath) {
|
|
401
|
+
try {
|
|
402
|
+
const text = await readFile(file.path)
|
|
403
|
+
setLoaded({ path: file.path, content: text })
|
|
404
|
+
setError(null)
|
|
405
|
+
} catch (err) {
|
|
406
|
+
const message = String(err)
|
|
407
|
+
const enoent =
|
|
408
|
+
(err as { code?: string } | null)?.code === "ENOENT" || message.includes("ENOENT")
|
|
409
|
+
if (enoent) {
|
|
410
|
+
pushFooterNotice(`${file.relativePath} no longer exists`)
|
|
411
|
+
setError(`Cannot read ${file.path}: ${message}`)
|
|
412
|
+
setLoaded(null)
|
|
413
|
+
} else {
|
|
414
|
+
pushFooterNotice(`reload failed: ${message}`)
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
if (!result.ok) {
|
|
419
|
+
if (result.reason === "spawn-failed") {
|
|
420
|
+
pushFooterNotice(`editor not found: ${editor.cmd}`)
|
|
421
|
+
} else if (result.reason === "non-zero") {
|
|
422
|
+
pushFooterNotice(`editor exited ${result.detail}`)
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
})()
|
|
426
|
+
},
|
|
354
427
|
}
|
|
355
428
|
|
|
356
429
|
useKeyboard((key) => {
|
|
357
|
-
// Filter modal: capture keystrokes for the input. Esc closes
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
430
|
+
// Filter modal: capture keystrokes for the input. Esc closes,
|
|
431
|
+
// leaving the typed query applied as the active filter; Return
|
|
432
|
+
// closes and focuses the reader (open the match); Ctrl+\ clears
|
|
433
|
+
// the input but stays in filter mode (same binding used from
|
|
434
|
+
// outside the modal — single chord, single mental model. Ctrl+U
|
|
435
|
+
// is deliberately not overloaded here; it stays reserved for its
|
|
436
|
+
// sidebar/reader half-page-up role); Backspace edits; Up/Down
|
|
437
|
+
// navigate the filtered list; printable characters extend the
|
|
438
|
+
// query and reset selection to 0. Everything else is swallowed
|
|
439
|
+
// so normal bindings (j/k as nav, `s`, `t`, …) don't fire while
|
|
440
|
+
// the user is typing. This sits outside the data-driven keymap
|
|
441
|
+
// for the same reason the help branch does — see DESIGN.md §12.
|
|
365
442
|
if (filterOpenRef.current) {
|
|
366
|
-
// One close path used by both Esc and Return.
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
// looking at when they hit the key, instead of landing on a
|
|
370
|
-
// random file at the same numeric position in a now-different
|
|
371
|
-
// list. `focusReader=true` is the Return semantic (open the
|
|
372
|
-
// match); false is Esc (cancel, stay in sidebar).
|
|
373
|
-
//
|
|
374
|
-
// Centralized so the dual filterOpenRef / filterOpen invariant
|
|
375
|
-
// only has to be maintained in one place (plus `openFilter`).
|
|
443
|
+
// One close path used by both Esc and Return. `commit=true` is
|
|
444
|
+
// the Return semantic (open the match in the reader); false is
|
|
445
|
+
// Esc (stop typing, keep the applied filter, stay in sidebar).
|
|
376
446
|
const closeFilter = (commit: boolean) => {
|
|
377
447
|
const picked = displayedFiles[selectedIndex] ?? null
|
|
378
|
-
// Return on a zero-match list has nothing to commit. Treat it
|
|
379
|
-
// as Esc so the user isn't stranded in an "applied filter with
|
|
380
|
-
// no visible files" state they'd have to back out of manually.
|
|
381
448
|
const effectiveCommit = commit && picked !== null
|
|
382
449
|
filterOpenRef.current = false
|
|
383
450
|
setFilterOpen(false)
|
|
384
|
-
if (effectiveCommit) {
|
|
385
|
-
// Return keeps the query. selectedIndex is already a valid
|
|
386
|
-
// position in the (still-filtered) displayedFiles list, so
|
|
387
|
-
// no translation is needed.
|
|
388
|
-
} else {
|
|
389
|
-
// Esc reverts the query to its pre-session value. After the
|
|
390
|
-
// revert, displayedFiles may change shape — translate the
|
|
391
|
-
// cursor by path so it stays on whatever the user was
|
|
392
|
-
// looking at, instead of snapping to a numerically-equivalent
|
|
393
|
-
// row in the restored list.
|
|
394
|
-
const before = priorFilterQueryRef.current
|
|
395
|
-
filterQueryRef.current = before
|
|
396
|
-
setFilterQuery(before)
|
|
397
|
-
if (picked) {
|
|
398
|
-
const restored = before === "" ? files : filterFiles(files, before)
|
|
399
|
-
const idx = restored.findIndex((f) => f.path === picked.path)
|
|
400
|
-
if (idx >= 0) setSelectedIndex(() => idx)
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
451
|
// Where focus lands after the filter closes:
|
|
404
|
-
// commit (Return on a real pick) → reader
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
// filter opened (shown=true), stay in it so j/k keeps
|
|
409
|
-
// walking. If the sidebar was hidden (shown=false), go
|
|
410
|
-
// back to the reader so the sidebar dismisses — in narrow
|
|
411
|
-
// that swaps screens; in wide that drops the focus-driven
|
|
412
|
-
// sidebar revival.
|
|
452
|
+
// commit (Return on a real pick) → reader. The user asked
|
|
453
|
+
// to open the match; show them what they picked.
|
|
454
|
+
// otherwise → sidebar if it's up so j/k keeps walking the
|
|
455
|
+
// filtered list; reader if the sidebar was hidden.
|
|
413
456
|
if (effectiveCommit) {
|
|
414
457
|
setFocus("reader")
|
|
415
458
|
} else {
|
|
@@ -424,11 +467,19 @@ export const Browser = ({
|
|
|
424
467
|
closeFilter(true)
|
|
425
468
|
return
|
|
426
469
|
}
|
|
470
|
+
if (key.ctrl && key.name === "\\") {
|
|
471
|
+
// Same action as the `filter.clearOrOpen` binding fires from
|
|
472
|
+
// outside the modal: clear the query, reset selection. The
|
|
473
|
+
// keymap doesn't see keys in filter mode, so this branch is
|
|
474
|
+
// the in-modal half of that single chord.
|
|
475
|
+
filterQueryRef.current = ""
|
|
476
|
+
setFilterQuery("")
|
|
477
|
+
setSelectedIndex(() => 0)
|
|
478
|
+
return
|
|
479
|
+
}
|
|
427
480
|
if (key.name === "backspace" || key.name === "delete") {
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
// reverts to the pre-session query (so an applied filter
|
|
431
|
-
// survives a "I changed my mind" tap).
|
|
481
|
+
// Backspace on empty input closes the modal — the leading `/`
|
|
482
|
+
// chevron is the last thing left to "delete."
|
|
432
483
|
if (filterQueryRef.current.length === 0) {
|
|
433
484
|
closeFilter(false)
|
|
434
485
|
return
|
|
@@ -602,38 +653,11 @@ export const Browser = ({
|
|
|
602
653
|
// budget is the viewport minus the 1-cell left padding only.
|
|
603
654
|
const sidebarPaneWidth = isNarrow ? width : sidebarWidth
|
|
604
655
|
const sidebarTextWidth = Math.max(4, sidebarPaneWidth - (isNarrow ? 1 : 2))
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
const truncatePath = useCallback(
|
|
608
|
-
(s: string): string =>
|
|
609
|
-
s.length <= sidebarTextWidth ? s : "…" + s.slice(s.length - sidebarTextWidth + 1),
|
|
656
|
+
const layoutSidebarRow = useCallback(
|
|
657
|
+
(relativePath: string) => formatSidebarRow(relativePath, sidebarTextWidth),
|
|
610
658
|
[sidebarTextWidth],
|
|
611
659
|
)
|
|
612
660
|
|
|
613
|
-
// Filter row content + color. Three reachable states:
|
|
614
|
-
// editing — filterOpen=true → /<query>▏ in textStrong
|
|
615
|
-
// applied — !filterOpen && query !== "" → /<query> in text
|
|
616
|
-
// idle — !filterOpen && query === "" → "/ filter…" in textMuted
|
|
617
|
-
const filterRowFg = filterOpen
|
|
618
|
-
? colors.textStrong
|
|
619
|
-
: filterQuery.length > 0
|
|
620
|
-
? colors.text
|
|
621
|
-
: colors.textMuted
|
|
622
|
-
const filterRowRaw = filterOpen
|
|
623
|
-
? `/${filterQuery}▏`
|
|
624
|
-
: filterQuery.length > 0
|
|
625
|
-
? `/${filterQuery}`
|
|
626
|
-
: "/ filter…"
|
|
627
|
-
// Editing keeps the cursor visible — anchor the right edge with a leading
|
|
628
|
-
// ellipsis when the query overflows. Applied/idle anchor the left edge
|
|
629
|
-
// (lose the tail) so the leading `/` always reads as a filter marker.
|
|
630
|
-
const filterRowContent =
|
|
631
|
-
filterRowRaw.length <= sidebarTextWidth
|
|
632
|
-
? filterRowRaw
|
|
633
|
-
: filterOpen
|
|
634
|
-
? "…" + filterRowRaw.slice(filterRowRaw.length - sidebarTextWidth + 1)
|
|
635
|
-
: filterRowRaw.slice(0, sidebarTextWidth - 1) + "…"
|
|
636
|
-
|
|
637
661
|
// While help is open, the `?` key closes the overlay — relabel its hint
|
|
638
662
|
// so the footer accurately describes what pressing the key will do.
|
|
639
663
|
// Memoized: `helpVisible` changes rarely; `browserBindings` and
|
|
@@ -653,7 +677,12 @@ export const Browser = ({
|
|
|
653
677
|
const sidebarBody = (
|
|
654
678
|
<>
|
|
655
679
|
{filterRowVisible && (
|
|
656
|
-
<
|
|
680
|
+
<PromptRow
|
|
681
|
+
query={filterQuery}
|
|
682
|
+
editing={filterOpen}
|
|
683
|
+
placeholder="/ to filter…"
|
|
684
|
+
width={sidebarTextWidth}
|
|
685
|
+
/>
|
|
657
686
|
)}
|
|
658
687
|
{displayedFiles.length === 0 ? (
|
|
659
688
|
<text
|
|
@@ -670,20 +699,18 @@ export const Browser = ({
|
|
|
670
699
|
visibleFiles.map((file, idx) => {
|
|
671
700
|
const realIdx = desiredScroll + idx
|
|
672
701
|
const isSelected = realIdx === selectedIndex
|
|
673
|
-
const
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
}
|
|
679
|
-
const bg = sidebarActive ? colors.selectedBg : colors.selectedBgInactive
|
|
702
|
+
const { basename, separator, parent } = layoutSidebarRow(file.relativePath)
|
|
703
|
+
const basenameFg = isSelected ? colors.textStrong : colors.text
|
|
704
|
+
const rowStyle = isSelected
|
|
705
|
+
? { bg: sidebarActive ? colors.selectedBg : colors.selectedBgInactive }
|
|
706
|
+
: {}
|
|
680
707
|
return (
|
|
681
|
-
<text
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
708
|
+
<text key={file.path} wrapMode="none" style={rowStyle}>
|
|
709
|
+
<span style={{ fg: basenameFg }}>{basename}</span>
|
|
710
|
+
{parent !== "" && (
|
|
711
|
+
<span style={{ fg: colors.textMuted }}>{`${separator}${parent}`}</span>
|
|
712
|
+
)}
|
|
713
|
+
</text>
|
|
687
714
|
)
|
|
688
715
|
})
|
|
689
716
|
)}
|
package/src/CommandPalette.tsx
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { RGBA } from "@opentui/core"
|
|
13
13
|
import { colors } from "./theme/colors.ts"
|
|
14
|
+
import { PromptRow } from "./PromptRow.tsx"
|
|
14
15
|
import type { AppCommand } from "./commands/types.ts"
|
|
15
16
|
|
|
16
17
|
// Semi-transparent black scrim painted across the viewport behind the modal.
|
|
@@ -104,11 +105,7 @@ export const CommandPalette = ({
|
|
|
104
105
|
backgroundColor: colors.surface,
|
|
105
106
|
}}
|
|
106
107
|
>
|
|
107
|
-
<
|
|
108
|
-
wrapMode="none"
|
|
109
|
-
content={fit(`> ${query}▏`, rowWidth)}
|
|
110
|
-
style={{ fg: colors.textStrong }}
|
|
111
|
-
/>
|
|
108
|
+
<PromptRow query={query} editing={true} width={rowWidth} />
|
|
112
109
|
<text content=" " />
|
|
113
110
|
{commands.length === 0 ? (
|
|
114
111
|
<text wrapMode="none" content=" (no matches)" style={{ fg: colors.textMuted }} />
|
package/src/Footer.tsx
CHANGED
|
@@ -138,7 +138,11 @@ export const Footer = <C,>({
|
|
|
138
138
|
hints.push({ key: null, label: `[filter: ${filterQuery}]` })
|
|
139
139
|
}
|
|
140
140
|
for (const b of bindings) {
|
|
141
|
-
|
|
141
|
+
// Hint visibility prefers `hintWhen` (binding-specific) over `when`
|
|
142
|
+
// (dispatch gate). Falling back to `when` keeps the original
|
|
143
|
+
// "hint shows when binding is enabled" behavior for the common case.
|
|
144
|
+
const visibleGate = b.hintWhen ?? b.when
|
|
145
|
+
if (visibleGate && !visibleGate(ctx)) continue
|
|
142
146
|
const h = formatHint(b)
|
|
143
147
|
if (h !== null) hints.push(h)
|
|
144
148
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PromptRow — single-line `> query` row shared by the sidebar filter and
|
|
3
|
+
* the command palette query input.
|
|
4
|
+
*
|
|
5
|
+
* Render-only: the parent owns query state and the focus/editing flag.
|
|
6
|
+
* The `> ` prefix always renders in `textStrong` regardless of state so it
|
|
7
|
+
* reads as chrome, not placeholder text — only the body span shifts color
|
|
8
|
+
* (textStrong while editing, text when applied, textMuted as placeholder).
|
|
9
|
+
*
|
|
10
|
+
* Overflow: when editing, an overflowing body anchors its right edge with a
|
|
11
|
+
* leading `…` so the cursor stays on screen; otherwise it anchors the left
|
|
12
|
+
* edge with a trailing `…`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { colors } from "./theme/colors.ts"
|
|
16
|
+
|
|
17
|
+
export interface PromptRowProps {
|
|
18
|
+
readonly query: string
|
|
19
|
+
/** True while the input is focused — shows a cursor and uses textStrong fg. */
|
|
20
|
+
readonly editing: boolean
|
|
21
|
+
/** Body fallback when !editing && query === "". Pass without the `> ` prefix. */
|
|
22
|
+
readonly placeholder?: string
|
|
23
|
+
/** Total cell width available for the row (prefix + body). */
|
|
24
|
+
readonly width: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const PREFIX = "> "
|
|
28
|
+
const CURSOR = "▏"
|
|
29
|
+
|
|
30
|
+
export const PromptRow = ({ query, editing, placeholder = "", width }: PromptRowProps) => {
|
|
31
|
+
const bodyBudget = Math.max(1, width - PREFIX.length)
|
|
32
|
+
|
|
33
|
+
const rawBody = editing ? `${query}${CURSOR}` : query.length > 0 ? query : placeholder
|
|
34
|
+
const bodyFg = editing ? colors.textStrong : query.length > 0 ? colors.text : colors.textMuted
|
|
35
|
+
|
|
36
|
+
const body =
|
|
37
|
+
rawBody.length <= bodyBudget
|
|
38
|
+
? rawBody
|
|
39
|
+
: editing
|
|
40
|
+
? "…" + rawBody.slice(rawBody.length - bodyBudget + 1)
|
|
41
|
+
: rawBody.slice(0, bodyBudget - 1) + "…"
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<text wrapMode="none">
|
|
45
|
+
<span style={{ fg: colors.textStrong }}>{PREFIX}</span>
|
|
46
|
+
<span style={{ fg: bodyFg }}>{body}</span>
|
|
47
|
+
</text>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
@@ -45,6 +45,7 @@ const annotations: Record<string, Annotation> = {
|
|
|
45
45
|
"help.toggle": { title: "Show help", category: "App" },
|
|
46
46
|
"filter.open": { title: "Filter files…", category: "Navigation" },
|
|
47
47
|
"serve.current": { title: "Open in browser", category: "File" },
|
|
48
|
+
"file.edit": { title: "Open in editor", category: "File", keywords: ["editor", "vim", "vscode"] },
|
|
48
49
|
"theme.next": { category: "Appearance" },
|
|
49
50
|
"theme.prev": { category: "Appearance" },
|
|
50
51
|
"theme.toneToggle": { title: "Toggle dark/light tone", category: "Appearance" },
|
package/src/io/editor.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve `$VISUAL` / `$EDITOR` into a spawnable `{ cmd, args }`.
|
|
3
|
+
*
|
|
4
|
+
* The string is POSIX shell-split so users with values like
|
|
5
|
+
* `code --wait` or `"/Applications/Sublime Text/subl" --wait` get the
|
|
6
|
+
* expected argv. We deliberately do *not* shell out via `sh -c`: the
|
|
7
|
+
* caller appends the file path as a separate argv element, which avoids
|
|
8
|
+
* command-injection risk for paths containing shell metacharacters.
|
|
9
|
+
*
|
|
10
|
+
* Resolution order: `$VISUAL` → `$EDITOR` → `null`. No silent fallback to
|
|
11
|
+
* `vi`; the caller surfaces a footer notice when neither is set.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface ResolvedEditor {
|
|
15
|
+
readonly cmd: string
|
|
16
|
+
readonly args: readonly string[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Split a command string the way `sh` would for the simple cases users
|
|
21
|
+
* actually put in `$EDITOR`: single quotes (literal), double quotes
|
|
22
|
+
* (with `\"`, `\\` escapes), backslash escapes outside quotes, and
|
|
23
|
+
* whitespace separation. No variable expansion, no globbing, no command
|
|
24
|
+
* substitution — by design. If the input is unbalanced (an unclosed
|
|
25
|
+
* quote), the partial token is emitted as-is so the caller's spawn
|
|
26
|
+
* surfaces a real error instead of us throwing.
|
|
27
|
+
*/
|
|
28
|
+
export const splitEditorString = (input: string): string[] => {
|
|
29
|
+
const tokens: string[] = []
|
|
30
|
+
let buf = ""
|
|
31
|
+
let inSingle = false
|
|
32
|
+
let inDouble = false
|
|
33
|
+
let pendingToken = false
|
|
34
|
+
|
|
35
|
+
for (let i = 0; i < input.length; i++) {
|
|
36
|
+
const ch = input[i]!
|
|
37
|
+
if (inSingle) {
|
|
38
|
+
if (ch === "'") inSingle = false
|
|
39
|
+
else buf += ch
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
if (inDouble) {
|
|
43
|
+
if (ch === "\\" && i + 1 < input.length) {
|
|
44
|
+
const next = input[i + 1]!
|
|
45
|
+
// In double quotes, `sh` only treats `\` as an escape before
|
|
46
|
+
// `$`, `` ` ``, `"`, `\`, or newline. Otherwise the backslash
|
|
47
|
+
// is literal. We collapse it for `"` and `\` (the cases users
|
|
48
|
+
// hit with Windows-style paths in double quotes) and keep it
|
|
49
|
+
// literal otherwise.
|
|
50
|
+
if (next === '"' || next === "\\") {
|
|
51
|
+
buf += next
|
|
52
|
+
i++
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (ch === '"') {
|
|
57
|
+
inDouble = false
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
buf += ch
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
if (ch === "'") {
|
|
64
|
+
inSingle = true
|
|
65
|
+
pendingToken = true
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
if (ch === '"') {
|
|
69
|
+
inDouble = true
|
|
70
|
+
pendingToken = true
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
if (ch === "\\" && i + 1 < input.length) {
|
|
74
|
+
buf += input[i + 1]!
|
|
75
|
+
i++
|
|
76
|
+
pendingToken = true
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
if (ch === " " || ch === "\t") {
|
|
80
|
+
if (pendingToken) {
|
|
81
|
+
tokens.push(buf)
|
|
82
|
+
buf = ""
|
|
83
|
+
pendingToken = false
|
|
84
|
+
}
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
buf += ch
|
|
88
|
+
pendingToken = true
|
|
89
|
+
}
|
|
90
|
+
if (pendingToken) tokens.push(buf)
|
|
91
|
+
return tokens
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Pick the user's editor from env vars. Returns `null` when neither
|
|
96
|
+
* `$VISUAL` nor `$EDITOR` is set to a non-empty, non-whitespace value.
|
|
97
|
+
*
|
|
98
|
+
* `env` is parameterised so tests don't need to mutate `process.env`.
|
|
99
|
+
*/
|
|
100
|
+
export const resolveEditor = (
|
|
101
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
102
|
+
): ResolvedEditor | null => {
|
|
103
|
+
const raw = pickEnv(env["VISUAL"]) ?? pickEnv(env["EDITOR"])
|
|
104
|
+
if (raw == null) return null
|
|
105
|
+
const parts = splitEditorString(raw)
|
|
106
|
+
if (parts.length === 0) return null
|
|
107
|
+
const [cmd, ...args] = parts
|
|
108
|
+
if (!cmd) return null
|
|
109
|
+
return { cmd, args }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const pickEnv = (value: string | undefined): string | null => {
|
|
113
|
+
if (value == null) return null
|
|
114
|
+
const trimmed = value.trim()
|
|
115
|
+
return trimmed.length === 0 ? null : trimmed
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Result of a launched editor session. Never throws — every failure is
|
|
119
|
+
* surfaced as a tagged value so the caller can decide UX. */
|
|
120
|
+
export type EditorRunResult =
|
|
121
|
+
| { readonly ok: true; readonly exitCode: number }
|
|
122
|
+
| {
|
|
123
|
+
readonly ok: false
|
|
124
|
+
readonly reason: "spawn-failed" | "non-zero"
|
|
125
|
+
readonly detail?: string
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface OpenInEditorOptions {
|
|
129
|
+
readonly editor: ResolvedEditor
|
|
130
|
+
readonly filePath: string
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Launch the resolved editor on `filePath`, inheriting stdio so the
|
|
135
|
+
* editor takes over the TTY. Caller is responsible for suspending /
|
|
136
|
+
* resuming the renderer around this call (see Browser.tsx).
|
|
137
|
+
*
|
|
138
|
+
* The file path is passed as a separate argv element — never interpolated
|
|
139
|
+
* into a shell string — so paths with shell metacharacters can't be
|
|
140
|
+
* misinterpreted.
|
|
141
|
+
*
|
|
142
|
+
* Windows is unsupported (see #129). The PATHEXT gap that breaks `.cmd`
|
|
143
|
+
* shims (`code.cmd`, `nvim.cmd`) is tracked specifically in #128.
|
|
144
|
+
*/
|
|
145
|
+
export const openInEditor = async ({
|
|
146
|
+
editor,
|
|
147
|
+
filePath,
|
|
148
|
+
}: OpenInEditorOptions): Promise<EditorRunResult> => {
|
|
149
|
+
const argv = [editor.cmd, ...editor.args, filePath]
|
|
150
|
+
try {
|
|
151
|
+
const proc = Bun.spawn(argv, {
|
|
152
|
+
stdin: "inherit",
|
|
153
|
+
stdout: "inherit",
|
|
154
|
+
stderr: "inherit",
|
|
155
|
+
})
|
|
156
|
+
const exitCode = await proc.exited
|
|
157
|
+
if (exitCode === 0) return { ok: true, exitCode }
|
|
158
|
+
return { ok: false, reason: "non-zero", detail: String(exitCode) }
|
|
159
|
+
} catch (err) {
|
|
160
|
+
return { ok: false, reason: "spawn-failed", detail: String(err) }
|
|
161
|
+
}
|
|
162
|
+
}
|
package/src/keymap/browser.ts
CHANGED
|
@@ -10,11 +10,19 @@ export type BrowserFocus = "sidebar" | "reader"
|
|
|
10
10
|
|
|
11
11
|
export interface BrowserCtx {
|
|
12
12
|
readonly files: readonly FileEntry[]
|
|
13
|
+
/** True iff `files[selectedIndex]` resolves to an entry. The honest
|
|
14
|
+
* predicate for File-group actions (`o`, `e`, `[`, `]`): with debounced
|
|
15
|
+
* filter and sticky auto-select, `files.length > 0` can be true while
|
|
16
|
+
* `selectedIndex` is invalid for the displayed list. See #115. */
|
|
17
|
+
readonly hasSelected: boolean
|
|
13
18
|
readonly focus: BrowserFocus
|
|
14
19
|
/** User's sticky sidebar preference. Visibility is `shown || focus==="sidebar"`. */
|
|
15
20
|
readonly sidebarShown: boolean
|
|
16
21
|
readonly helpVisible: boolean
|
|
17
22
|
readonly filterOpen: boolean
|
|
23
|
+
/** Current applied/edited filter query. Used by `filter.clearOrOpen`'s
|
|
24
|
+
* hint gate so the hint only appears when there is something to clear. */
|
|
25
|
+
readonly filterQuery: string
|
|
18
26
|
readonly paletteOpen: boolean
|
|
19
27
|
readonly setFocus: (next: BrowserFocus | ((prev: BrowserFocus) => BrowserFocus)) => void
|
|
20
28
|
readonly setSelectedIndex: (updater: (prev: number) => number) => void
|
|
@@ -22,12 +30,19 @@ export interface BrowserCtx {
|
|
|
22
30
|
readonly toggleShown: () => void
|
|
23
31
|
readonly setHelpVisible: (updater: (prev: boolean) => boolean) => void
|
|
24
32
|
readonly openFilter: () => void
|
|
33
|
+
/** Clear the current filter query and open the filter modal in a single
|
|
34
|
+
* action. Bound to `\` so users can reset a stranded zero-match filter
|
|
35
|
+
* without first reopening with `/` and backspacing. */
|
|
36
|
+
readonly clearAndOpenFilter: () => void
|
|
25
37
|
readonly openPalette: () => void
|
|
26
38
|
readonly cycleTheme: (delta: 1 | -1) => void
|
|
27
39
|
readonly toggleTone: () => void
|
|
28
40
|
readonly quit: () => void
|
|
29
41
|
/** Start (or retarget) the HTML preview server on the focused file. */
|
|
30
42
|
readonly serveCurrent: () => void
|
|
43
|
+
/** Suspend the TUI, hand the TTY to `$EDITOR`, resume and re-read on
|
|
44
|
+
* exit. No-op when nothing is selected; gating is the binding's job. */
|
|
45
|
+
readonly editCurrent: () => void
|
|
31
46
|
}
|
|
32
47
|
|
|
33
48
|
/** Step size for shift+j/k and the space/b/page keys. Constant for v1; could
|
|
@@ -37,6 +52,7 @@ const JUMP = 8
|
|
|
37
52
|
const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n))
|
|
38
53
|
const lastIndex = (c: BrowserCtx) => Math.max(0, c.files.length - 1)
|
|
39
54
|
const haveFiles = (c: BrowserCtx) => c.files.length > 0
|
|
55
|
+
const hasSelected = (c: BrowserCtx) => c.hasSelected
|
|
40
56
|
const stepBy = (c: BrowserCtx, delta: number) =>
|
|
41
57
|
c.setSelectedIndex((i) => clamp(i + delta, 0, lastIndex(c)))
|
|
42
58
|
|
|
@@ -45,7 +61,10 @@ const filterClosed = (c: BrowserCtx) => !c.filterOpen
|
|
|
45
61
|
const paletteClosed = (c: BrowserCtx) => !c.paletteOpen
|
|
46
62
|
const inReader = (c: BrowserCtx) => c.focus === "reader"
|
|
47
63
|
const inSidebarWithFiles = (c: BrowserCtx) => inSidebar(c) && haveFiles(c)
|
|
48
|
-
|
|
64
|
+
/** Reader-only sibling-step gate: needs a current selection plus a sibling
|
|
65
|
+
* to step to. `hasSelected` implies `files.length >= 1`, so `>= 2` is the
|
|
66
|
+
* meaningful extra condition. */
|
|
67
|
+
const inReaderWithSibling = (c: BrowserCtx) => inReader(c) && hasSelected(c) && c.files.length >= 2
|
|
49
68
|
|
|
50
69
|
export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
|
|
51
70
|
// Global
|
|
@@ -93,6 +112,28 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
|
|
|
93
112
|
when: filterClosed,
|
|
94
113
|
run: (c) => c.openFilter(),
|
|
95
114
|
},
|
|
115
|
+
{
|
|
116
|
+
id: "filter.clearOrOpen",
|
|
117
|
+
group: "Sidebar",
|
|
118
|
+
description: "Clear filter",
|
|
119
|
+
hint: "clear",
|
|
120
|
+
keys: ["ctrl+\\"],
|
|
121
|
+
// Fires from anywhere outside the filter modal via the keymap.
|
|
122
|
+
// Inside the filter modal it's intercepted directly in Browser.tsx
|
|
123
|
+
// (the filter mode owns key handling), but the action is the same —
|
|
124
|
+
// clear input, keep modal open. Palette/help branches short-circuit
|
|
125
|
+
// dispatch in Browser.tsx, so we don't need to gate on them for
|
|
126
|
+
// behavior; the `hintWhen` gate keeps the footer chip from showing
|
|
127
|
+
// when there's nothing to clear or when a modal owns the input.
|
|
128
|
+
// Chord chosen over single `\` so the binding works inside the
|
|
129
|
+
// filter input without colliding with the typed character; ctrl+u
|
|
130
|
+
// is deliberately left to its reader/sidebar half-page-up role to
|
|
131
|
+
// avoid overload.
|
|
132
|
+
when: filterClosed,
|
|
133
|
+
hintWhen: (c) =>
|
|
134
|
+
filterClosed(c) && !c.paletteOpen && !c.helpVisible && c.filterQuery.length > 0,
|
|
135
|
+
run: (c) => c.clearAndOpenFilter(),
|
|
136
|
+
},
|
|
96
137
|
{
|
|
97
138
|
id: "palette.open",
|
|
98
139
|
group: "Global",
|
|
@@ -106,15 +147,6 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
|
|
|
106
147
|
when: paletteClosed,
|
|
107
148
|
run: (c) => c.openPalette(),
|
|
108
149
|
},
|
|
109
|
-
{
|
|
110
|
-
id: "serve.current",
|
|
111
|
-
group: "Global",
|
|
112
|
-
description: "Open current file in browser as HTML",
|
|
113
|
-
hint: "html",
|
|
114
|
-
keys: ["o"],
|
|
115
|
-
when: haveFiles,
|
|
116
|
-
run: (c) => c.serveCurrent(),
|
|
117
|
-
},
|
|
118
150
|
{
|
|
119
151
|
id: "theme.next",
|
|
120
152
|
group: "Global",
|
|
@@ -213,32 +245,57 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
|
|
|
213
245
|
run: (c) => c.setFocus("reader"),
|
|
214
246
|
},
|
|
215
247
|
|
|
216
|
-
//
|
|
248
|
+
// File — actions on the currently-selected file. Gated on `hasSelected`
|
|
249
|
+
// (per #115) so they're available exactly when the reader has something
|
|
250
|
+
// to act on, regardless of focus or filter state.
|
|
217
251
|
{
|
|
218
|
-
id: "
|
|
219
|
-
group: "
|
|
220
|
-
description: "
|
|
221
|
-
hint: "
|
|
222
|
-
keys: ["
|
|
223
|
-
when:
|
|
224
|
-
run: (c) => c.
|
|
252
|
+
id: "serve.current",
|
|
253
|
+
group: "File",
|
|
254
|
+
description: "Open current file in browser as HTML",
|
|
255
|
+
hint: "html",
|
|
256
|
+
keys: ["o"],
|
|
257
|
+
when: hasSelected,
|
|
258
|
+
run: (c) => c.serveCurrent(),
|
|
225
259
|
},
|
|
226
260
|
{
|
|
261
|
+
id: "file.edit",
|
|
262
|
+
group: "File",
|
|
263
|
+
description: "Open current file in $EDITOR",
|
|
264
|
+
hint: "edit",
|
|
265
|
+
keys: ["e"],
|
|
266
|
+
when: hasSelected,
|
|
267
|
+
run: (c) => c.editCurrent(),
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
// `[`/`]` keep the `inReader` clause so they're only typed from the
|
|
271
|
+
// reader (sidebar uses j/k for stepping). The File-group predicate is
|
|
272
|
+
// additive: needs a selection *and* a sibling to step to.
|
|
227
273
|
id: "reader.prevFile",
|
|
228
|
-
group: "
|
|
274
|
+
group: "File",
|
|
229
275
|
description: "Prev file",
|
|
230
276
|
hint: "prev",
|
|
231
277
|
keys: ["["],
|
|
232
|
-
when:
|
|
278
|
+
when: inReaderWithSibling,
|
|
233
279
|
run: (c) => stepBy(c, -1),
|
|
234
280
|
},
|
|
235
281
|
{
|
|
236
282
|
id: "reader.nextFile",
|
|
237
|
-
group: "
|
|
283
|
+
group: "File",
|
|
238
284
|
description: "Next file",
|
|
239
285
|
hint: "next",
|
|
240
286
|
keys: ["]"],
|
|
241
|
-
when:
|
|
287
|
+
when: inReaderWithSibling,
|
|
242
288
|
run: (c) => stepBy(c, 1),
|
|
243
289
|
},
|
|
290
|
+
|
|
291
|
+
// Reader
|
|
292
|
+
{
|
|
293
|
+
id: "reader.back",
|
|
294
|
+
group: "Reader",
|
|
295
|
+
description: "Back to sidebar",
|
|
296
|
+
hint: "back",
|
|
297
|
+
keys: ["escape", "left", "h"],
|
|
298
|
+
when: inReader,
|
|
299
|
+
run: (c) => c.setFocus("sidebar"),
|
|
300
|
+
},
|
|
244
301
|
]
|
package/src/keymap/keymap.ts
CHANGED
|
@@ -34,6 +34,12 @@ export interface KeyBinding<C> {
|
|
|
34
34
|
readonly hint?: string
|
|
35
35
|
/** If present, the binding only fires when this returns true. */
|
|
36
36
|
readonly when?: (ctx: C) => boolean
|
|
37
|
+
/** If present, the footer hint is shown only when this returns true. When
|
|
38
|
+
* absent, hint visibility falls back to `when`. Use when a binding's
|
|
39
|
+
* dispatch gate is broader than the situations where the hint is useful
|
|
40
|
+
* (e.g. "clear filter" fires from anywhere but only deserves a hint when
|
|
41
|
+
* there is actually a filter to clear). */
|
|
42
|
+
readonly hintWhen?: (ctx: C) => boolean
|
|
37
43
|
readonly run: (ctx: C) => void
|
|
38
44
|
}
|
|
39
45
|
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidebar row layout — basename-first with a dim parent suffix sized to the
|
|
3
|
+
* available width.
|
|
4
|
+
*
|
|
5
|
+
* Pure per-row formatting: a row's rendered shape depends only on its own
|
|
6
|
+
* path and the column budget — never on neighboring rows. That keeps the
|
|
7
|
+
* sidebar stable as filters change and the file set grows, and keeps the
|
|
8
|
+
* function trivially predictable. Disambiguation against same-basename
|
|
9
|
+
* siblings is the header's job (it shows the full relative path of the
|
|
10
|
+
* selected row); a future auto-scroll on the selected sidebar row can carry
|
|
11
|
+
* the same information without altering layout for the rest.
|
|
12
|
+
*
|
|
13
|
+
* Truncation policy (head-elide, segment-aware):
|
|
14
|
+
* - Full parent fits → render whole.
|
|
15
|
+
* - Else drop leading segments one at a time, prefixed with `…/`, until
|
|
16
|
+
* the remainder fits — never chops a segment mid-character.
|
|
17
|
+
* - When even the tail segment with `…/` overflows, drop the marker.
|
|
18
|
+
* - When the tail segment alone overflows, hard-truncate it from its head
|
|
19
|
+
* (leading `…`) as a last resort.
|
|
20
|
+
*
|
|
21
|
+
* Why head-elide: the immediate parent is the segment closest to the file
|
|
22
|
+
* and the most universally meaningful one when context shrinks.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export const SIDEBAR_ROW_SEPARATOR = " · "
|
|
26
|
+
const ELISION_PREFIX = "…/"
|
|
27
|
+
const MIN_PARENT_BUDGET = 3
|
|
28
|
+
|
|
29
|
+
export interface SidebarRowParts {
|
|
30
|
+
readonly basename: string
|
|
31
|
+
readonly separator: string
|
|
32
|
+
readonly parent: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const formatSidebarRow = (relativePath: string, totalWidth: number): SidebarRowParts => {
|
|
36
|
+
const slash = relativePath.lastIndexOf("/")
|
|
37
|
+
if (slash < 0) {
|
|
38
|
+
return { basename: fitTail(relativePath, totalWidth), separator: "", parent: "" }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const basename = relativePath.slice(slash + 1)
|
|
42
|
+
const parentFull = relativePath.slice(0, slash)
|
|
43
|
+
|
|
44
|
+
if (basename.length >= totalWidth) {
|
|
45
|
+
return { basename: fitTail(basename, totalWidth), separator: "", parent: "" }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const remaining = totalWidth - basename.length - SIDEBAR_ROW_SEPARATOR.length
|
|
49
|
+
if (remaining < MIN_PARENT_BUDGET || parentFull.length === 0) {
|
|
50
|
+
return { basename, separator: "", parent: "" }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (parentFull.length <= remaining) {
|
|
54
|
+
return row(basename, parentFull)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const segments = parentFull.split("/")
|
|
58
|
+
for (let k = segments.length - 1; k >= 1; k--) {
|
|
59
|
+
const candidate = ELISION_PREFIX + segments.slice(segments.length - k).join("/")
|
|
60
|
+
if (candidate.length <= remaining) return row(basename, candidate)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Even one segment with the `…/` marker doesn't fit. Try without the marker.
|
|
64
|
+
const tail = segments[segments.length - 1]!
|
|
65
|
+
if (tail.length <= remaining) return row(basename, tail)
|
|
66
|
+
|
|
67
|
+
// Hard-chop the tail segment from its head as a last resort.
|
|
68
|
+
return row(basename, "…" + tail.slice(tail.length - remaining + 1))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const row = (basename: string, parent: string): SidebarRowParts => ({
|
|
72
|
+
basename,
|
|
73
|
+
separator: SIDEBAR_ROW_SEPARATOR,
|
|
74
|
+
parent,
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const fitTail = (s: string, width: number): string => {
|
|
78
|
+
if (s.length <= width) return s
|
|
79
|
+
// At width ≤ 1 there's no room for both a character and the ellipsis; emit
|
|
80
|
+
// a single char so the result respects the budget. Callers currently clamp
|
|
81
|
+
// width to ≥ 4, but the helper carries its own floor so a tighter future
|
|
82
|
+
// caller can't silently overflow the column.
|
|
83
|
+
if (width <= 1) return s.slice(0, 1)
|
|
84
|
+
return s.slice(0, width - 1) + "…"
|
|
85
|
+
}
|
package/src/serve/server.ts
CHANGED
|
@@ -86,9 +86,12 @@ export const startServer = ({ path, port = 0 }: StartOptions): ServerHandle => {
|
|
|
86
86
|
// user's local files, so leaking them to the network would be a
|
|
87
87
|
// surprise. URL strings are localhost-only by construction below.
|
|
88
88
|
hostname: "127.0.0.1",
|
|
89
|
-
async fetch(req) {
|
|
89
|
+
async fetch(req, server) {
|
|
90
90
|
const url = new URL(req.url)
|
|
91
91
|
if (url.pathname === "/__reload") {
|
|
92
|
+
// SSE stream is silent between file changes; without this Bun
|
|
93
|
+
// closes the request at the default 10s idleTimeout and warns.
|
|
94
|
+
server.timeout(req, 0)
|
|
92
95
|
// `cancel` receives a reason, not the controller — capture
|
|
93
96
|
// the controller in `start` so we can remove it from the set
|
|
94
97
|
// on disconnect. Without this, dead clients accumulate.
|