feat: default explorer to compact directory tree

This commit is contained in:
Valdemar Lorenzen
2026-10-08 15:51:25 +02:00
parent 9ba8b38898
commit 6d7be0c169
9 changed files with 2036 additions and 145 deletions
+42 -19
View File
@@ -20,7 +20,7 @@ Without a file, vamp opens an unnamed buffer. A nonexistent file opens an empty
| All modes | Ctrl+p opens the command palette; type to filter, Ctrl+j/k or arrows select, Enter accepts, Esc/Ctrl+p cancels |
| Normal, Visual | Space Space opens the palette through the default leader |
| Normal | Space w saves; Space b n/p/l/d switches next/previous, lists, or closes buffers |
| Normal, Visual | Space o opens floating file search; Space e opens/focuses the docked explorer |
| Normal, Visual | Space o opens the floating file list; Space e opens/focuses the docked compact tree |
| Normal | `i` insert before each caret; `a` insert after each caret; `o`/`O` open lines below/above |
| Normal, Visual | `I` (Shift+i) / `A` (Shift+a) insert at first nonblank / line end; Visual splits each touched row into a caret first |
| Normal | `h`/`j`/`k`/`l` or arrows move every caret; `0`/`$` or Home/End |
@@ -92,24 +92,33 @@ Switching from Insert commits its pending undo group and parks that buffer in No
### File picker and explorer
Space o opens a floating picker; Space e opens or focuses the docked explorer. Both use the same searchable project-relative file list. There is one instance: invoking the other action changes its presentation while retaining the query and selection. These actions are available as `config.core.file_picker` and `config.core.file_explorer` in Normal/Visual and in the command palette.
Space o opens a floating file picker, defaulting to **List**; Space e opens or focuses the docked explorer, defaulting to **Tree**. Both use one catalog and instance. Invoking the other action applies that presentation's configured default while retaining query, selection identities, and surviving folder expansion. These actions are available as `config.core.file_picker` and `config.core.file_explorer` in Normal/Visual and in the command palette.
The dock defaults to the right of the editor workspace. Set `config.file_explorer_side = "left"` (or `"right"`, `"top"`, `"bottom"`) to choose another side. Status and command/search prompts remain full-width; editor text, gutter, highlights, and cursors use the remaining rectangle. Focused panes prioritize their query on tiny terminals; unfocused docks shrink or hide to preserve editing space.
| File-search input | Behavior |
Choose either view independently with `config.file_picker_view = "list"` and `config.file_explorer_view = "tree"` (both accept `"list"` or `"tree"`). Ctrl+t toggles the current focused instance without rescanning. List retains searchable full relative paths. Tree shows folders first, two-cell indentation, disclosure markers, and basename-only files. Maximal single-child directory chains share one row, such as `src/client/http`; a directory containing one file still gets its own row.
New trees start in browsing focus. `/` enters search; typing and Backspace then filter full relative paths by case-insensitive whitespace tokens. Matching nodes appear with their ancestor context, temporarily revealing descendants without clearing saved expansion. Query changes select a direct match, not merely a contextual ancestor. Escape clears the temporary filter and restores pre-search browsing selection; the next Escape returns to editing or closes floating search. List always accepts query typing immediately.
| File-view input | Behavior |
| --- | --- |
| Type / Backspace | Case-insensitive whitespace-token filtering / remove one whole grapheme |
| Ctrl+j/Down/Ctrl+n or Ctrl+k/Up | Wrap forward or backward through matching files |
| Enter | Open the selected file, reusing an existing buffer without reloading hidden edits |
| Esc | Close floating search; return to editor focus while retaining a dock |
| Type / Backspace | Filter in List/tree search / remove one whole grapheme; ignored in tree browsing |
| Ctrl+j/Down/Ctrl+n or Ctrl+k/Up | Wrap forward/backward through visible rows |
| Ctrl+l/Right (tree browse) | Expand a closed folder; select its first child when already open |
| Ctrl+h/Left (tree browse) | Collapse an open folder; otherwise select its visible parent |
| Enter | Toggle a folder, or open the selected file using existing buffer deduplication |
| `/` (tree browse) | Focus search without inserting the slash |
| Esc | Return from tree search to browsing; otherwise close floating search or blur a dock |
| Tab | Return to editor focus from a dock |
| Ctrl+w | Close either presentation |
| Ctrl+r | Rescan the project while retaining the query |
| Ctrl+r | Rescan while retaining query, surviving expansion, and selection |
| Ctrl+p | Open the command palette, closing floating search or blurring a dock |
| Ctrl+t | Toggle List/Tree while retaining view state |
Opening a file from a dock leaves it visible and returns focus to editing. Space e refocuses it. Query input never edits the underlying buffer; bracketed paste is ignored while file search is focused. Cancellation preserves buffer mode, selections, and pending Insert history. Invalid, binary, unreadable, or disappeared selections report an error and keep focus without changing the active buffer.
Opening a file from a dock leaves it visible and returns focus to editing. Space e refocuses it. Choosing a folder during search returns to browsing and toggles that folder; it never opens a text buffer for a directory. Selections use actual OS-path identities across filtering, view switches, and refresh; a removed or hidden selection falls back to the nearest visible ancestor, then the first row. Query input never edits the underlying buffer; bracketed paste is ignored while file search is focused. Cancellation preserves buffer mode, selections, and pending Insert history. Invalid, binary, unreadable, or disappeared file selections report an error and keep focus without changing the active buffer.
The project root is the working directory captured at startup. Scanning uses the `ignore` crate for nested `.gitignore` rules and negation; parent/global Git ignore rules are disabled. Hidden regular files are included unless ignored, `.git`/`.jj` metadata is always excluded, and symlink files/directories are not followed. Files open using their original OS paths, not sanitized display labels. Root scan failures report an error; descendant/ignore errors retain reachable files with an explicit incomplete-scan warning. A failed refresh keeps the prior list. There is no background watcher: refresh explicitly after external file changes. The explorer is a searchable path list, not an expandable directory tree.
The project root is the working directory captured at startup. Scanning uses the `ignore` crate for nested `.gitignore` rules and negation; parent/global Git ignore rules are disabled. Hidden regular files and directories are included unless ignored, `.git`/`.jj` metadata is always excluded, and symlink files/directories are not followed. Files open using their original OS paths, not sanitized display labels. Root scan failures report an error; descendant/ignore errors retain reachable entries with an explicit incomplete-scan warning. A failed refresh keeps the prior catalog. There is no background watcher: refresh explicitly after external file changes.
### Command palette
@@ -131,7 +140,16 @@ config.bind "ctrl+b", config.core.up, mode: config.modes.selection
config.bind "ctrl+j", nil, mode: config.modes.selection
```
Selection bindings take precedence only while a list is focused. Unbound printable keys still filter; a disabled key is consumed without filtering or moving. Only `up`, `down`, and `nil` are accepted—no leader sequences, edits, or plugin commands. Invalid declarations leave existing routes unchanged. Navigation does not change text, carets, history, or the underlying mode; Enter remains accept by default. Both list footers show the effective navigation keys, including remaps and disabled directions.
Selection bindings take precedence while a file or command list is focused. Unbound printable keys filter in List/search, but not tree browsing; a disabled key is consumed without filtering or moving. Only `up`, `down`, and `nil` are accepted—no leader sequences, edits, or plugin commands. Invalid declarations leave existing routes unchanged. Navigation does not change text, carets, history, or the underlying mode. Both list footers show effective keys, including remaps and disabled directions.
Tree browsing additionally uses `config.modes.tree`, accepting `up`, `down`, `left`, `right`, or `nil`. Its single-key routes override shared selection routes only during tree browsing; unmatched keys inherit the shared selection bindings. Defaults are Ctrl+h/Left for collapse/parent and Ctrl+l/Right for expand/child. For example:
```ruby
config.bind "ctrl+f", config.core.right, mode: config.modes.tree
config.bind "ctrl+h", nil, mode: config.modes.tree
```
Ctrl+h is **not** a Backspace alias or default deletion key in any mode. Raw Ctrl+h (0x08) and Backspace (DEL/0x7f) are distinct, as are CSI-u events. It navigates only through the tree browsing binding context; it does not delete a query or Insert text. Explicit `config.core.backspace` bindings remain available if desired.
Ctrl+j is distinct from Enter: in the TUI's raw mode, LF is Ctrl+j and CR is Enter; CSI-u control events also work. No enhanced-terminal negotiation is required. Ctrl+m remains an Enter alias. Outside lists, existing editing bindings are unchanged: Ctrl+k still clones above in Normal/Visual, and explicit Ctrl+j defaults insert a newline in Insert or submit Command/Search prompts. Enter and Ctrl+j can now be rebound independently.
@@ -150,7 +168,7 @@ Leader bindings require Normal/Visual and the target's existing mode eligibility
One crate, five modules, no workspace or terminal-coordinate types in the editing backend:
- `editor`: owns buffer lifecycle and per-buffer text, saved state, primary/selection set, modes, search, and undo/redo; session-wide clipboard fragments, action metadata, command-palette and file-picker focus/dispatch, key notation, leader/count prefixes and binding resolution, modal paste policy, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected.
- `files`: owns the gitignore-aware catalog, authoritative OS paths, query filtering, navigation, and scan/error state shared by floating and docked presentations.
- `files`: owns the gitignore-aware directory/file catalog, authoritative OS paths, compact tree projection, query filtering, browse/search focus, expansion/selection restoration, navigation, and scan/error state shared by floating and docked presentations.
- `session`: `EditorSession` owns the editor and its Ruby hosts together. It loads configuration once, registers eligible plugins before installing shared bindings, executes selected commands against the active buffer, and retires both host and command ownership on invocation or effect-validation failure.
- `tui`: translates terminal events to session input, renders read-only active-buffer state, keeps frontend-owned viewports by stable buffer ID, and advertises `text` and `styled_text` capabilities. It has no plugin lifecycle callback, host storage, or paste acceptance policy.
- `ruby`: evaluates configuration and runs isolated plugin hosts. Protocol, deadlines, and process cleanup stay inside this module.
@@ -159,11 +177,11 @@ One crate, five modules, no workspace or terminal-coordinate types in the editin
Frontends drive `EditorSession::handle` or `execute` and inspect `editor()`; there is no mutable editor getter that could independently alter plugin registration. Pasted text uses `Input::Paste(String)` through the same input seam as keys. Plugin effects are applied internally so a rejected result also retires its owner; there is no generic frontend effects entry point.
Frontends inspect `selections()` for document-ordered, end-exclusive Visual ranges of grapheme coordinates (empty outside Visual), `carets()` for all active cursors in document order, `cursor()` for the primary, and `selection_count()` for the total target count. A range ending at the next row's column 0 includes the previous row's newline; an empty final-row range contains no text. Selection normalization, clipboard contents, edit planning, and history remain inside the editor module.
Frontends inspect `palette()` for the optional read-only query, filtered command entries, selected index, effective bindings, and eligibility. Palette input handling and acceptance stay in the editor; registered command execution stays in the session.
Frontends use the cached `selection_navigation_hint()` to advertise the current shared list bindings without reconstructing input policy.
Frontends use cached `selection_navigation_hint()` for command lists and `file_navigation_hint()` for the active file view/context, without reconstructing input policy.
Frontends inspect `count()` for the optional pending motion prefix; parsing and counted motion dispatch remain editor-owned.
Frontends inspect `leader_pending()` for the cached display of a pending leader sequence; prefix recognition, cancellation, and dispatch remain editor-owned.
Frontends inspect `buffer_id()`, `buffer_count()`, `has_buffer(id)`, and the ascending-ID `buffers()` iterator of borrowed path/dirty/active metadata. Existing text, cursor, selection, search, and mode methods describe only the active buffer.
Frontends inspect `file_picker()` for optional query/results, presentation, focus, and errors, and `file_explorer_side()` for logical docking preference. They allocate rectangles and render; file filtering, focus transitions, and selected-file opening remain backend-owned.
Frontends inspect `file_picker()` for query, presentation, view, tree-search subfocus, selection, and errors, and iterate borrowed `FileRow` metadata (actual path, display label, directory flag, visible depth, expansion). Dock side is a logical preference. They allocate rectangles and render; hierarchy, filtering, parent/child navigation, focus transitions, and selected-file opening remain backend-owned.
The terminal adapter owns raw mode, alternate-screen state, bracketed paste, and its panic hook for one session lifetime. Entry failure, normal return, I/O errors, and unwinding panics restore the caller's hook; active-terminal cleanup runs before caller panic diagnostics. Panics retain their original payload and propagate rather than being swallowed. Terminal state and panic hooks are process-global: run one TUI at a time and do not replace its hook while that session is active.
@@ -183,6 +201,10 @@ By default, vamp loads `$XDG_CONFIG_HOME/vamp/config.rb`, or `~/.config/vamp/con
Vamp.configure do |config|
config.leader = "space"
config.file_explorer_side = "right"
config.file_explorer_view = "tree"
config.file_picker_view = "list"
config.bind "ctrl+h", config.core.left, mode: config.modes.tree
config.bind "ctrl+l", config.core.right, mode: config.modes.tree
config.plugin "plugins/greeting.rb"
config.bind "g", "greet"
config.bind "leader g", "greet"
@@ -198,8 +220,8 @@ Vamp.configure do |config|
end
```
Plugin paths resolve relative to the config file, not the shell's working directory. `config.bind key, target, mode: config.modes.normal` overrides one key or leader-prefixed sequence; editing modes are `config.modes.normal`, `config.modes.insert`, `config.modes.command`, `config.modes.search`, and `config.modes.visual`. `config.modes.selection` configures focused list navigation. Omitting `mode` selects Normal. The leader is session-wide, defaults to `"space"`, and is applied before configured bindings.
`config.file_explorer_side` is session-wide and defaults to `"right"`; the other choices are `"left"`, `"top"`, and `"bottom"`. An invalid side reports a notice and retains the default, while valid bindings still install. Non-string/empty values disable the whole configuration like other Ruby type errors.
Plugin paths resolve relative to the config file, not the shell's working directory. `config.bind key, target, mode: config.modes.normal` overrides one key or leader-prefixed sequence; editing modes are `config.modes.normal`, `config.modes.insert`, `config.modes.command`, `config.modes.search`, and `config.modes.visual`. `config.modes.selection` configures shared list navigation; `config.modes.tree` overrides it in tree browsing. Omitting `mode` selects Normal. The leader defaults to `"space"` and is applied before configured bindings.
`config.file_explorer_side` defaults to `"right"`; other choices are `"left"`, `"top"`, and `"bottom"`. Independent `file_explorer_view` / `file_picker_view` defaults are `"tree"` / `"list"` and both accept either value. Invalid preference strings report notices and retain defaults while valid bindings still install. Non-string/empty values disable the whole configuration like other Ruby type errors.
`config.core` exposes the built-in actions below as immutable `Action` values; `config.modes` exposes immutable `Mode` values. Misspelled methods fail during configuration evaluation, and `bind` rejects the wrong value type. Built-in action strings and mode strings are not accepted. Registered Ex/plugin commands still use exact string names (`"w"`, `"greet"`, etc., without `:` or arguments) and can only be bound in Normal mode. Ruby provides runtime checks, not compile-time checking; the editor still validates action/mode compatibility.
@@ -209,7 +231,7 @@ Key notation follows [Herdr's readable style](https://herdr.dev/docs/configurati
- Modifiers: `ctrl+a` through `ctrl+z`, and `shift+a` through `shift+z`. `shift+j` and `J` address the same key.
- Named punctuation: `minus`, `comma`, `ampersand`, `plus`, `backtick`. Each addresses the same binding as its literal character.
Terminals share `ctrl+h` with `backspace`, `ctrl+i` with `tab`, and `ctrl+m` with `enter`; these names address the same binding, including inside leader sequences. Ctrl+j is a distinct control key, not an Enter alias. Only a single modifier on an ASCII letter is supported. Arbitrary non-leader key sequences, Alt/Cmd/Super, combined modifiers, modified special keys, and Vim-style angle-bracket notation are rejected rather than silently treated as another key.
`ctrl+i` aliases `tab`, and `ctrl+m` aliases `enter`, including inside leader sequences. Ctrl+h and Ctrl+j are distinct control keys, not Backspace/Enter aliases. Only a single modifier on an ASCII letter is supported. Arbitrary non-leader key sequences, Alt/Cmd/Super, combined modifiers, modified special keys, and Vim-style angle-bracket notation are rejected rather than silently treated as another key.
| Available modes | Methods on `config.core` |
| --- | --- |
@@ -219,8 +241,9 @@ Terminals share `ctrl+h` with `backspace`, `ctrl+i` with `tab`, and `ctrl+m` wit
| Normal | `search`, `paste_before`, `paste_after` |
| Normal, Visual | `select`, `search_next`, `search_previous` |
| Normal, Visual | `file_picker`, `file_explorer` |
| Normal, Insert, Visual | `left`, `right`, `line_start`, `line_end` |
| Normal, Insert, Visual, Selection | `down`, `up` |
| Normal, Insert, Visual | `line_start`, `line_end` |
| Normal, Insert, Visual, Tree | `left`, `right` |
| Normal, Insert, Visual, Selection, Tree | `down`, `up` |
| Normal, Insert, Visual | `word_next`, `word_previous`, `word_end`, `big_word_next`, `big_word_previous`, `big_word_end` |
| Normal, Insert | `delete` |
| Visual | `copy`, `cut`, `change`, `select_lines`, `select_next`, `skip_occurrence`, `select_all` |