From 6d7be0c1691e304334d43e37a12ed222fb49711b Mon Sep 17 00:00:00 2001 From: Valdemar Lorenzen Date: Thu, 8 Oct 2026 15:51:25 +0200 Subject: [PATCH] feat: default explorer to compact directory tree --- README.md | 61 ++-- examples/config.rb | 4 + src/editor.rs | 581 ++++++++++++++++++++++++++++--- src/files.rs | 834 ++++++++++++++++++++++++++++++++++++++++++--- src/main.rs | 5 +- src/ruby.rs | 16 +- src/ruby_host.rb | 20 +- src/session.rs | 123 ++++++- src/tui.rs | 537 +++++++++++++++++++++++++++-- 9 files changed, 2036 insertions(+), 145 deletions(-) diff --git a/README.md b/README.md index d66bf80..2ea0b5f 100644 --- a/README.md +++ b/README.md @@ -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` | diff --git a/examples/config.rb b/examples/config.rb index ac1423c..3b6ffbf 100644 --- a/examples/config.rb +++ b/examples/config.rb @@ -1,8 +1,12 @@ 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+j", config.core.down, mode: config.modes.selection config.bind "ctrl+k", config.core.up, mode: config.modes.selection + 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" diff --git a/src/editor.rs b/src/editor.rs index 4b1567c..57acf4f 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1,4 +1,4 @@ -use crate::files::{DockSide, FilePicker, FilePresentation}; +use crate::files::{DockSide, FilePicker, FilePresentation, FileView}; use serde::Serialize; use std::borrow::Cow; use std::collections::{BTreeMap, HashMap, HashSet}; @@ -443,7 +443,6 @@ fn parse_key(key: &str) -> Result { fn control_key(ch: char) -> Input { // Terminals encode these controls as named keys. match ch.to_ascii_lowercase() { - 'h' => Input::Backspace, 'i' => Input::Char('\t'), 'm' => Input::Enter, ch => Input::Ctrl(ch), @@ -708,6 +707,8 @@ pub struct Editor { bindings: HashMap>, selection_bindings: HashMap, selection_navigation_hint: String, + tree_bindings: HashMap, + tree_navigation_hint: String, leader: Input, leader_bindings: HashMap, Binding>>, leader_suffix: Vec, @@ -716,6 +717,8 @@ pub struct Editor { project_root: PathBuf, file_picker: Option, file_explorer_side: DockSide, + file_picker_view: FileView, + file_explorer_view: FileView, count: Option, } @@ -744,6 +747,16 @@ impl Editor { .map(|(key, action)| (key, Binding::Action(action))) .collect(), selection_navigation_hint: String::new(), + tree_bindings: [ + (Input::Left, Action::Left), + (Input::Ctrl('h'), Action::Left), + (Input::Right, Action::Right), + (Input::Ctrl('l'), Action::Right), + ] + .into_iter() + .map(|(key, action)| (key, Binding::Action(action))) + .collect(), + tree_navigation_hint: String::new(), leader: Input::Char(' '), leader_bindings: HashMap::new(), leader_suffix: Vec::new(), @@ -752,6 +765,8 @@ impl Editor { project_root: std::env::current_dir()?, file_picker: None, file_explorer_side: DockSide::Right, + file_picker_view: FileView::List, + file_explorer_view: FileView::Tree, count: None, }; editor.refresh_selection_navigation_hint(); @@ -850,6 +865,47 @@ impl Editor { }; Ok(()) } + + pub fn file_picker_view(&self) -> FileView { + self.file_picker_view + } + + pub fn file_explorer_view(&self) -> FileView { + self.file_explorer_view + } + + pub fn set_file_picker_view(&mut self, view: &str) -> Result<(), String> { + self.set_file_view(view, FilePresentation::Floating) + } + + pub fn set_file_explorer_view(&mut self, view: &str) -> Result<(), String> { + self.set_file_view(view, FilePresentation::Docked) + } + + fn set_file_view(&mut self, view: &str, presentation: FilePresentation) -> Result<(), String> { + let view = match view.to_ascii_lowercase().as_str() { + "list" => FileView::List, + "tree" => FileView::Tree, + _ => return Err(format!("Invalid file view: {view:?}")), + }; + match presentation { + FilePresentation::Floating => self.file_picker_view = view, + FilePresentation::Docked => self.file_explorer_view = view, + } + if let Some(picker) = self + .file_picker + .as_mut() + .filter(|picker| picker.presentation == presentation) + { + picker.set_view(view); + } + Ok(()) + } + + #[cfg(test)] + pub(crate) fn set_project_root(&mut self, root: PathBuf) { + self.project_root = root; + } /// Pending decimal prefix for the next Normal/Visual motion. pub fn count(&self) -> Option { self.count @@ -1027,18 +1083,24 @@ impl Editor { self.set_message(format!("Plugin {name} disabled: {reason}")); } - /// Overrides an editing mode's key/leader route, or a single `selection` key - /// for list navigation. `None` disables it; invalid overrides are atomic. + /// Overrides an editing route or single selection/tree navigation key. + /// `None` disables it; invalid overrides are atomic. pub fn bind(&mut self, mode: &str, key: &str, target: Option) -> Result<(), String> { - if mode == "selection" { + if matches!(mode, "selection" | "tree") { let input = parse_key(key)?; let binding = match target.as_deref() { None => Binding::Disabled, Some("core.up") => Binding::Action(Action::Up), Some("core.down") => Binding::Action(Action::Down), - Some(target) => return Err(format!("Unknown selection action: {target}")), + Some("core.left") if mode == "tree" => Binding::Action(Action::Left), + Some("core.right") if mode == "tree" => Binding::Action(Action::Right), + Some(target) => return Err(format!("Unknown {mode} action: {target}")), }; - self.selection_bindings.insert(input, binding); + if mode == "tree" { + self.tree_bindings.insert(input, binding); + } else { + self.selection_bindings.insert(input, binding); + } self.refresh_selection_navigation_hint(); return Ok(()); } @@ -1115,11 +1177,25 @@ impl Editor { &self.selection_navigation_hint } + pub fn file_navigation_hint(&self) -> &str { + if self + .file_picker + .as_ref() + .is_some_and(|picker| picker.view == FileView::Tree && !picker.searching) + { + &self.tree_navigation_hint + } else { + &self.selection_navigation_hint + } + } + fn refresh_selection_navigation_hint(&mut self) { - let keys = |action| { + let keys = |action, tree: bool| { let mut keys: Vec<_> = self .selection_bindings .iter() + .filter(|(key, _)| !tree || !self.tree_bindings.contains_key(*key)) + .chain(self.tree_bindings.iter().filter(|_| tree)) .filter( |(_, binding)| matches!(binding, Binding::Action(bound) if *bound == action), ) @@ -1134,9 +1210,36 @@ impl Editor { }; self.selection_navigation_hint = format!( "{} next · {} previous", - keys(Action::Down), - keys(Action::Up) + keys(Action::Down, false), + keys(Action::Up, false) ); + self.tree_navigation_hint = format!( + "{} next · {} previous · {} parent/close · {} open/child", + keys(Action::Down, true), + keys(Action::Up, true), + keys(Action::Left, true), + keys(Action::Right, true) + ); + } + + fn handle_tree_binding(&mut self, input: &Input) -> bool { + let Some(picker) = self + .file_picker + .as_mut() + .filter(|picker| picker.focused && picker.view == FileView::Tree && !picker.searching) + else { + return false; + }; + match self.tree_bindings.get(input) { + Some(Binding::Action(Action::Down)) => picker.navigate(true), + Some(Binding::Action(Action::Up)) => picker.navigate(false), + Some(Binding::Action(Action::Left)) => picker.left(), + Some(Binding::Action(Action::Right)) => picker.right(), + Some(Binding::Disabled) => {} + None => return false, + _ => unreachable!("tree bindings only accept navigation or disabled"), + } + true } fn handle_selection_binding(&mut self, input: &Input) -> bool { @@ -1295,13 +1398,20 @@ impl Editor { } fn open_file_picker(&mut self, presentation: FilePresentation) { + let view = match presentation { + FilePresentation::Floating => self.file_picker_view, + FilePresentation::Docked => self.file_explorer_view, + }; if let Some(picker) = &mut self.file_picker { picker.presentation = presentation; + picker.set_view(view); picker.focused = true; picker.clear_error(); } else { - match FilePicker::scan(self.project_root.clone(), presentation) { - Ok(picker) => self.file_picker = Some(picker), + match FilePicker::scan(self.project_root.clone(), presentation, view) { + Ok(picker) => { + self.file_picker = Some(picker); + } Err(error) => { self.set_message(format!("File scan failed: {error}")); return; @@ -1329,6 +1439,9 @@ impl Editor { .expect("focused picker receives input"); match input { Input::Ctrl('w') => self.file_picker = None, + Input::Escape if picker.view == FileView::Tree && picker.searching => { + picker.cancel_search() + } Input::Escape => self.blur_file_picker(), Input::Ctrl('p') => { self.act(Action::Palette); @@ -1337,20 +1450,21 @@ impl Editor { picker.focused = false; } Input::Ctrl('r') => picker.refresh(), - Input::Backspace => picker.backspace(), - Input::Char(ch) if !ch.is_control() => { + Input::Ctrl('t') => picker.toggle_view(), + Input::Backspace if picker.view == FileView::List || picker.searching => { + picker.backspace() + } + Input::Char('/') if picker.view == FileView::Tree && !picker.searching => { + picker.start_search() + } + Input::Char(ch) + if !ch.is_control() && (picker.view == FileView::List || picker.searching) => + { picker.query.push(ch); picker.refilter(); } Input::Enter => { - picker.clear_error(); - let Some(path) = picker - .entries() - .nth(picker.selected) - .map(|entry| picker.root.join(&entry.path)) - else { - return; - }; + let Some(path) = picker.accept() else { return }; match self.edit_buffer(path, false) { Ok(()) => self.blur_file_picker(), Err(error) => { @@ -1375,6 +1489,9 @@ impl Editor { .file_picker .as_ref() .is_some_and(|picker| picker.focused); + if picker_focused && self.handle_tree_binding(&input) { + return None; + } if (picker_focused || self.palette.is_some()) && self.handle_selection_binding(&input) { return None; } @@ -3218,6 +3335,11 @@ mod tests { editor.command_line = "original prompt".into(); if let Some(presentation) = surface { editor.open_file_picker(presentation); + editor + .file_picker + .as_mut() + .unwrap() + .set_view(FileView::List); type_text(&mut editor, "txt"); } else { editor.handle(Input::Ctrl('p')); @@ -3345,6 +3467,11 @@ mod tests { editor.bind("normal", "ctrl+k", None).unwrap(); if let Some(presentation) = surface { editor.open_file_picker(presentation); + editor + .file_picker + .as_mut() + .unwrap() + .set_view(FileView::List); } else { editor.handle(Input::Ctrl('p')); } @@ -3402,7 +3529,7 @@ mod tests { assert_eq!(parse_key("CTRL+J").unwrap(), Input::Ctrl('j')); for (key, input) in [ ("ctrl+m", Input::Enter), - ("ctrl+h", Input::Backspace), + ("ctrl+h", Input::Ctrl('h')), ("ctrl+i", Input::Char('\t')), ] { assert_eq!(parse_key(key).unwrap(), input); @@ -3602,7 +3729,7 @@ mod tests { let directory = Directory::new(); fs::write(directory.path("keep.txt"), "text").unwrap(); let mut editor = project_editor(&directory); - type_text(&mut editor, " ekeep"); + type_text(&mut editor, " e/keep"); editor.handle(Input::Char('\t')); assert!(!editor.file_picker().unwrap().focused); fs::write(directory.path("keep new.txt"), "new").unwrap(); @@ -3616,7 +3743,7 @@ mod tests { editor.handle(Input::Ctrl('R')); assert_eq!(editor.file_picker().unwrap().entries().len(), 2); assert_eq!(editor.file_picker().unwrap().query, "keep"); - editor.handle(Input::Escape); + editor.handle(Input::Char('\t')); assert!(!editor.file_picker().unwrap().focused); type_text(&mut editor, " e"); assert!(editor.file_picker().unwrap().focused); @@ -3647,7 +3774,7 @@ mod tests { let id = editor.buffer_id(); editor.execute(&format!("edit {}", b.display())); fs::write(&a, b"\xff").unwrap(); - type_text(&mut editor, " ea space 界"); + type_text(&mut editor, " e/a space 界"); editor.handle(Input::Enter); assert_eq!(editor.buffer_id(), id); assert_eq!(editor.buffer_count(), 3); @@ -3759,7 +3886,7 @@ mod tests { type_text(&mut editor, " o"); editor.execute("buffers"); assert!(editor.file_picker().is_none()); - type_text(&mut editor, " efile"); + type_text(&mut editor, " e/file"); editor.execute("buffers"); assert!(!editor.file_picker().unwrap().focused); assert_eq!(editor.file_picker().unwrap().query, "file"); @@ -3776,8 +3903,8 @@ mod tests { editor .apply(vec![Effect::Replace("a\nb\nc\nd".into())]) .unwrap(); - type_text(&mut editor, " efile"); - editor.handle(Input::Escape); + type_text(&mut editor, " e/file"); + editor.handle(Input::Char('\t')); type_text(&mut editor, "2j"); assert_eq!(editor.cursor().row, 2); assert_eq!(editor.file_picker().unwrap().query, "file"); @@ -3848,7 +3975,7 @@ mod tests { ); let selected = picker .entries() - .position(|entry| entry.path == *name) + .position(|entry| entry.path == name.as_path()) .unwrap(); picker.selected = selected; editor.handle(Input::Enter); @@ -3858,6 +3985,391 @@ mod tests { assert_eq!(editor.buffer_count(), 3); } + #[test] + fn file_view_preferences_are_independent_atomic_and_applied_to_current_presentation() { + let directory = Directory::new(); + fs::create_dir(directory.path("folder")).unwrap(); + fs::write(directory.path("file.txt"), "text").unwrap(); + let mut editor = project_editor(&directory); + assert_eq!(editor.file_picker_view(), FileView::List); + assert_eq!(editor.file_explorer_view(), FileView::Tree); + editor.open_file_picker(FilePresentation::Docked); + assert_eq!(editor.file_picker().unwrap().view, FileView::Tree); + editor.set_file_picker_view("TREE").unwrap(); + assert_eq!(editor.file_picker().unwrap().view, FileView::Tree); + editor.set_file_explorer_view("LiSt").unwrap(); + assert_eq!(editor.file_picker().unwrap().view, FileView::List); + for invalid in ["", "flat", " tree "] { + assert!(editor.set_file_picker_view(invalid).is_err()); + assert!(editor.set_file_explorer_view(invalid).is_err()); + assert_eq!(editor.file_picker_view(), FileView::Tree); + assert_eq!(editor.file_explorer_view(), FileView::List); + assert_eq!(editor.file_picker().unwrap().view, FileView::List); + } + editor.handle(Input::Ctrl('t')); + assert_eq!(editor.file_picker().unwrap().view, FileView::Tree); + assert_eq!(editor.file_explorer_view(), FileView::List); + editor.handle(Input::Char('\t')); + editor.open_file_picker(FilePresentation::Docked); + assert_eq!(editor.file_picker().unwrap().view, FileView::List); + editor.open_file_picker(FilePresentation::Floating); + assert_eq!(editor.file_picker().unwrap().view, FileView::Tree); + assert_eq!( + editor.file_picker().unwrap().presentation, + FilePresentation::Floating + ); + editor.set_file_explorer_view("tree").unwrap(); + editor.set_file_picker_view("list").unwrap(); + assert_eq!(editor.file_picker().unwrap().view, FileView::List); + assert_eq!(editor.file_picker().unwrap().entries().len(), 1); + } + + #[test] + fn tree_browse_and_search_have_separate_input_and_two_stage_escape() { + let directory = Directory::new(); + fs::create_dir_all(directory.path("folder/deep")).unwrap(); + fs::write(directory.path("folder/deep/needle.txt"), "text").unwrap(); + fs::write(directory.path("folder/other.txt"), "other").unwrap(); + for presentation in [FilePresentation::Floating, FilePresentation::Docked] { + for mode in [ + Mode::Normal, + Mode::Visual, + Mode::Insert, + Mode::Command, + Mode::Search, + ] { + let mut editor = project_editor(&directory); + editor + .apply(vec![Effect::Replace("alpha\nbeta".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 1), (1, 2)], 1); + editor.active.mode = mode; + editor.command_line = "underlying".into(); + let targets = editor.active.targets.clone(); + let text = editor.context().text; + let undo = editor.active.undo.len(); + editor.set_file_picker_view("tree").unwrap(); + editor.open_file_picker(presentation); + type_text(&mut editor, "123 b xyz"); + editor.handle(Input::Paste("ignored".into())); + editor.handle(Input::Backspace); + assert_eq!(editor.file_picker().unwrap().query, ""); + assert_eq!(editor.count(), None); + assert_eq!(editor.leader_pending(), None); + editor.handle(Input::Enter); + assert_eq!(editor.buffer_count(), 1); + assert!( + editor + .file_picker() + .unwrap() + .entries() + .next() + .unwrap() + .expanded + ); + editor.handle(Input::Ctrl('l')); + assert_eq!( + editor.file_picker().unwrap().entries().nth(1).unwrap().path, + Path::new("folder/deep") + ); + assert_eq!(editor.file_picker().unwrap().selected, 1); + editor.handle(Input::Ctrl('h')); + assert_eq!(editor.file_picker().unwrap().selected, 0); + let before = editor + .file_picker() + .unwrap() + .entries() + .map(|row| row.path.to_owned()) + .collect::>(); + type_text(&mut editor, "/deep/"); + assert!(editor.file_picker().unwrap().searching); + assert_eq!(editor.file_picker().unwrap().query, "deep/"); + editor.handle(Input::Ctrl('H')); + assert_eq!(editor.file_picker().unwrap().query, "deep/"); + editor.handle(Input::Backspace); + assert_eq!(editor.file_picker().unwrap().query, "deep"); + type_text(&mut editor, "👩‍💻"); + editor.handle(Input::Backspace); + assert_eq!(editor.file_picker().unwrap().query, "deep"); + editor.handle(Input::Escape); + let picker = editor.file_picker().unwrap(); + assert!(picker.focused); + assert!(!picker.searching); + assert!(picker.query.is_empty()); + assert_eq!(picker.selected, 0); + assert_eq!( + picker + .entries() + .map(|row| row.path.to_owned()) + .collect::>(), + before + ); + editor.handle(Input::Escape); + match presentation { + FilePresentation::Floating => assert!(editor.file_picker().is_none()), + FilePresentation::Docked => assert!(!editor.file_picker().unwrap().focused), + } + assert_eq!(editor.mode(), mode); + assert_eq!(editor.command_line, "underlying"); + assert_eq!(editor.active.targets, targets); + assert_eq!(editor.context().text, text); + assert_eq!(editor.active.undo.len(), undo); + } + } + } + + #[test] + fn tree_bindings_override_selection_only_in_browse_and_hints_are_effective() { + let directory = Directory::new(); + fs::create_dir(directory.path("folder")).unwrap(); + fs::write(directory.path("folder/a.txt"), "a").unwrap(); + fs::write(directory.path("folder/b.txt"), "b").unwrap(); + fs::write(directory.path("z.txt"), "z").unwrap(); + let mut editor = project_editor(&directory); + editor + .bind("selection", "x", Some("core.down".into())) + .unwrap(); + editor.bind("tree", "x", Some("core.right".into())).unwrap(); + editor + .bind("tree", "ctrl+j", Some("core.left".into())) + .unwrap(); + editor.bind("tree", "ctrl+k", None).unwrap(); + type_text(&mut editor, "3 b"); + let pending = editor.leader_pending().unwrap().to_owned(); + let hint = editor.tree_navigation_hint.clone(); + for (key, target) in [ + ("x", Some("core.delete")), + ("x", Some("core.palette")), + ("x", Some("w")), + ("leader x", Some("core.right")), + ("x y", Some("core.left")), + ("alt+x", None), + ] { + assert!(editor.bind("tree", key, target.map(str::to_owned)).is_err()); + assert_eq!(editor.tree_navigation_hint, hint); + assert_eq!(editor.count(), Some(3)); + assert_eq!(editor.leader_pending(), Some(pending.as_str())); + } + editor.open_file_picker(FilePresentation::Docked); + assert_eq!( + editor.file_navigation_hint(), + "Ctrl+n/Down next · Up previous · Ctrl+h/Ctrl+j/Left parent/close · Ctrl+l/Right/x open/child" + ); + editor.handle(Input::Char('x')); + assert_eq!(editor.file_picker().unwrap().selected, 0); + assert!( + editor + .file_picker() + .unwrap() + .entries() + .next() + .unwrap() + .expanded + ); + editor.handle(Input::Char('x')); + assert_eq!(editor.file_picker().unwrap().selected, 1); + editor.handle(Input::Ctrl('k')); + assert_eq!(editor.file_picker().unwrap().selected, 1); + editor.handle(Input::Ctrl('j')); + assert_eq!(editor.file_picker().unwrap().selected, 0); + editor.handle(Input::Char('/')); + assert_eq!( + editor.file_navigation_hint(), + editor.selection_navigation_hint() + ); + editor.handle(Input::Char('x')); + assert_eq!(editor.file_picker().unwrap().selected, 1); + assert_eq!(editor.file_picker().unwrap().query, ""); + editor.handle(Input::Ctrl('j')); + assert_eq!(editor.file_picker().unwrap().selected, 2); + editor.handle(Input::Ctrl('h')); + assert_eq!(editor.file_picker().unwrap().selected, 2); + editor.handle(Input::Ctrl('t')); + assert_eq!(editor.file_picker().unwrap().view, FileView::List); + assert_eq!( + editor.file_navigation_hint(), + editor.selection_navigation_hint() + ); + editor.handle(Input::Char('x')); + assert_eq!(editor.file_picker().unwrap().selected, 1); + editor.handle(Input::Ctrl('w')); + editor.handle(Input::Char('x')); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(editor.context().text, ""); + editor.handle(Input::Ctrl('p')); + let selected = editor.palette().unwrap().selected; + editor.handle(Input::Char('x')); + assert_eq!(editor.palette().unwrap().selected, selected + 1); + editor.handle(Input::Ctrl('h')); + assert_eq!(editor.palette().unwrap().query, ""); + } + + #[test] + fn ctrl_h_is_not_backspace_unless_explicitly_bound() { + assert_eq!(parse_key("CTRL+H").unwrap(), Input::Ctrl('h')); + assert_eq!(parse_key("backspace").unwrap(), Input::Backspace); + for mode in [ + Mode::Normal, + Mode::Visual, + Mode::Insert, + Mode::Command, + Mode::Search, + ] { + let mut editor = editor(); + editor.apply(vec![Effect::Replace("abc".into())]).unwrap(); + set_carets(&mut editor, &[(0, 2)], 0); + editor.active.mode = mode; + editor.command_line = "query".into(); + let targets = editor.active.targets.clone(); + let undo = editor.active.undo.len(); + editor.handle(Input::Ctrl('h')); + assert_eq!(editor.context().text, "abc"); + assert_eq!(editor.active.targets, targets); + assert_eq!(editor.active.undo.len(), undo); + assert_eq!(editor.command_line, "query"); + assert_eq!(editor.mode(), mode); + } + let mut editor = editor(); + type_text(&mut editor, "iabc"); + editor.handle(Input::Ctrl('h')); + assert_eq!(editor.context().text, "abc"); + editor + .bind("insert", "ctrl+h", Some("core.backspace".into())) + .unwrap(); + editor.handle(Input::Ctrl('h')); + assert_eq!(editor.context().text, "ab"); + editor.bind("insert", "ctrl+h", None).unwrap(); + editor.handle(Input::Ctrl('h')); + assert_eq!(editor.context().text, "ab"); + editor.handle(Input::Backspace); + assert_eq!(editor.context().text, "a"); + + let directory = Directory::new(); + fs::write(directory.path("a.txt"), "text").unwrap(); + let mut editor = project_editor(&directory); + editor.open_file_picker(FilePresentation::Floating); + type_text(&mut editor, "a"); + editor.handle(Input::Ctrl('h')); + assert_eq!(editor.file_picker().unwrap().query, "a"); + editor.handle(Input::Backspace); + assert_eq!(editor.file_picker().unwrap().query, ""); + editor.handle(Input::Ctrl('p')); + type_text(&mut editor, "core"); + editor.handle(Input::Ctrl('h')); + assert_eq!(editor.palette().unwrap().query, "core"); + editor.handle(Input::Backspace); + assert_eq!(editor.palette().unwrap().query, "cor"); + } + + #[test] + fn tree_leaf_search_opens_direct_match_and_directory_search_enters_browse() { + let directory = Directory::new(); + fs::create_dir_all(directory.path("src/module")).unwrap(); + fs::write(directory.path("src/module/needle.txt"), "needle").unwrap(); + fs::write(directory.path("src/sibling.txt"), "sibling").unwrap(); + for presentation in [FilePresentation::Floating, FilePresentation::Docked] { + let mut editor = project_editor(&directory); + editor.set_file_picker_view("tree").unwrap(); + editor.open_file_picker(presentation); + type_text(&mut editor, "/needle.txt"); + let picker = editor.file_picker().unwrap(); + assert_eq!( + picker.entries().nth(picker.selected).unwrap().path, + Path::new("src/module/needle.txt") + ); + editor.handle(Input::Enter); + assert_eq!(editor.context().text, "needle"); + assert_eq!( + editor.path(), + Some(directory.path("src/module/needle.txt").as_path()) + ); + editor.open_file_picker(presentation); + editor.handle(Input::Ctrl('w')); + editor.open_file_picker(presentation); + type_text(&mut editor, "/module"); + assert!( + editor + .file_picker() + .unwrap() + .entries() + .nth(editor.file_picker().unwrap().selected) + .unwrap() + .is_dir + ); + let id = editor.buffer_id(); + editor.handle(Input::Enter); + let picker = editor.file_picker().unwrap(); + assert_eq!(editor.buffer_id(), id); + assert!(!picker.searching); + assert!(picker.query.is_empty()); + let selected = picker.entries().nth(picker.selected).unwrap(); + assert_eq!(selected.path, Path::new("src/module")); + assert!(selected.expanded); + assert_eq!(editor.buffer_count(), 2); + } + } + + #[cfg(unix)] + #[test] + fn tree_search_opens_raw_nested_paths_and_deduplicates_dirty_buffers() { + let directory = Directory::new(); + fs::create_dir_all(directory.path("dir\n/deep")).unwrap(); + let a = directory.path("dir\n/deep/same\n.txt"); + let b = directory.path("dir\n/deep/same\t.txt"); + fs::write(&a, "first").unwrap(); + fs::write(&b, "second").unwrap(); + for presentation in [FilePresentation::Floating, FilePresentation::Docked] { + fs::write(&a, "first").unwrap(); + let mut editor = project_editor(&directory); + editor.set_file_picker_view("tree").unwrap(); + editor.edit_buffer(a.clone(), false).unwrap(); + editor + .apply(vec![Effect::Replace("dirty first".into())]) + .unwrap(); + type_text(&mut editor, "vl"); + let selections = editor.selections(); + let id = editor.buffer_id(); + editor.edit_buffer(b.clone(), false).unwrap(); + fs::write(&a, b"\xff").unwrap(); + editor.open_file_picker(presentation); + type_text(&mut editor, "/same"); + let picker = editor.file_picker.as_mut().unwrap(); + let labels = picker + .entries() + .filter(|row| !row.is_dir) + .map(|row| row.label) + .collect::>(); + assert_eq!(labels.len(), 2); + assert_eq!(labels[0], labels[1]); + assert!( + picker + .entries() + .all(|row| !row.label.chars().any(char::is_control)) + ); + let selected = picker + .entries() + .position(|row| picker.root.join(row.path) == a) + .unwrap(); + picker.selected = selected; + editor.handle(Input::Enter); + assert_eq!(editor.path(), Some(a.as_path())); + assert_eq!(editor.buffer_id(), id); + assert_eq!(editor.buffer_count(), 3); + assert_eq!(editor.context().text, "dirty first"); + assert!(editor.dirty()); + assert_eq!(editor.mode(), Mode::Visual); + assert_eq!(editor.selections(), selections); + match presentation { + FilePresentation::Floating => assert!(editor.file_picker().is_none()), + FilePresentation::Docked => { + assert!(!editor.file_picker().unwrap().focused); + assert!(editor.file_picker().unwrap().searching); + assert_eq!(editor.file_picker().unwrap().query, "same"); + } + } + } + } + #[test] fn default_leader_routes_save_switch_list_delete_and_open_palette() { let directory = Directory::new(); @@ -5115,14 +5627,7 @@ mod tests { assert_eq!( palette_entry(&editor, "core.insert").bindings, vec![ - "Backspace", - "Ctrl+j", - "Ctrl+r", - "Enter", - "I", - "Space", - "Tab", - "界" + "Ctrl+h", "Ctrl+j", "Ctrl+r", "Enter", "I", "Space", "Tab", "界" ] ); assert!( diff --git a/src/files.rs b/src/files.rs index 0b046c0..92be508 100644 --- a/src/files.rs +++ b/src/files.rs @@ -1,7 +1,8 @@ use ignore::WalkBuilder; +use std::collections::{HashMap, HashSet}; use std::fs; use std::io; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use unicode_segmentation::UnicodeSegmentation; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -10,6 +11,21 @@ pub enum FilePresentation { Docked, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FileView { + List, + Tree, +} + +impl FileView { + fn index(self) -> usize { + match self { + Self::List => 0, + Self::Tree => 1, + } + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum DockSide { Left, @@ -18,11 +34,36 @@ pub enum DockSide { Bottom, } -pub struct FileEntry { +pub struct FileRow<'a> { /// The actual project-relative OS path, never reconstructed from the label. - pub path: PathBuf, - pub label: String, + pub path: &'a Path, + pub label: &'a str, + pub is_dir: bool, + pub depth: usize, + pub expanded: bool, +} + +struct Node { + path: PathBuf, + label: String, search_label: String, + is_dir: bool, + parent: Option, + children: Vec, +} + +struct TreeNode { + node: usize, + label: String, + parent: Option, + children: Vec, +} + +struct VisibleRow { + node: usize, + tree: Option, + depth: usize, + expanded: bool, } pub struct FilePicker { @@ -30,22 +71,39 @@ pub struct FilePicker { pub query: String, pub selected: usize, pub presentation: FilePresentation, + pub view: FileView, + pub searching: bool, pub focused: bool, pub error: Option, - files: Vec, - matches: Vec, + nodes: Vec, + tree: Vec, + tree_for_node: Vec, + tree_roots: Vec, + rows: Vec, + expanded: HashSet, + saved_selection: [Option; 2], + filtered_selection: [Option; 2], + before_search: Option, + tree_searching: bool, warning: Option, } +fn label(path: &Path) -> String { + path.to_string_lossy() + .chars() + .map(|ch| if ch.is_control() { '�' } else { ch }) + .collect() +} + impl FilePicker { - pub fn scan(root: PathBuf, presentation: FilePresentation) -> io::Result { + pub fn scan(root: PathBuf, presentation: FilePresentation, view: FileView) -> io::Result { if !fs::metadata(&root)?.is_dir() { return Err(io::Error::new( io::ErrorKind::NotADirectory, "Project root is not a directory", )); } - // Root failures are fatal; descendant failures leave a visibly incomplete list. + // Root failures are fatal; descendant failures leave a visibly incomplete catalog. fs::read_dir(&root)?; // ponytail: synchronous scan; move indexing off the input thread if large projects stall. let mut walk = WalkBuilder::new(&root); @@ -57,7 +115,7 @@ impl FilePicker { .filter_entry(|entry| { entry.depth() == 0 || (entry.file_name() != ".git" && entry.file_name() != ".jj") }); - let mut files = Vec::new(); + let mut nodes = Vec::new(); let mut warning = None; for entry in walk.build() { let entry = match entry { @@ -73,8 +131,12 @@ impl FilePicker { if let Some(error) = entry.error() { warning.get_or_insert_with(|| format!("Incomplete scan: {error}")); } - // Symlinks (including file symlinks) are intentionally excluded. - if !entry.file_type().is_some_and(|kind| kind.is_file()) { + // Symlinks (including file symlinks) and the implicit project root are excluded. + if entry.depth() == 0 + || !entry + .file_type() + .is_some_and(|kind| kind.is_file() || kind.is_dir()) + { continue; } let path = entry @@ -82,49 +144,335 @@ impl FilePicker { .strip_prefix(&root) .expect("walk stays within root") .to_owned(); - let label: String = path - .to_string_lossy() - .chars() - .map(|ch| if ch.is_control() { '�' } else { ch }) - .collect(); - let search_label = label.to_lowercase(); - files.push(FileEntry { + let label = label(&path); + nodes.push(Node { path, + search_label: label.to_lowercase(), label, - search_label, + is_dir: entry.file_type().unwrap().is_dir(), + parent: None, + children: Vec::new(), }); } - files.sort_by(|left, right| left.path.cmp(&right.path)); - let matches = (0..files.len()).collect(); - Ok(Self { + nodes.sort_by(|left, right| left.path.cmp(&right.path)); + let by_path: HashMap<_, _> = nodes + .iter() + .enumerate() + .map(|(index, node)| (node.path.clone(), index)) + .collect(); + let mut roots = Vec::new(); + for index in 0..nodes.len() { + let parent = nodes[index] + .path + .parent() + .and_then(|path| by_path.get(path)) + .copied(); + nodes[index].parent = parent; + if let Some(parent) = parent { + nodes[parent].children.push(index); + } else { + roots.push(index); + } + } + let mut children: Vec<_> = nodes + .iter_mut() + .map(|node| std::mem::take(&mut node.children)) + .collect(); + let order = |left: &usize, right: &usize| { + nodes[*right] + .is_dir + .cmp(&nodes[*left].is_dir) + .then_with(|| { + nodes[*left] + .path + .file_name() + .cmp(&nodes[*right].path.file_name()) + }) + }; + roots.sort_by(order); + for children in &mut children { + children.sort_by(order); + } + for (node, children) in nodes.iter_mut().zip(children) { + node.children = children; + } + let mut picker = Self { root, query: String::new(), selected: 0, presentation, + view, + searching: false, focused: true, error: warning.clone(), - files, - matches, + tree_for_node: vec![0; nodes.len()], + nodes, + tree: Vec::new(), + tree_roots: Vec::new(), + rows: Vec::new(), + expanded: HashSet::new(), + saved_selection: [None, None], + filtered_selection: [None, None], + before_search: None, + tree_searching: false, warning, + }; + for root in roots { + let tree = picker.compact(root, None); + picker.tree_roots.push(tree); + } + picker.project(None); + picker.remember_selection(false); + Ok(picker) + } + + fn compact(&mut self, start: usize, parent: Option) -> usize { + let tree = self.tree.len(); + let mut terminal = start; + let mut name = label(Path::new(self.nodes[start].path.file_name().unwrap())); + self.tree_for_node[start] = tree; + while self.nodes[terminal].children.len() == 1 { + let child = self.nodes[terminal].children[0]; + if !self.nodes[terminal].is_dir || !self.nodes[child].is_dir { + break; + } + terminal = child; + self.tree_for_node[terminal] = tree; + name.push('/'); + name.push_str(&label(Path::new( + self.nodes[terminal].path.file_name().unwrap(), + ))); + } + self.tree.push(TreeNode { + node: terminal, + label: name, + parent, + children: Vec::new(), + }); + for index in 0..self.nodes[terminal].children.len() { + let child = self.compact(self.nodes[terminal].children[index], Some(tree)); + self.tree[tree].children.push(child); + } + tree + } + + pub fn entries(&self) -> impl ExactSizeIterator> + '_ { + self.rows.iter().map(|row| { + let node = &self.nodes[row.node]; + FileRow { + path: &node.path, + label: row + .tree + .map_or(node.label.as_str(), |tree| self.tree[tree].label.as_str()), + is_dir: node.is_dir, + depth: row.depth, + expanded: row.expanded, + } }) } - pub fn entries(&self) -> impl ExactSizeIterator + '_ { - self.matches.iter().map(|&index| &self.files[index]) + fn selected_node(&self) -> Option { + self.rows.get(self.selected).map(|row| row.node) + } + + fn remember_selection(&mut self, explicit: bool) { + let Some(node) = self.selected_node() else { + return; + }; + self.saved_selection[self.view.index()] = Some(node); + if explicit && !self.nodes[node].is_dir { + self.saved_selection = [Some(node), Some(node)]; + if !self.query.is_empty() { + self.filtered_selection = [Some(node), Some(node)]; + } + } + } + + fn is_expanded(&self, tree: usize) -> bool { + let mut node = Some(self.tree[tree].node); + while let Some(index) = node { + if self.tree_for_node[index] != tree { + break; + } + if self.expanded.contains(&index) { + return true; + } + node = self.nodes[index].parent; + } + false + } + + fn set_expanded(&mut self, tree: usize, expanded: bool) { + let mut node = Some(self.tree[tree].node); + while let Some(index) = node { + if self.tree_for_node[index] != tree { + break; + } + if expanded { + self.expanded.insert(index); + } else { + self.expanded.remove(&index); + } + node = self.nodes[index].parent; + } + } + + fn reveal(&mut self, node: usize) { + let mut parent = self.tree[self.tree_for_node[node]].parent; + while let Some(tree) = parent { + self.set_expanded(tree, true); + parent = self.tree[tree].parent; + } + } + + pub fn set_view(&mut self, view: FileView) { + if self.view == view { + return; + } + let current = self.selected_node(); + if !self.searching { + self.remember_selection(false); + } + if self.view == FileView::Tree { + self.tree_searching = self.searching; + } + self.view = view; + let selected = self.filtered_selection[view.index()] + .or(self.saved_selection[view.index()]) + .or(current); + if view == FileView::Tree { + self.searching = self.tree_searching || !self.query.is_empty(); + if self.searching && self.before_search.is_none() { + self.before_search = self.saved_selection[1]; + } + if !self.searching + && let Some(node) = selected + { + self.reveal(node); + } + } else { + self.searching = false; + } + self.project(selected); + } + + pub fn toggle_view(&mut self) { + self.set_view(match self.view { + FileView::List => FileView::Tree, + FileView::Tree => FileView::List, + }); + } + + pub(crate) fn start_search(&mut self) { + self.before_search = self.selected_node(); + self.searching = true; + self.tree_searching = true; + self.project(self.before_search); + self.clear_error(); + } + + pub(crate) fn cancel_search(&mut self) { + self.query.clear(); + self.searching = false; + self.tree_searching = false; + self.filtered_selection = [None, None]; + let selected = self.before_search.take().or(self.saved_selection[1]); + self.project(selected); + self.clear_error(); + } + + fn project(&mut self, selected: Option) { + let query = self.query.to_lowercase(); + let tokens: Vec<_> = query.split_whitespace().collect(); + let matches = |node: &Node| tokens.iter().all(|token| node.search_label.contains(token)); + self.rows.clear(); + if self.view == FileView::List { + self.rows + .extend(self.nodes.iter().enumerate().filter_map(|(node, entry)| { + (!entry.is_dir && matches(entry)).then_some(VisibleRow { + node, + tree: None, + depth: 0, + expanded: false, + }) + })); + } else { + let mut included = vec![false; self.tree.len()]; + for (index, _) in self + .nodes + .iter() + .enumerate() + .filter(|(_, node)| matches(node)) + { + let mut tree = Some(self.tree_for_node[index]); + while let Some(index) = tree { + if included[index] { + break; + } + included[index] = true; + tree = self.tree[index].parent; + } + } + let filtered = self.searching || !tokens.is_empty(); + let mut pending: Vec<_> = self + .tree_roots + .iter() + .rev() + .map(|&tree| (tree, 0)) + .collect(); + while let Some((tree, depth)) = pending.pop() { + if !included[tree] { + continue; + } + let entry = &self.tree[tree]; + let expanded = self.nodes[entry.node].is_dir + && (self.is_expanded(tree) + || (filtered && entry.children.iter().any(|&child| included[child]))); + self.rows.push(VisibleRow { + node: entry.node, + tree: Some(tree), + depth, + expanded, + }); + if expanded { + pending.extend(entry.children.iter().rev().map(|&child| (child, depth + 1))); + } + } + } + // Filtering selects a real match, never an ancestor present only as context. + // Browsing falls back to the nearest visible ancestor, then the first row. + let mut target = selected; + self.selected = self + .rows + .iter() + .position(|row| matches(&self.nodes[row.node])) + .unwrap_or(0); + while let Some(node) = target { + if let Some(index) = self.rows.iter().position(|row| { + matches(&self.nodes[row.node]) + && (row.node == node + || (self.view == FileView::Tree + && row.tree == Some(self.tree_for_node[node]))) + }) { + self.selected = index; + break; + } + target = self.nodes[node].parent; + } } pub(crate) fn refilter(&mut self) { - let query = self.query.to_lowercase(); - let tokens: Vec<_> = query.split_whitespace().collect(); - self.matches.clear(); - self.matches - .extend(self.files.iter().enumerate().filter_map(|(index, entry)| { - tokens - .iter() - .all(|token| entry.search_label.contains(token)) - .then_some(index) - })); - self.selected = 0; + let view = self.view.index(); + let current = self.selected_node(); + let selected = if self.query.is_empty() { + self.filtered_selection[view].take().or(current) + } else { + if self.filtered_selection[view].is_none() { + self.filtered_selection[view] = current; + } + self.filtered_selection[view] + }; + self.project(selected); self.clear_error(); } @@ -136,7 +484,7 @@ impl FilePicker { } pub(crate) fn navigate(&mut self, forward: bool) { - let count = self.matches.len(); + let count = self.rows.len(); self.selected = if count == 0 { 0 } else if forward { @@ -146,19 +494,127 @@ impl FilePicker { } else { self.selected - 1 }; + if !self.query.is_empty() { + self.filtered_selection[self.view.index()] = self.selected_node(); + } + if !self.searching { + self.remember_selection(true); + } self.clear_error(); } + pub(crate) fn right(&mut self) { + let Some(row) = self.rows.get(self.selected) else { + return; + }; + if !self.nodes[row.node].is_dir { + return; + } + if row.expanded { + if self + .rows + .get(self.selected + 1) + .is_some_and(|child| child.depth > row.depth) + { + self.selected += 1; + } + } else { + let node = row.node; + let tree = row.tree.expect("directory navigation is tree-only"); + self.set_expanded(tree, true); + self.project(Some(node)); + } + self.remember_selection(true); + self.clear_error(); + } + + pub(crate) fn left(&mut self) { + let Some(row) = self.rows.get(self.selected) else { + return; + }; + if row.expanded { + let node = row.node; + let tree = row.tree.expect("directory navigation is tree-only"); + self.set_expanded(tree, false); + self.project(Some(node)); + } else if let Some(parent) = row.tree.and_then(|tree| self.tree[tree].parent) { + let node = self.tree[parent].node; + if let Some(index) = self.rows.iter().position(|row| row.node == node) { + self.selected = index; + } + } + self.remember_selection(true); + self.clear_error(); + } + + /// Directories toggle in browsing; files retain the authoritative path for the editor. + pub(crate) fn accept(&mut self) -> Option { + self.clear_error(); + let node = self.selected_node()?; + if self.nodes[node].is_dir { + if self.searching { + self.query.clear(); + self.searching = false; + self.tree_searching = false; + self.before_search = None; + self.filtered_selection = [None, None]; + self.reveal(node); + } + let tree = self.tree_for_node[node]; + let expanded = !self.is_expanded(tree); + self.set_expanded(tree, expanded); + self.project(Some(node)); + self.remember_selection(true); + None + } else { + self.remember_selection(true); + Some(self.root.join(&self.nodes[node].path)) + } + } + pub(crate) fn clear_error(&mut self) { self.error = self.warning.clone(); } pub(crate) fn refresh(&mut self) { - match Self::scan(self.root.clone(), self.presentation) { + match Self::scan(self.root.clone(), self.presentation, self.view) { Ok(mut replacement) => { + let by_path: HashMap<_, _> = replacement + .nodes + .iter() + .enumerate() + .map(|(index, node)| (node.path.clone(), index)) + .collect(); + let surviving = |node: usize| { + let mut path = self.nodes[node].path.as_path(); + loop { + if let Some(&node) = by_path.get(path) { + return Some(node); + } + path = path.parent()?; + } + }; + replacement.expanded = self + .expanded + .iter() + .filter_map(|&node| { + by_path + .get(&self.nodes[node].path) + .copied() + .filter(|&node| replacement.nodes[node].is_dir) + }) + .collect(); + replacement.saved_selection = + self.saved_selection.map(|node| node.and_then(surviving)); + replacement.filtered_selection = + self.filtered_selection.map(|node| node.and_then(surviving)); + replacement.before_search = self.before_search.and_then(surviving); replacement.query = self.query.clone(); replacement.focused = self.focused; - replacement.refilter(); + replacement.view = self.view; + replacement.searching = self.searching; + replacement.tree_searching = self.tree_searching; + replacement.project(self.selected_node().and_then(surviving)); *self = replacement; } Err(error) => self.error = Some(format!("Refresh failed: {error}")), @@ -187,7 +643,7 @@ mod tests { fs::write(path, text).unwrap(); } fn scan(&self) -> FilePicker { - FilePicker::scan(self.0.clone(), FilePresentation::Floating).unwrap() + FilePicker::scan(self.0.clone(), FilePresentation::Floating, FileView::List).unwrap() } } impl Drop for Directory { @@ -196,7 +652,10 @@ mod tests { } } fn paths(picker: &FilePicker) -> Vec { - picker.entries().map(|entry| entry.path.clone()).collect() + picker + .entries() + .map(|entry| entry.path.to_owned()) + .collect() } #[test] @@ -280,7 +739,14 @@ mod tests { picker.refresh(); assert_eq!(paths(&picker), [PathBuf::from("new.txt")]); assert!(picker.error.as_ref().unwrap().contains("Refresh failed")); - assert!(FilePicker::scan(directory.0.clone(), FilePresentation::Docked).is_err()); + assert!( + FilePicker::scan( + directory.0.clone(), + FilePresentation::Docked, + FileView::Tree + ) + .is_err() + ); } #[test] @@ -306,12 +772,13 @@ mod tests { directory.put(".gitignore", "*.txt\n"); directory.put("project/visible.txt", "text"); let root = directory.0.join("project"); - let picker = FilePicker::scan(root, FilePresentation::Floating).unwrap(); + let picker = FilePicker::scan(root, FilePresentation::Floating, FileView::List).unwrap(); assert_eq!(paths(&picker), [PathBuf::from("visible.txt")]); assert!( FilePicker::scan( directory.0.join("project/visible.txt"), - FilePresentation::Docked + FilePresentation::Docked, + FileView::Tree, ) .is_err() ); @@ -329,11 +796,284 @@ mod tests { fs::write(directory.0.join(&raw), "text").unwrap(); let picker = directory.scan(); assert_eq!(picker.entries().len(), 2); - let entry = picker.entries().find(|entry| entry.path == raw).unwrap(); + let entry = picker + .entries() + .find(|entry| entry.path == raw.as_path()) + .unwrap(); assert!(!entry.label.chars().any(char::is_control)); assert_eq!( - fs::read_to_string(directory.0.join(&entry.path)).unwrap(), + fs::read_to_string(directory.0.join(entry.path)).unwrap(), "text" ); } + + fn tree(directory: &Directory) -> FilePicker { + FilePicker::scan( + directory.0.clone(), + FilePresentation::Docked, + FileView::Tree, + ) + .unwrap() + } + + fn select(picker: &mut FilePicker, path: &str) { + let selected = picker + .entries() + .position(|row| row.path == Path::new(path)) + .unwrap(); + picker.selected = selected; + } + + fn selected_path(picker: &FilePicker) -> &Path { + picker.entries().nth(picker.selected).unwrap().path + } + + #[test] + fn tree_orders_folders_first_keeps_empty_dirs_and_compacts_only_directory_chains() { + let directory = Directory::new(); + directory.put("a.txt", ""); + directory.put("folder/only.txt", ""); + directory.put("chain/one/two/a.txt", ""); + directory.put("chain/one/two/z.txt", ""); + fs::create_dir(directory.0.join("empty")).unwrap(); + let mut picker = tree(&directory); + assert_eq!(picker.view, FileView::Tree); + assert!(!picker.searching); + assert_eq!( + picker + .entries() + .map(|row| (row.label, row.is_dir, row.depth)) + .collect::>(), + [ + ("chain/one/two", true, 0), + ("empty", true, 0), + ("folder", true, 0), + ("a.txt", false, 0) + ] + ); + assert_eq!(selected_path(&picker), Path::new("chain/one/two")); + picker.right(); + assert_eq!( + picker + .entries() + .map(|row| (row.label, row.depth)) + .collect::>(), + [ + ("chain/one/two", 0), + ("a.txt", 1), + ("z.txt", 1), + ("empty", 0), + ("folder", 0), + ("a.txt", 0) + ] + ); + picker.right(); + assert_eq!(selected_path(&picker), Path::new("chain/one/two/a.txt")); + let rows = paths(&picker); + picker.right(); + assert_eq!(paths(&picker), rows); + picker.left(); + assert_eq!(selected_path(&picker), Path::new("chain/one/two")); + picker.left(); + assert!(!picker.entries().next().unwrap().expanded); + picker.left(); + assert_eq!(picker.selected, 0); + select(&mut picker, "folder"); + assert!(picker.accept().is_none()); + assert_eq!( + picker.entries().nth(picker.selected + 1).unwrap().label, + "only.txt" + ); + picker.right(); + assert_eq!(picker.accept(), Some(directory.0.join("folder/only.txt"))); + select(&mut picker, "empty"); + picker.right(); + assert!(picker.entries().nth(picker.selected).unwrap().expanded); + picker.right(); + assert_eq!(selected_path(&picker), Path::new("empty")); + } + + #[test] + fn tree_search_reveals_ancestors_without_mutating_saved_expansion_or_compaction() { + let directory = Directory::new(); + directory.put("src/module/deep/needle.txt", ""); + directory.put("src/module/sibling.txt", ""); + directory.put("src/root.txt", ""); + directory.put("other/keep.txt", ""); + let mut picker = tree(&directory); + select(&mut picker, "other"); + picker.right(); + select(&mut picker, "src"); + let browsing = paths(&picker); + let expanded = picker.expanded.clone(); + picker.start_search(); + picker.query = "NEEDLE".into(); + picker.refilter(); + assert_eq!( + picker + .entries() + .map(|row| (row.label, row.depth)) + .collect::>(), + [("src", 0), ("module", 1), ("deep", 2), ("needle.txt", 3)] + ); + assert_eq!( + selected_path(&picker), + Path::new("src/module/deep/needle.txt") + ); + assert_eq!(picker.expanded, expanded); + select(&mut picker, "src/module/deep/needle.txt"); + picker.navigate(false); + picker.cancel_search(); + assert_eq!(paths(&picker), browsing); + assert_eq!(selected_path(&picker), Path::new("src")); + assert_eq!(picker.expanded, expanded); + picker.start_search(); + picker.query = "deep".into(); + picker.refilter(); + select(&mut picker, "src/module/deep"); + assert!(picker.accept().is_none()); + assert!(!picker.searching); + assert!(picker.query.is_empty()); + assert_eq!(selected_path(&picker), Path::new("src/module/deep")); + assert!(picker.entries().nth(picker.selected).unwrap().expanded); + assert!(picker.expanded.is_superset(&expanded)); + + let chain = Directory::new(); + chain.put("one/two/three/a.txt", ""); + chain.put("one/two/three/b.txt", ""); + let mut picker = tree(&chain); + picker.start_search(); + picker.query = "a.txt".into(); + picker.refilter(); + assert_eq!( + picker.entries().map(|row| row.label).collect::>(), + ["one/two/three", "a.txt"] + ); + } + + #[test] + fn view_switches_restore_directory_choices_and_reveal_explicit_file_choices() { + let directory = Directory::new(); + directory.put("src/a.txt", ""); + directory.put("src/b.txt", ""); + directory.put("z.txt", ""); + let mut picker = tree(&directory); + picker.right(); + let open = paths(&picker); + picker.set_view(FileView::List); + assert_eq!( + paths(&picker), + ["src/a.txt", "src/b.txt", "z.txt"].map(PathBuf::from) + ); + picker.set_view(FileView::Tree); + assert_eq!(selected_path(&picker), Path::new("src")); + assert_eq!(paths(&picker), open); + picker.left(); + picker.toggle_view(); + picker.navigate(true); + assert_eq!(selected_path(&picker), Path::new("src/b.txt")); + picker.toggle_view(); + assert_eq!(selected_path(&picker), Path::new("src/b.txt")); + assert!(picker.entries().next().unwrap().expanded); + picker.left(); + picker.toggle_view(); + picker.toggle_view(); + assert_eq!(selected_path(&picker), Path::new("src")); + } + + #[test] + fn filtering_and_refresh_preserve_raw_selection_and_surviving_expansion() { + let directory = Directory::new(); + directory.put("src/a.txt", ""); + directory.put("src/b.txt", ""); + directory.put("z.txt", ""); + let mut picker = directory.scan(); + select(&mut picker, "src/b.txt"); + picker.query = "absent".into(); + picker.refilter(); + assert_eq!(picker.entries().len(), 0); + picker.query.clear(); + picker.refilter(); + assert_eq!(selected_path(&picker), Path::new("src/b.txt")); + picker.set_view(FileView::Tree); + assert_eq!(selected_path(&picker), Path::new("src/b.txt")); + fs::remove_file(directory.0.join("src/b.txt")).unwrap(); + directory.put("src/c.txt", ""); + picker.refresh(); + assert_eq!(selected_path(&picker), Path::new("src")); + assert!(picker.entries().next().unwrap().expanded); + assert!(paths(&picker).contains(&PathBuf::from("src/c.txt"))); + fs::remove_dir_all(directory.0.join("src")).unwrap(); + picker.refresh(); + assert_eq!(selected_path(&picker), Path::new("z.txt")); + assert!(picker.expanded.is_empty()); + picker.toggle_view(); + picker.toggle_view(); + assert_eq!(selected_path(&picker), Path::new("z.txt")); + } + + #[test] + fn tree_search_subfocus_and_expansion_survive_view_toggles_and_refresh() { + let directory = Directory::new(); + directory.put("src/deep/a.txt", ""); + directory.put("src/other.txt", ""); + let mut picker = tree(&directory); + picker.start_search(); + assert!(picker.searching); + assert_eq!(picker.entries().len(), 4); + assert!(picker.expanded.is_empty()); + picker.toggle_view(); + assert_eq!(picker.view, FileView::List); + picker.toggle_view(); + assert!(picker.searching); + assert!(picker.expanded.is_empty()); + picker.query = "a.txt".into(); + picker.refilter(); + assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt")); + picker.refresh(); + assert!(picker.searching); + assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt")); + picker.cancel_search(); + assert_eq!(paths(&picker), [PathBuf::from("src")]); + assert_eq!(selected_path(&picker), Path::new("src")); + assert!(picker.expanded.is_empty()); + } + + #[test] + fn refresh_preserves_expansion_when_directory_chains_merge_and_split() { + let directory = Directory::new(); + directory.put("one/a.txt", ""); + directory.put("one/two/b.txt", ""); + let mut picker = tree(&directory); + picker.right(); + assert_eq!( + paths(&picker), + ["one", "one/two", "one/a.txt"].map(PathBuf::from) + ); + fs::remove_file(directory.0.join("one/a.txt")).unwrap(); + picker.refresh(); + assert_eq!(selected_path(&picker), Path::new("one/two")); + assert_eq!( + paths(&picker), + ["one/two", "one/two/b.txt"].map(PathBuf::from) + ); + assert!(picker.entries().next().unwrap().expanded); + directory.put("one/a.txt", ""); + picker.refresh(); + assert_eq!(selected_path(&picker), Path::new("one/two")); + assert_eq!( + paths(&picker), + ["one", "one/two", "one/a.txt"].map(PathBuf::from) + ); + assert!(picker.entries().next().unwrap().expanded); + assert!(!picker.entries().nth(1).unwrap().expanded); + fs::remove_file(directory.0.join("one/a.txt")).unwrap(); + picker.refresh(); + picker.left(); + assert_eq!(paths(&picker), [PathBuf::from("one/two")]); + directory.put("one/a.txt", ""); + picker.refresh(); + assert_eq!(paths(&picker), [PathBuf::from("one")]); + assert!(!picker.entries().next().unwrap().expanded); + } } diff --git a/src/main.rs b/src/main.rs index 2b436e6..2ec41de 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,8 +10,9 @@ Default keys (configurable with config.bind): All modes: Ctrl+p command palette; type to filter, Ctrl+j/k or arrows select, Enter accept, Esc cancel Normal/Visual: Space is leader; Space Space opens palette; Esc cancels prefix Normal leader: Space w saves; Space b n/p/l/d next/previous/list/close buffer -Files: Space o floating picker; Space e docked explorer (right by default) - type to filter, Ctrl+j/k or arrows select, Enter opens, Esc returns, Ctrl+w closes, Ctrl+r refreshes +Files: Space o floating list; Space e docked compact tree (right by default) + Ctrl+j/k select; tree Ctrl+l/h expand/parent, Enter toggles/opens + / tree search, Esc browse/edit, Ctrl+t view, Ctrl+w close, Ctrl+r refresh Normal: i/a insert, I/A insert at first nonblank/end, o/O new line h/j/k/l move, 0/$ line ends, x delete, J join lines w/b/e word next/previous/end, W/B/E whitespace-delimited WORDs diff --git a/src/ruby.rs b/src/ruby.rs index bdb5457..1741bb9 100644 --- a/src/ruby.rs +++ b/src/ruby.rs @@ -19,6 +19,8 @@ pub struct Config { pub bindings: Vec, pub leader: String, pub file_explorer_side: String, + pub file_picker_view: String, + pub file_explorer_view: String, } impl Default for Config { @@ -28,6 +30,8 @@ impl Default for Config { bindings: Vec::new(), leader: "space".into(), file_explorer_side: "right".into(), + file_picker_view: "list".into(), + file_explorer_view: "tree".into(), } } } @@ -44,6 +48,8 @@ pub fn load_config(path: &Path) -> Result { bindings, leader, file_explorer_side, + file_picker_view, + file_explorer_view, .. } => { let mut config = Config::default(); @@ -57,6 +63,8 @@ pub fn load_config(path: &Path) -> Result { config.bindings = bindings; config.leader = leader; config.file_explorer_side = file_explorer_side; + config.file_picker_view = file_picker_view; + config.file_explorer_view = file_explorer_view; Ok(config) } Response::Error { @@ -203,6 +211,8 @@ enum Response { bindings: Vec, leader: String, file_explorer_side: String, + file_picker_view: String, + file_explorer_view: String, }, #[serde(rename = "result")] Result { @@ -598,9 +608,9 @@ end assert!(load_config(&path).unwrap_err().contains(error), "{value}"); } for frame in [ - r#"{"type":"config","version":1,"plugins":[],"bindings":[],"file_explorer_side":"right"}"#, - r#"{"type":"config","version":1,"plugins":[],"bindings":[],"leader":null,"file_explorer_side":"right"}"#, - r#"{"type":"config","version":1,"plugins":[],"bindings":[],"leader":7,"file_explorer_side":"right"}"#, + r#"{"type":"config","version":1,"plugins":[],"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree"}"#, + r#"{"type":"config","version":1,"plugins":[],"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree"}"#, + r#"{"type":"config","version":1,"plugins":[],"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree"}"#, ] { assert!(serde_json::from_str::(frame).is_err(), "{frame}"); } diff --git a/src/ruby_host.rb b/src/ruby_host.rb index 01172cd..e0d3296 100644 --- a/src/ruby_host.rb +++ b/src/ruby_host.rb @@ -38,7 +38,7 @@ module Vamp end module Modes - %w[normal insert command search visual selection].each do |name| + %w[normal insert command search visual selection tree].each do |name| mode = Mode.new(name.freeze).freeze define_singleton_method(name) { mode } end @@ -53,6 +53,8 @@ module Vamp @bindings = [] @leader = "space" @file_explorer_side = "right" + @file_picker_view = "list" + @file_explorer_view = "tree" end def plugin(path) @@ -74,6 +76,18 @@ module Vamp @file_explorer_side = side end + def file_picker_view=(view) + view = Vamp.string!(view, "file picker view") + raise ArgumentError, "file picker view must not be empty" if view.empty? + @file_picker_view = view + end + + def file_explorer_view=(view) + view = Vamp.string!(view, "file explorer view") + raise ArgumentError, "file explorer view must not be empty" if view.empty? + @file_explorer_view = view + end + def core Core end @@ -106,7 +120,9 @@ module Vamp def response { "version" => 1, "type" => "config", "plugins" => @plugins, "bindings" => @bindings, "leader" => @leader, - "file_explorer_side" => @file_explorer_side } + "file_explorer_side" => @file_explorer_side, + "file_picker_view" => @file_picker_view, + "file_explorer_view" => @file_explorer_view } end end diff --git a/src/session.rs b/src/session.rs index 401e288..05b43c1 100644 --- a/src/session.rs +++ b/src/session.rs @@ -42,6 +42,18 @@ impl EditorSession { config.file_explorer_side )); } + if let Err(error) = editor.set_file_picker_view(&config.file_picker_view) { + notices.push(format!( + "file picker view {:?}: {error}", + config.file_picker_view + )); + } + if let Err(error) = editor.set_file_explorer_view(&config.file_explorer_view) { + notices.push(format!( + "file explorer view {:?}: {error}", + config.file_explorer_view + )); + } let mut plugins = BTreeMap::new(); for path in config.plugins { match RubyPlugin::start(&path) { @@ -233,11 +245,22 @@ end session.editor().file_picker().unwrap().presentation, crate::files::FilePresentation::Docked ); + assert_eq!( + session.editor().file_picker().unwrap().view, + crate::files::FileView::Tree + ); + assert!(!session.editor().file_picker().unwrap().searching); + session.handle(Input::Char('/')); for ch in "dir e\u{301}".chars() { session.handle(Input::Char(ch)); } session.handle(Input::Backspace); assert_eq!(session.editor().file_picker().unwrap().query, "dir "); + session.handle(Input::Ctrl('h')); + assert_eq!(session.editor().file_picker().unwrap().query, "dir "); + session.handle(Input::Escape); + assert!(!session.editor().file_picker().unwrap().searching); + assert!(session.editor().file_picker().unwrap().focused); session.handle(Input::Escape); assert!(!session.editor().file_picker().unwrap().focused); session.handle(Input::Char('i')); @@ -251,7 +274,11 @@ end session.editor().file_picker().unwrap().presentation, crate::files::FilePresentation::Floating ); - assert_eq!(session.editor().file_picker().unwrap().query, "dir "); + assert_eq!( + session.editor().file_picker().unwrap().view, + crate::files::FileView::List + ); + assert_eq!(session.editor().file_picker().unwrap().query, ""); session.handle(Input::Escape); assert!(session.editor().file_picker().is_none()); session.handle(Input::Char('u')); @@ -259,13 +286,73 @@ end } #[test] - fn invalid_explorer_side_preserves_default_and_valid_bindings() { + fn configured_flat_dock_and_tree_picker_keep_control_h_distinct_from_backspace() { + let fixture = Fixture::new(); + fixture.file("buffer.txt", "alpha"); + let mut session = fixture.open( + r#" +Vamp.configure do |config| + config.file_explorer_view = "list" + config.file_picker_view = "tree" + config.bind "ctrl+f", config.core.right, mode: config.modes.tree +end +"#, + ); + session.handle(Input::Char(' ')); + session.handle(Input::Char('e')); + assert_eq!( + session.editor().file_picker().unwrap().view, + crate::files::FileView::List + ); + for ch in "e\u{301}".chars() { + session.handle(Input::Char(ch)); + } + session.handle(Input::Ctrl('h')); + assert_eq!(session.editor().file_picker().unwrap().query, "e\u{301}"); + session.handle(Input::Backspace); + assert_eq!(session.editor().file_picker().unwrap().query, ""); + session.handle(Input::Ctrl('w')); + session.handle(Input::Char(' ')); + session.handle(Input::Char('o')); + assert_eq!( + session.editor().file_picker().unwrap().view, + crate::files::FileView::Tree + ); + assert!(!session.editor().file_picker().unwrap().searching); + session.handle(Input::Char('x')); + assert_eq!(session.editor().file_picker().unwrap().query, ""); + session.handle(Input::Char('/')); + session.handle(Input::Char('x')); + session.handle(Input::Ctrl('h')); + assert_eq!(session.editor().file_picker().unwrap().query, "x"); + session.handle(Input::Escape); + assert!(!session.editor().file_picker().unwrap().searching); + assert!(session.editor().file_picker().unwrap().focused); + session.handle(Input::Ctrl('t')); + assert_eq!( + session.editor().file_picker().unwrap().view, + crate::files::FileView::List + ); + session.handle(Input::Escape); + assert!(session.editor().file_picker().is_none()); + session.handle(Input::Char('i')); + session.handle(Input::Char('x')); + session.handle(Input::Ctrl('h')); + assert_eq!(session.editor().lines(), &["xalpha"]); + session.handle(Input::Backspace); + assert_eq!(session.editor().lines(), &["alpha"]); + } + + #[test] + fn invalid_explorer_preferences_preserve_defaults_and_valid_bindings() { let fixture = Fixture::new(); fixture.file("buffer.txt", "abc"); let mut session = fixture.open( r#" Vamp.configure do |config| config.file_explorer_side = "diagonal" + config.file_explorer_view = "grid" + config.file_picker_view = "grid" config.bind "n", config.core.right end "#, @@ -274,6 +361,14 @@ end session.editor().file_explorer_side(), crate::files::DockSide::Right ); + assert_eq!( + session.editor().file_explorer_view(), + crate::files::FileView::Tree + ); + assert_eq!( + session.editor().file_picker_view(), + crate::files::FileView::List + ); session.handle(Input::Char('n')); assert_eq!( session.editor().cursor(), @@ -1101,6 +1196,30 @@ end r#"config.file_explorer_side = """#, "file explorer side must not be empty", ), + ( + "config.file_picker_view = 7", + "file picker view must be a String", + ), + ( + "config.file_picker_view = nil", + "file picker view must be a String", + ), + ( + r#"config.file_picker_view = """#, + "file picker view must not be empty", + ), + ( + "config.file_explorer_view = 7", + "file explorer view must be a String", + ), + ( + "config.file_explorer_view = nil", + "file explorer view must be a String", + ), + ( + r#"config.file_explorer_view = """#, + "file explorer view must not be empty", + ), (r#"config.bind "n", "core.insert""#, "TypeError"), (r#"config.bind "n", nil, mode: "insert""#, "TypeError"), (r#"config.bind "n", config.modes.insert"#, "TypeError"), diff --git a/src/tui.rs b/src/tui.rs index 6232eea..3794a2a 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -28,7 +28,7 @@ use unicode_width::UnicodeWidthStr; use crate::{ editor::{Editor, Input, Mode, Palette}, - files::{DockSide, FilePicker, FilePresentation}, + files::{DockSide, FilePicker, FilePresentation, FileView}, session::EditorSession, }; @@ -427,9 +427,9 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) { ]) .areas(column); frame.render_widget(Clear, panel); - draw_file_list(frame, picker, panel, editor.selection_navigation_hint()); + draw_file_list(frame, picker, panel, editor.file_navigation_hint()); } else if let Some(panel) = dock { - draw_file_list(frame, picker, panel, editor.selection_navigation_hint()); + draw_file_list(frame, picker, panel, editor.file_navigation_hint()); } } if let Some(palette) = editor.palette() { @@ -492,9 +492,14 @@ fn draw_file_list(frame: &mut Frame, picker: &FilePicker, panel: Rect, navigatio Color::DarkGray }; let bordered = panel.width >= 4 && panel.height >= 4; - let title = match picker.presentation { - FilePresentation::Floating => " Files ", - FilePresentation::Docked => " Explorer ", + let browsing = picker.view == FileView::Tree && !picker.searching; + let title = match (picker.presentation, picker.view, browsing) { + (FilePresentation::Floating, FileView::List, _) => " Files · List ", + (FilePresentation::Docked, FileView::List, _) => " Explorer · List ", + (FilePresentation::Floating, FileView::Tree, true) => " Files · Tree browse ", + (FilePresentation::Docked, FileView::Tree, true) => " Explorer · Tree browse ", + (FilePresentation::Floating, FileView::Tree, false) => " Files · Tree search ", + (FilePresentation::Docked, FileView::Tree, false) => " Explorer · Tree search ", }; let block = Block::default() .borders(if bordered { @@ -519,10 +524,18 @@ fn draw_file_list(frame: &mut Frame, picker: &FilePicker, panel: Rect, navigatio Constraint::Length(u16::from(show_help)), ]) .areas(content); - let query = format!("> {}", display_text(&picker.query)); + let query = if browsing { + "browse · / search · ^T view".into() + } else { + format!("> {}", display_text(&picker.query)) + }; let query_width = display_column(&query, usize::MAX); let width = usize::from(query_area.width); - let offset = query_width.saturating_sub(width.saturating_sub(1)); + let offset = if browsing { + 0 + } else { + query_width.saturating_sub(width.saturating_sub(1)) + }; frame.render_widget( Paragraph::new(visible_line(&query, offset, width)).style(Style::default().fg( if picker.focused { @@ -534,7 +547,8 @@ fn draw_file_list(frame: &mut Frame, picker: &FilePicker, panel: Rect, navigatio query_area, ); if picker.focused && query_area.width > 0 && query_area.height > 0 { - frame.set_cursor_position((query_area.x + (query_width - offset) as u16, query_area.y)); + let cursor_x = if browsing { 0 } else { query_width - offset }; + frame.set_cursor_position((query_area.x + cursor_x as u16, query_area.y)); } if show_root { frame.render_widget( @@ -562,13 +576,14 @@ fn draw_file_list(frame: &mut Frame, picker: &FilePicker, panel: Rect, navigatio ); } else if results_area.height > 0 { let count = usize::from(results_area.height); - let first = picker.selected.saturating_sub(count - 1); + let selected_index = picker.selected.min(entries.len() - 1); + let first = selected_index.saturating_sub(count - 1); let lines: Vec = entries .enumerate() .skip(first) .take(count) .map(|(index, entry)| { - let selected = index == picker.selected; + let selected = index == selected_index; let style = if selected { Style::default() .bg(if picker.focused { @@ -580,22 +595,47 @@ fn draw_file_list(frame: &mut Frame, picker: &FilePicker, panel: Rect, navigatio } else { Style::default() }; - Line::styled( - format!("{} {}", if selected { ">" } else { " " }, entry.label), - style, - ) + let label = match picker.view { + FileView::List => { + format!("{} {}", if selected { ">" } else { " " }, entry.label) + } + FileView::Tree => format!( + "{} {:indent$}{} {}", + if selected { ">" } else { " " }, + "", + if entry.is_dir { + if entry.expanded { "▾" } else { "▸" } + } else { + " " + }, + entry.label, + indent = entry.depth * 2, + ), + }; + Line::styled(label, style) }) .collect(); frame.render_widget(Paragraph::new(lines), results_area); + if browsing && picker.focused && results_area.width > 0 { + frame.set_cursor_position(( + results_area.x, + results_area.y + (selected_index - first) as u16, + )); + } } let help = if picker.focused { let controls = match picker.presentation { FilePresentation::Floating => "Enter · Esc/^W close · ^R scan", FilePresentation::Docked => "Enter · Esc/Tab edit · ^W · ^R", }; - format!("{navigation_hint} · {controls}") + let view_controls = if picker.view == FileView::Tree { + "/ search · ^T view" + } else { + "^T view" + }; + format!("{navigation_hint} · {view_controls} · {controls}") } else { - "Editor focus · invoke explorer to search".into() + "Editor focus · invoke explorer to browse".into() }; frame.render_widget( Paragraph::new(help).style(Style::default().fg(Color::DarkGray)), @@ -722,6 +762,7 @@ mod tests { fn file(&self, name: &str, text: &str) -> PathBuf { let path = self.0.join(name); + fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write(&path, text).unwrap(); path } @@ -764,6 +805,21 @@ mod tests { KeyModifiers::CONTROL, Some(Input::Ctrl('k')), ), + ( + KeyCode::Char('h'), + KeyModifiers::CONTROL, + Some(Input::Ctrl('h')), + ), + ( + KeyCode::Char('l'), + KeyModifiers::CONTROL, + Some(Input::Ctrl('l')), + ), + ( + KeyCode::Backspace, + KeyModifiers::NONE, + Some(Input::Backspace), + ), (KeyCode::Enter, KeyModifiers::NONE, Some(Input::Enter)), (KeyCode::Enter, KeyModifiers::CONTROL, Some(Input::Enter)), ( @@ -793,16 +849,11 @@ mod tests { editor.handle(translate(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)).unwrap()); assert_eq!(editor.file_picker().unwrap().selected, count - 2); let picker = editor.file_picker().unwrap(); - let label = picker.entries().nth(picker.selected).unwrap().label.clone(); + let label = picker.entries().nth(picker.selected).unwrap().label; let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 9)).unwrap(); terminal .draw(|frame| { - draw_file_list( - frame, - picker, - frame.area(), - editor.selection_navigation_hint(), - ) + draw_file_list(frame, picker, frame.area(), editor.file_navigation_hint()) }) .unwrap(); let text = screen_text(&terminal); @@ -815,6 +866,7 @@ mod tests { #[test] fn list_footers_show_remapped_and_disabled_selection_keys() { let mut editor = Editor::open(None, vec![]).unwrap(); + editor.set_file_explorer_view("list").unwrap(); for key in ["ctrl+j", "ctrl+n", "down", "ctrl+k", "up"] { editor.bind("selection", key, None).unwrap(); } @@ -854,15 +906,20 @@ mod tests { } let special = directory.file("界e\u{301} space\nname", "text"); let editor = Editor::open(None, vec![]).unwrap(); - let mut picker = FilePicker::scan(directory.0.clone(), FilePresentation::Floating).unwrap(); + let mut picker = FilePicker::scan( + directory.0.clone(), + FilePresentation::Floating, + FileView::List, + ) + .unwrap(); assert!( picker .entries() - .any(|entry| directory.0.join(&entry.path) == special) + .any(|entry| directory.0.join(entry.path) == special) ); let count = picker.entries().len(); picker.selected = count - 1; - let label = picker.entries().last().unwrap().label.clone(); + let label = picker.entries().last().unwrap().label; assert_eq!(label, "界e\u{301} space�name"); picker.query = "long\t界e\u{301}z".into(); let area = Rect::new(3, 2, 24, 9); @@ -902,7 +959,12 @@ mod tests { fn file_list_renders_root_empty_filter_errors_and_idle_focus() { let directory = Directory::new(); let editor = Editor::open(None, vec![]).unwrap(); - let mut picker = FilePicker::scan(directory.0.clone(), FilePresentation::Docked).unwrap(); + let mut picker = FilePicker::scan( + directory.0.clone(), + FilePresentation::Docked, + FileView::List, + ) + .unwrap(); let area = Rect::new(0, 0, 100, 12); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap(); terminal @@ -943,13 +1005,41 @@ mod tests { assert!(screen_text(&terminal).contains("Files")); assert!(screen_text(&terminal).contains("Esc/^W")); assert_eq!(terminal.backend().buffer()[(0, 0)].fg, Color::Cyan); + picker.set_view(FileView::Tree); + picker.cancel_search(); + terminal + .draw(|frame| draw_file_list(frame, &picker, area, editor.file_navigation_hint())) + .unwrap(); + let text = screen_text(&terminal); + assert!(text.contains("Tree browse"), "{text}"); + assert!(text.contains("No project files"), "{text}"); + assert_eq!( + terminal.get_cursor_position().unwrap(), + ratatui::layout::Position::new(1, 1) + ); + picker.focused = false; + terminal + .draw(|frame| { + frame.set_cursor_position((99, 11)); + draw_file_list(frame, &picker, area, editor.file_navigation_hint()); + }) + .unwrap(); + assert_eq!( + terminal.get_cursor_position().unwrap(), + ratatui::layout::Position::new(99, 11) + ); } #[test] fn file_query_cursor_and_dock_layout_survive_tiny_offset_resizes() { let directory = Directory::new(); let editor = Editor::open(None, vec![]).unwrap(); - let mut picker = FilePicker::scan(directory.0.clone(), FilePresentation::Docked).unwrap(); + let mut picker = FilePicker::scan( + directory.0.clone(), + FilePresentation::Docked, + FileView::List, + ) + .unwrap(); picker.query = "long界e\u{301}z".into(); for (width, height) in [(12, 6), (8, 2), (1, 1), (1, 2), (8, 1), (12, 6)] { let area = Rect::new(3, 2, width, height); @@ -1029,6 +1119,378 @@ mod tests { } } + #[test] + fn tree_rows_render_directory_chains_hierarchy_and_basename_files_in_both_presentations() { + use std::path::Path; + let directory = Directory::new(); + directory.file("chain/a/b/leaf.txt", ""); + directory.file("mixed/nested/inner.txt", ""); + directory.file("mixed/sibling.txt", ""); + directory.file("single/only.txt", ""); + directory.file("root.txt", ""); + fs::create_dir(directory.0.join("empty")).unwrap(); + let editor = Editor::open(None, vec![]).unwrap(); + for presentation in [FilePresentation::Floating, FilePresentation::Docked] { + let mut picker = + FilePicker::scan(directory.0.clone(), presentation, FileView::Tree).unwrap(); + assert_eq!(picker.entries().next().unwrap().label, "chain/a/b"); + for path in ["chain/a/b", "mixed", "mixed/nested", "single"] { + let selected = picker + .entries() + .position(|row| row.path == Path::new(path)) + .unwrap(); + picker.selected = selected; + picker.right(); + } + let area = Rect::new(3, 2, 100, 22); + let mut terminal = Terminal::with_options( + ratatui::backend::TestBackend::new(110, 28), + ratatui::TerminalOptions { + viewport: ratatui::Viewport::Fixed(area), + }, + ) + .unwrap(); + terminal + .draw(|frame| draw_file_list(frame, &picker, area, editor.file_navigation_hint())) + .unwrap(); + let text = screen_text(&terminal); + for label in [ + " ▾ chain/a/b", + " leaf.txt", + " ▸ empty", + " ▾ mixed", + " ▾ nested", + " inner.txt", + " sibling.txt", + "> ▾ single", + " only.txt", + " root.txt", + "/ search · ^T view", + ] { + assert!(text.contains(label), "{label}: {text}"); + } + assert!(!text.contains("single/only.txt"), "{text}"); + assert!(!text.contains("mixed/nested/inner.txt"), "{text}"); + let cursor = terminal.get_cursor_position().unwrap(); + assert_eq!( + terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(), + ">" + ); + assert_eq!( + terminal.backend().buffer()[(cursor.x, cursor.y)].bg, + Color::Blue + ); + picker.set_view(FileView::List); + terminal + .draw(|frame| draw_file_list(frame, &picker, area, editor.file_navigation_hint())) + .unwrap(); + let text = screen_text(&terminal); + assert!(text.contains("List"), "{text}"); + assert!(text.contains("chain/a/b/leaf.txt"), "{text}"); + assert!(text.contains("single/only.txt"), "{text}"); + assert!(!text.contains("▸"), "{text}"); + assert_eq!(terminal.get_cursor_position().unwrap().y, area.y + 1); + } + } + + #[test] + fn tree_browse_cursor_tracks_window_and_bounds_empty_search_and_tiny_offset_panels() { + let directory = Directory::new(); + for index in 0..18 { + directory.file(&format!("dir-{index:02}/file.txt"), ""); + } + let editor = Editor::open(None, vec![]).unwrap(); + let mut picker = FilePicker::scan( + directory.0.clone(), + FilePresentation::Docked, + FileView::Tree, + ) + .unwrap(); + for _ in 0..17 { + picker.navigate(true); + } + picker.right(); + picker.right(); + for (width, height) in [(100, 9), (12, 6), (8, 2), (1, 1), (1, 2), (8, 1), (100, 9)] { + let area = Rect::new(3, 2, width, height); + let mut terminal = Terminal::with_options( + ratatui::backend::TestBackend::new(110, 16), + ratatui::TerminalOptions { + viewport: ratatui::Viewport::Fixed(area), + }, + ) + .unwrap(); + terminal + .draw(|frame| draw_file_list(frame, &picker, area, editor.file_navigation_hint())) + .unwrap(); + let cursor = terminal.get_cursor_position().unwrap(); + assert!(area.contains(cursor), "{area:?}: {cursor:?}"); + let has_results = height > if width >= 4 && height >= 4 { 3 } else { 1 }; + if has_results { + assert_eq!( + terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(), + ">" + ); + } else { + assert_eq!(cursor, ratatui::layout::Position::new(area.x, area.y)); + } + if width == 100 { + let text = screen_text(&terminal); + assert!(text.contains("> file.txt"), "{text}"); + assert!(!text.contains("dir-00"), "{text}"); + } + picker.start_search(); + picker.query = "missing".into(); + picker.refilter(); + terminal + .draw(|frame| draw_file_list(frame, &picker, area, editor.file_navigation_hint())) + .unwrap(); + let cursor = terminal.get_cursor_position().unwrap(); + assert!(area.contains(cursor), "{area:?}: {cursor:?}"); + assert_eq!(cursor.y, area.y + u16::from(width >= 4 && height >= 4)); + picker.cancel_search(); + assert_eq!( + picker.entries().nth(picker.selected).unwrap().path, + std::path::Path::new("dir-17/file.txt") + ); + } + } + + #[test] + fn default_explorer_tree_search_and_escape_restore_browse_rows_cursor_and_editor_state() { + let directory = Directory::new(); + directory.file("src/inner/needle.txt", ""); + directory.file("src/other.txt", ""); + directory.file("root.txt", ""); + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.set_project_root(directory.0.clone()); + editor + .apply(vec![crate::editor::Effect::Replace("unchanged".into())]) + .unwrap(); + editor.handle(Input::Char('v')); + let mode = editor.mode(); + let selections = editor.selections(); + editor.handle(Input::Char(' ')); + editor.handle(Input::Char('e')); + assert_eq!(editor.file_picker().unwrap().view, FileView::Tree); + editor.handle(Input::Char('z')); + assert_eq!(editor.file_picker().unwrap().query, ""); + editor.handle(Input::Ctrl('l')); + editor.handle(Input::Ctrl('l')); + let picker = editor.file_picker().unwrap(); + let selected = picker + .entries() + .nth(picker.selected) + .unwrap() + .path + .to_owned(); + let before: Vec<_> = picker + .entries() + .map(|row| (row.path.to_owned(), row.expanded)) + .collect(); + assert_eq!(selected, std::path::Path::new("src/inner")); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap(); + let (mut top, mut left) = (0, 0); + terminal + .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .unwrap(); + let browse_cursor = terminal.get_cursor_position().unwrap(); + editor.handle(Input::Char('/')); + assert!(editor.file_picker().unwrap().searching); + assert_eq!(editor.file_picker().unwrap().query, ""); + assert!( + editor + .file_picker() + .unwrap() + .entries() + .any(|row| row.path == std::path::Path::new("src/inner/needle.txt")) + ); + editor.handle(Input::Ctrl('t')); + assert_eq!(editor.file_picker().unwrap().view, FileView::List); + editor.handle(Input::Ctrl('t')); + assert_eq!(editor.file_picker().unwrap().view, FileView::Tree); + assert!(editor.file_picker().unwrap().searching); + for ch in "src/inner/needle.txt".chars() { + editor.handle(Input::Char(ch)); + } + editor.handle(Input::Ctrl('h')); + assert_eq!(editor.file_picker().unwrap().query, "src/inner/needle.txt"); + let picker = editor.file_picker().unwrap(); + assert_eq!( + picker.entries().nth(picker.selected).unwrap().path, + std::path::Path::new("src/inner/needle.txt") + ); + editor.handle(Input::Ctrl('j')); + terminal + .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .unwrap(); + let text = screen_text(&terminal); + assert!(text.contains("Tree search"), "{text}"); + assert!(text.contains("needle.txt"), "{text}"); + assert_eq!(terminal.get_cursor_position().unwrap().y, 1); + editor.handle(Input::Escape); + let picker = editor.file_picker().unwrap(); + assert!(picker.focused && !picker.searching && picker.query.is_empty()); + assert_eq!( + picker.entries().nth(picker.selected).unwrap().path, + selected.as_path() + ); + assert_eq!( + picker + .entries() + .map(|row| (row.path.to_owned(), row.expanded)) + .collect::>(), + before + ); + terminal + .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .unwrap(); + assert_eq!(terminal.get_cursor_position().unwrap(), browse_cursor); + editor.handle(Input::Escape); + assert!(!editor.file_picker().unwrap().focused); + assert_eq!(editor.mode(), mode); + assert_eq!(editor.selections(), selections); + assert_eq!(editor.lines(), ["unchanged"]); + editor.handle(Input::Char(' ')); + editor.handle(Input::Char('e')); + editor.handle(Input::Char('/')); + editor.handle(Input::Char('n')); + editor.handle(Input::Char('\t')); + assert!(!editor.file_picker().unwrap().focused); + editor.handle(Input::Char(' ')); + editor.handle(Input::Char('e')); + assert!(editor.file_picker().unwrap().searching); + assert_eq!(editor.file_picker().unwrap().query, "n"); + editor.handle(Input::Ctrl('t')); + assert_eq!(editor.file_picker().unwrap().view, FileView::List); + assert_eq!(editor.file_picker().unwrap().query, "n"); + terminal + .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .unwrap(); + assert!(screen_text(&terminal).contains("List")); + assert_eq!(terminal.get_cursor_position().unwrap().y, 1); + editor.handle(Input::Ctrl('t')); + assert_eq!(editor.file_picker().unwrap().view, FileView::Tree); + assert!(editor.file_picker().unwrap().searching); + assert_eq!(editor.file_picker().unwrap().query, "n"); + terminal + .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .unwrap(); + assert!(screen_text(&terminal).contains("Tree search")); + assert_eq!(terminal.get_cursor_position().unwrap().y, 1); + editor.handle(Input::Escape); + assert!(editor.file_picker().unwrap().focused); + assert!(!editor.file_picker().unwrap().searching); + } + + #[test] + fn configured_views_and_view_toggle_render_in_both_presentations_with_scoped_hints() { + let directory = Directory::new(); + directory.file("folder/file.txt", ""); + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.set_project_root(directory.0.clone()); + editor.set_file_picker_view("tree").unwrap(); + editor.set_file_explorer_view("list").unwrap(); + editor.set_file_explorer_side("bottom").unwrap(); + editor.bind("tree", "ctrl+l", None).unwrap(); + editor.bind("tree", "right", None).unwrap(); + editor.bind("tree", "x", Some("core.right".into())).unwrap(); + for key in ["ctrl+j", "ctrl+n", "down", "ctrl+k", "up"] { + editor.bind("selection", key, None).unwrap(); + } + editor + .bind("selection", "j", Some("core.down".into())) + .unwrap(); + editor + .bind("selection", "k", Some("core.up".into())) + .unwrap(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(200, 30)).unwrap(); + let (mut top, mut left) = (0, 0); + for (key, expected) in [('o', FileView::Tree), ('e', FileView::List)] { + editor.handle(Input::Char(' ')); + editor.handle(Input::Char(key)); + assert_eq!(editor.file_picker().unwrap().view, expected); + for view in [ + expected, + if expected == FileView::Tree { + FileView::List + } else { + FileView::Tree + }, + ] { + assert_eq!(editor.file_picker().unwrap().view, view); + terminal + .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .unwrap(); + let text = screen_text(&terminal); + assert!( + text.contains(if view == FileView::Tree { + "Tree browse" + } else { + "List" + }), + "{text}" + ); + assert!(text.contains("^T view"), "{text}"); + assert!(text.contains(editor.file_navigation_hint()), "{text}"); + if view == FileView::Tree { + assert_ne!( + editor.file_navigation_hint(), + editor.selection_navigation_hint() + ); + assert!(text.contains("/ search"), "{text}"); + let cursor = terminal.get_cursor_position().unwrap(); + assert_eq!( + terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(), + ">" + ); + editor.handle(Input::Char('x')); + assert_eq!(editor.file_picker().unwrap().query, ""); + assert!( + editor + .file_picker() + .unwrap() + .entries() + .next() + .unwrap() + .expanded + ); + let picker = editor.file_picker().unwrap(); + let was_file = !picker.entries().nth(picker.selected).unwrap().is_dir; + editor.handle(Input::Ctrl('h')); + if was_file { + let picker = editor.file_picker().unwrap(); + assert!(picker.entries().nth(picker.selected).unwrap().is_dir); + assert!(picker.entries().next().unwrap().expanded); + editor.handle(Input::Ctrl('h')); + } + assert!( + !editor + .file_picker() + .unwrap() + .entries() + .next() + .unwrap() + .expanded + ); + } else { + assert_eq!( + editor.file_navigation_hint(), + editor.selection_navigation_hint() + ); + assert!(text.contains("folder/file.txt"), "{text}"); + } + editor.handle(Input::Ctrl('t')); + } + editor.handle(Input::Escape); + } + editor.handle(Input::Ctrl('p')); + terminal + .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .unwrap(); + assert!(screen_text(&terminal).contains(editor.selection_navigation_hint())); + } + #[test] fn all_dock_sides_clip_editor_gutter_selection_carets_and_cursor() { let mut editor = Editor::open(None, vec![]).unwrap(); @@ -2245,6 +2707,10 @@ mod tests { Input::Ctrl('k'), Input::Ctrl('j'), Input::Ctrl('k'), + Input::Ctrl('h'), + Input::Ctrl('l'), + Input::Backspace, + Input::Backspace, Input::Enter, Input::Enter, ] @@ -2257,6 +2723,10 @@ mod tests { let input = translate(key).unwrap(); assert_eq!(input, expected); if index == 4 { + for ch in "ab".chars() { + editor.handle(Input::Char(ch)); + } + } else if index == 8 { for ch in "q!".chars() { editor.handle(Input::Char(ch)); } @@ -2265,13 +2735,16 @@ mod tests { match index { 0 | 2 => assert_eq!(editor.palette().unwrap().selected, 1), 1 | 3 => assert_eq!(editor.palette().unwrap().selected, 0), - 4 => { + 4 | 5 => assert_eq!(editor.palette().unwrap().query, "ab"), + 6 => assert_eq!(editor.palette().unwrap().query, "a"), + 7 => assert_eq!(editor.palette().unwrap().query, ""), + 8 => { assert!(editor.palette().is_none()); assert_eq!(editor.mode(), Mode::Command); assert_eq!(editor.command_line(), "q! "); assert!(!editor.should_quit()); } - 5 => assert!(editor.should_quit()), + 9 => assert!(editor.should_quit()), _ => unreachable!(), } assert_eq!(editor.lines(), original); @@ -2374,7 +2847,7 @@ PTY.spawn(*ARGV) do |output, input, pid| STDOUT.write(chunk) captured << chunk if !sent_keys && captured.include?("READING_KEYS") - input.write("\e[106;5u\e[107;5u\n\v\e[13u\r") + input.write("\e[106;5u\e[107;5u\n\v\b\f\x7f\x7f\e[13u\r") input.flush sent_keys = true end